feat: add onboarding status and dismiss endpoints with tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
110
backend/app/api/endpoints/onboarding.py
Normal file
110
backend/app/api/endpoints/onboarding.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Onboarding status endpoints — track new-user checklist progress."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.database import get_db
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session import Session
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import OnboardingStatus
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["onboarding"])
|
||||
|
||||
|
||||
@router.get("/onboarding-status", response_model=OnboardingStatus)
|
||||
async def get_onboarding_status(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStatus:
|
||||
"""Return onboarding checklist completion status for the current user."""
|
||||
|
||||
# created_flow — user owns at least 1 tree
|
||||
created_flow_q = await db.execute(
|
||||
select(func.count()).select_from(Tree).where(Tree.author_id == current_user.id).limit(1)
|
||||
)
|
||||
created_flow = (created_flow_q.scalar() or 0) > 0
|
||||
|
||||
# ran_session — user has at least 1 session
|
||||
ran_session_q = await db.execute(
|
||||
select(func.count()).select_from(Session).where(Session.user_id == current_user.id).limit(1)
|
||||
)
|
||||
ran_session = (ran_session_q.scalar() or 0) > 0
|
||||
|
||||
# exported_session — user has at least 1 session with exported=True
|
||||
exported_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Session)
|
||||
.where(Session.user_id == current_user.id, Session.exported == True) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
exported_session = (exported_q.scalar() or 0) > 0
|
||||
|
||||
# tried_ai_assistant — user has at least 1 assistant chat
|
||||
ai_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(AssistantChat)
|
||||
.where(AssistantChat.user_id == current_user.id)
|
||||
.limit(1)
|
||||
)
|
||||
tried_ai_assistant = (ai_q.scalar() or 0) > 0
|
||||
|
||||
# Team-dependent checks
|
||||
is_team_user = current_user.team_id is not None
|
||||
|
||||
invited_teammate = False
|
||||
connected_psa = False
|
||||
|
||||
if is_team_user:
|
||||
# invited_teammate — team/account has more than 1 member
|
||||
if current_user.account_id:
|
||||
teammate_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(
|
||||
User.account_id == current_user.account_id,
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
invited_teammate = (teammate_q.scalar() or 0) > 1
|
||||
|
||||
# connected_psa — account has at least 1 PSA connection
|
||||
if current_user.account_id:
|
||||
psa_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PsaConnection)
|
||||
.where(PsaConnection.account_id == current_user.account_id)
|
||||
.limit(1)
|
||||
)
|
||||
connected_psa = (psa_q.scalar() or 0) > 0
|
||||
|
||||
return OnboardingStatus(
|
||||
created_flow=created_flow,
|
||||
ran_session=ran_session,
|
||||
exported_session=exported_session,
|
||||
tried_ai_assistant=tried_ai_assistant,
|
||||
invited_teammate=invited_teammate,
|
||||
connected_psa=connected_psa,
|
||||
is_team_user=is_team_user,
|
||||
dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
|
||||
async def dismiss_onboarding(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStatus:
|
||||
"""Dismiss the onboarding checklist for the current user."""
|
||||
current_user.onboarding_dismissed = True
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
# Return updated status (reuse the GET logic)
|
||||
return await get_onboarding_status(db=db, current_user=current_user)
|
||||
@@ -18,6 +18,7 @@ from app.api.endpoints import kb_accelerator
|
||||
from app.api.endpoints import beta_signup
|
||||
from app.api.endpoints import scripts
|
||||
from app.api.endpoints import integrations
|
||||
from app.api.endpoints import onboarding
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -61,3 +62,4 @@ api_router.include_router(kb_accelerator.router)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(scripts.router)
|
||||
api_router.include_router(integrations.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
|
||||
72
backend/tests/test_onboarding.py
Normal file
72
backend/tests/test_onboarding.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for onboarding status endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_status_fresh_user(client, auth_headers):
|
||||
"""Fresh user should have all onboarding items false."""
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["created_flow"] is False
|
||||
assert data["ran_session"] is False
|
||||
assert data["exported_session"] is False
|
||||
assert data["tried_ai_assistant"] is False
|
||||
assert data["invited_teammate"] is False
|
||||
assert data["connected_psa"] is False
|
||||
assert data["is_team_user"] is False
|
||||
assert data["dismissed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_dismiss(client, auth_headers):
|
||||
"""Dismiss endpoint should set dismissed to true."""
|
||||
# Verify starts as false
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dismissed"] is False
|
||||
|
||||
# Dismiss
|
||||
response = await client.post(
|
||||
"/api/v1/users/onboarding-status/dismiss",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dismissed"] is True
|
||||
|
||||
# Verify persisted
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dismissed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_created_flow_after_tree_creation(client, auth_headers, test_tree):
|
||||
"""After creating a tree, created_flow should be true."""
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["created_flow"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_requires_auth(client):
|
||||
"""Unauthenticated requests should be rejected."""
|
||||
response = await client.get("/api/v1/users/onboarding-status")
|
||||
assert response.status_code == 401
|
||||
|
||||
response = await client.post("/api/v1/users/onboarding-status/dismiss")
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user