From 75b32a4f5aeacdcdf527108c9cb9c547a0edb36b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:05:28 -0400 Subject: [PATCH] feat: add onboarding status and dismiss endpoints with tests Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/onboarding.py | 110 ++++++++++++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_onboarding.py | 72 ++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 backend/app/api/endpoints/onboarding.py create mode 100644 backend/tests/test_onboarding.py diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py new file mode 100644 index 00000000..fdb07cd8 --- /dev/null +++ b/backend/app/api/endpoints/onboarding.py @@ -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) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 3bc9afde..c8d96850 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py new file mode 100644 index 00000000..aa4f48d8 --- /dev/null +++ b/backend/tests/test_onboarding.py @@ -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