feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest
Persists welcome-wizard Step 1/2/3 progress for self-serve signup Phase 2. PATCH validates step cannot decrease, ignores `data` on action="skip", and is idempotent on re-PATCH of the same step. POST /users/me/onboarding-dismiss-rest backs the wizard's "Skip the rest" button. Both routes added to _EMAIL_VERIFICATION_ALLOWLIST and _SUBSCRIPTION_GUARD_ALLOWLIST so the wizard runs before email verification and during the trial. 4 integration tests cover field writes, skip semantics, decrease guard, and dismiss-rest. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -235,6 +235,7 @@ _SUBSCRIPTION_GUARD_ALLOWLIST = {
|
|||||||
"/api/v1/billing/portal-session",
|
"/api/v1/billing/portal-session",
|
||||||
"/api/v1/users/me",
|
"/api/v1/users/me",
|
||||||
"/api/v1/users/me/onboarding-step",
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -298,6 +299,8 @@ _EMAIL_VERIFICATION_ALLOWLIST = {
|
|||||||
"/api/v1/auth/email/verify",
|
"/api/v1/auth/email/verify",
|
||||||
"/api/v1/auth/password/change",
|
"/api/v1/auth/password/change",
|
||||||
"/api/v1/users/me",
|
"/api/v1/users/me",
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||||
"/api/v1/billing/state",
|
"/api/v1/billing/state",
|
||||||
"/api/v1/billing/checkout-session",
|
"/api/v1/billing/checkout-session",
|
||||||
"/api/v1/billing/portal-session",
|
"/api/v1/billing/portal-session",
|
||||||
|
|||||||
@@ -2,19 +2,24 @@
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.admin_database import get_admin_db
|
from app.core.admin_database import get_admin_db
|
||||||
|
from app.models.account import Account
|
||||||
from app.models.assistant_chat import AssistantChat
|
from app.models.assistant_chat import AssistantChat
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.onboarding import OnboardingStatus
|
from app.schemas.onboarding import (
|
||||||
|
OnboardingStatus,
|
||||||
|
OnboardingStepRequest,
|
||||||
|
OnboardingStepResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["onboarding"])
|
router = APIRouter(prefix="/users", tags=["onboarding"])
|
||||||
|
|
||||||
@@ -109,3 +114,98 @@ async def dismiss_onboarding(
|
|||||||
|
|
||||||
# Return updated status (reuse the GET logic)
|
# Return updated status (reuse the GET logic)
|
||||||
return await get_onboarding_status(db=db, current_user=current_user)
|
return await get_onboarding_status(db=db, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Welcome wizard endpoints (Phase 2)
|
||||||
|
#
|
||||||
|
# These persist Step 1/2/3 progress for the post-signup welcome wizard.
|
||||||
|
# Mounted on /users/me/* (the parent router prefix is /users) so the wizard
|
||||||
|
# can run before email verification and during trial.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse)
|
||||||
|
async def patch_onboarding_step(
|
||||||
|
body: OnboardingStepRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> OnboardingStepResponse:
|
||||||
|
"""Persist welcome-wizard progress for the current user.
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
- step=1 + complete writes accounts.name, accounts.team_size_bucket,
|
||||||
|
users.role_at_signup, then sets users.onboarding_step_completed=1.
|
||||||
|
- step=2 + complete writes accounts.primary_psa, then sets
|
||||||
|
users.onboarding_step_completed=2.
|
||||||
|
- step=3 + complete just sets users.onboarding_step_completed=3
|
||||||
|
(invites are POSTed separately).
|
||||||
|
- action="skip" ignores `data` entirely and only advances the step.
|
||||||
|
- The new step must be >= current onboarding_step_completed (None=>0);
|
||||||
|
otherwise 400. Idempotent re-PATCH of the same step succeeds.
|
||||||
|
"""
|
||||||
|
current_step = current_user.onboarding_step_completed or 0
|
||||||
|
if body.step < current_step:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"error": "step_cannot_decrease",
|
||||||
|
"current_step": current_step,
|
||||||
|
"requested_step": body.step,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.action == "complete" and body.data is not None and body.step in (1, 2):
|
||||||
|
# Load the user's account for field writes. Step 3 has no data writes.
|
||||||
|
account_result = await db.execute(
|
||||||
|
select(Account).where(Account.id == current_user.account_id)
|
||||||
|
)
|
||||||
|
account = account_result.scalar_one_or_none()
|
||||||
|
if account is None:
|
||||||
|
# Should never happen — user is required to have an account_id.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="account_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.step == 1:
|
||||||
|
data = body.data
|
||||||
|
if data.company_name is not None:
|
||||||
|
account.name = data.company_name
|
||||||
|
if data.team_size_bucket is not None:
|
||||||
|
account.team_size_bucket = data.team_size_bucket
|
||||||
|
if data.role_at_signup is not None:
|
||||||
|
current_user.role_at_signup = data.role_at_signup
|
||||||
|
elif body.step == 2:
|
||||||
|
data = body.data
|
||||||
|
if data.primary_psa is not None:
|
||||||
|
account.primary_psa = data.primary_psa
|
||||||
|
|
||||||
|
current_user.onboarding_step_completed = body.step
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return OnboardingStepResponse(
|
||||||
|
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||||
|
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse)
|
||||||
|
async def dismiss_onboarding_rest(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
) -> OnboardingStepResponse:
|
||||||
|
"""Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button.
|
||||||
|
|
||||||
|
Returns the same shape as the step PATCH so the frontend can update its
|
||||||
|
local store from a single response.
|
||||||
|
"""
|
||||||
|
current_user.onboarding_dismissed = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return OnboardingStepResponse(
|
||||||
|
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||||
|
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from pydantic import BaseModel
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class OnboardingStatus(BaseModel):
|
class OnboardingStatus(BaseModel):
|
||||||
@@ -10,3 +12,40 @@ class OnboardingStatus(BaseModel):
|
|||||||
connected_psa: bool
|
connected_psa: bool
|
||||||
is_team_user: bool
|
is_team_user: bool
|
||||||
dismissed: bool
|
dismissed: bool
|
||||||
|
|
||||||
|
|
||||||
|
# --- Welcome wizard (Phase 2) ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
TeamSizeBucket = Literal["1-2", "3-5", "6-10", "11-25", "26+"]
|
||||||
|
RoleAtSignup = Literal["owner", "lead_tech", "tech", "other"]
|
||||||
|
PrimaryPsa = Literal["connectwise", "autotask", "halopsa", "none"]
|
||||||
|
WizardStep = Literal[1, 2, 3]
|
||||||
|
WizardAction = Literal["complete", "skip"]
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingStepData(BaseModel):
|
||||||
|
"""Optional payload carried with `action="complete"` for steps 1 and 2.
|
||||||
|
|
||||||
|
Step 1 fields: company_name, team_size_bucket, role_at_signup
|
||||||
|
Step 2 fields: primary_psa
|
||||||
|
Step 3 has no data (invitations posted separately).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Step 1
|
||||||
|
company_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
team_size_bucket: Optional[TeamSizeBucket] = None
|
||||||
|
role_at_signup: Optional[RoleAtSignup] = None
|
||||||
|
# Step 2
|
||||||
|
primary_psa: Optional[PrimaryPsa] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingStepRequest(BaseModel):
|
||||||
|
step: WizardStep
|
||||||
|
action: WizardAction
|
||||||
|
data: Optional[OnboardingStepData] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingStepResponse(BaseModel):
|
||||||
|
onboarding_step_completed: Optional[int]
|
||||||
|
onboarding_dismissed: bool
|
||||||
|
|||||||
149
backend/tests/test_onboarding_step.py
Normal file
149
backend/tests/test_onboarding_step.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Tests for welcome-wizard onboarding-step endpoints (Phase 2)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_onboarding_step1_complete_writes_account_name_and_team_size_and_role(
|
||||||
|
client, auth_headers, test_db, test_user
|
||||||
|
):
|
||||||
|
"""Step 1 + complete writes account.name + team_size_bucket + user.role_at_signup
|
||||||
|
and advances onboarding_step_completed to 1."""
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"step": 1,
|
||||||
|
"action": "complete",
|
||||||
|
"data": {
|
||||||
|
"company_name": "Acme MSP",
|
||||||
|
"team_size_bucket": "3-5",
|
||||||
|
"role_at_signup": "owner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["onboarding_step_completed"] == 1
|
||||||
|
assert data["onboarding_dismissed"] is False
|
||||||
|
|
||||||
|
# Verify persisted writes
|
||||||
|
account_id = test_user["user_data"]["account_id"]
|
||||||
|
user_email = test_user["email"]
|
||||||
|
|
||||||
|
acct = (
|
||||||
|
await test_db.execute(select(Account).where(Account.id == account_id))
|
||||||
|
).scalar_one()
|
||||||
|
assert acct.name == "Acme MSP"
|
||||||
|
assert acct.team_size_bucket == "3-5"
|
||||||
|
|
||||||
|
user = (
|
||||||
|
await test_db.execute(select(User).where(User.email == user_email))
|
||||||
|
).scalar_one()
|
||||||
|
assert user.role_at_signup == "owner"
|
||||||
|
assert user.onboarding_step_completed == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_onboarding_step2_skip_advances_without_psa(
|
||||||
|
client, auth_headers, test_db, test_user
|
||||||
|
):
|
||||||
|
"""Step 2 + skip ignores data entirely and only advances the step counter
|
||||||
|
(no primary_psa write)."""
|
||||||
|
# Capture original account.primary_psa so we can assert it's untouched.
|
||||||
|
account_id = test_user["user_data"]["account_id"]
|
||||||
|
acct_before = (
|
||||||
|
await test_db.execute(select(Account).where(Account.id == account_id))
|
||||||
|
).scalar_one()
|
||||||
|
psa_before = acct_before.primary_psa # likely None
|
||||||
|
|
||||||
|
# Advance step 1 first so step 2 is allowed.
|
||||||
|
r1 = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"step": 1, "action": "skip"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
|
||||||
|
# Skip step 2 — even if data is present it must be ignored.
|
||||||
|
r2 = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"step": 2,
|
||||||
|
"action": "skip",
|
||||||
|
"data": {"primary_psa": "connectwise"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["onboarding_step_completed"] == 2
|
||||||
|
|
||||||
|
# Re-fetch account: primary_psa must NOT have been written.
|
||||||
|
test_db.expire_all()
|
||||||
|
acct_after = (
|
||||||
|
await test_db.execute(select(Account).where(Account.id == account_id))
|
||||||
|
).scalar_one()
|
||||||
|
assert acct_after.primary_psa == psa_before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_onboarding_step_cannot_decrease(client, auth_headers):
|
||||||
|
"""A step=2 PATCH followed by step=1 must return 400."""
|
||||||
|
# Advance to step 2.
|
||||||
|
r1 = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"step": 1, "action": "skip"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
r2 = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"step": 2, "action": "skip"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["onboarding_step_completed"] == 2
|
||||||
|
|
||||||
|
# Try to go back to step 1 — must fail.
|
||||||
|
r3 = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"step": 1, "action": "skip"},
|
||||||
|
)
|
||||||
|
assert r3.status_code == 400, r3.text
|
||||||
|
|
||||||
|
# Idempotent re-PATCH of same step succeeds.
|
||||||
|
r4 = await client.patch(
|
||||||
|
"/api/v1/users/me/onboarding-step",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"step": 2, "action": "skip"},
|
||||||
|
)
|
||||||
|
assert r4.status_code == 200, r4.text
|
||||||
|
assert r4.json()["onboarding_step_completed"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_onboarding_dismiss_rest_sets_flag(
|
||||||
|
client, auth_headers, test_db, test_user
|
||||||
|
):
|
||||||
|
"""POST /users/me/onboarding-dismiss-rest sets users.onboarding_dismissed=TRUE."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["onboarding_dismissed"] is True
|
||||||
|
# step counter is whatever it was (None for a fresh user).
|
||||||
|
assert "onboarding_step_completed" in data
|
||||||
|
|
||||||
|
# Verify persisted.
|
||||||
|
user_email = test_user["email"]
|
||||||
|
user = (
|
||||||
|
await test_db.execute(select(User).where(User.email == user_email))
|
||||||
|
).scalar_one()
|
||||||
|
assert user.onboarding_dismissed is True
|
||||||
Reference in New Issue
Block a user