Skip to content

Commit 8c3109b

Browse files
committed
feat(entitlements): pro onboarding marker on the plan block
The dashboard shows the Pro tour once, the first time a plan is active and the account has not seen it. The marker lives on the user, rides in the plan block of /me/entitlements, and one idempotent route stamps it.
1 parent 557dfbe commit 8c3109b

12 files changed

Lines changed: 214 additions & 2 deletions

File tree

openapi.json

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8042,6 +8042,81 @@
80428042
}
80438043
}
80448044
},
8045+
"/api/v1/me/pro-onboarding": {
8046+
"post": {
8047+
"tags": [
8048+
"Me"
8049+
],
8050+
"summary": "Complete Pro Onboarding",
8051+
"description": "Record that this account has seen the Pro tour. Idempotent: the first\ncompletion is kept, and it is read back as `user.pro_onboarded_at` on\n`GET /auth/me`.\n\n**Authentication**: Required. 403 on the free plan.",
8052+
"operationId": "completeProOnboarding",
8053+
"responses": {
8054+
"204": {
8055+
"description": "Successful Response"
8056+
},
8057+
"400": {
8058+
"description": "Bad Request \u2014 invalid parameters",
8059+
"content": {
8060+
"application/json": {
8061+
"schema": {
8062+
"$ref": "#/components/schemas/ErrorResponse"
8063+
}
8064+
}
8065+
}
8066+
},
8067+
"401": {
8068+
"description": "Unauthorized \u2014 missing or invalid credentials",
8069+
"content": {
8070+
"application/json": {
8071+
"schema": {
8072+
"$ref": "#/components/schemas/ErrorResponse"
8073+
}
8074+
}
8075+
}
8076+
},
8077+
"403": {
8078+
"description": "Forbidden \u2014 insufficient permissions or scope",
8079+
"content": {
8080+
"application/json": {
8081+
"schema": {
8082+
"$ref": "#/components/schemas/ErrorResponse"
8083+
}
8084+
}
8085+
}
8086+
},
8087+
"404": {
8088+
"description": "Not found",
8089+
"content": {
8090+
"application/json": {
8091+
"schema": {
8092+
"$ref": "#/components/schemas/ErrorResponse"
8093+
}
8094+
}
8095+
}
8096+
},
8097+
"429": {
8098+
"description": "Rate limit exceeded",
8099+
"content": {
8100+
"application/json": {
8101+
"schema": {
8102+
"$ref": "#/components/schemas/ErrorResponse"
8103+
}
8104+
}
8105+
}
8106+
},
8107+
"409": {
8108+
"description": "Conflict \u2014 resource already exists",
8109+
"content": {
8110+
"application/json": {
8111+
"schema": {
8112+
"$ref": "#/components/schemas/ErrorResponse"
8113+
}
8114+
}
8115+
}
8116+
}
8117+
}
8118+
}
8119+
},
80458120
"/api/v1/me/layouts/{page}": {
80468121
"get": {
80478122
"tags": [
@@ -13900,6 +13975,12 @@
1390013975
"title": "Founding",
1390113976
"description": "Founding cohort member",
1390213977
"default": false
13978+
},
13979+
"renews": {
13980+
"type": "boolean",
13981+
"title": "Renews",
13982+
"description": "True when the term renews on its own at `until`; false when it ends",
13983+
"default": false
1390313984
}
1390413985
},
1390513986
"type": "object",
@@ -16712,6 +16793,18 @@
1671216793
"title": "Onboarded At",
1671316794
"description": "When the user completed onboarding (null = never)"
1671416795
},
16796+
"pro_onboarded_at": {
16797+
"anyOf": [
16798+
{
16799+
"type": "string"
16800+
},
16801+
{
16802+
"type": "null"
16803+
}
16804+
],
16805+
"title": "Pro Onboarded At",
16806+
"description": "When the user completed the Pro tour (null = not yet)"
16807+
},
1671516808
"auth_providers": {
1671616809
"items": {
1671716810
"$ref": "#/components/schemas/AuthProviderInfo"

repositories/user_repository.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ async def find_by_id(self, user_id: ObjectId) -> UserDoc | None:
4848
"""Find a user by ObjectId."""
4949
return await self._find_one({"_id": user_id})
5050

51+
async def mark_pro_onboarded(self, user_id: ObjectId) -> bool:
52+
"""Stamp the first Pro tour completion; later calls change nothing."""
53+
return await self._update_modified(
54+
{"_id": user_id, "pro_onboarded_at": None},
55+
{"$set": {"pro_onboarded_at": datetime.now(timezone.utc)}},
56+
)
57+
5158
async def find_by_oauth_provider(
5259
self, provider: str, provider_user_id: str
5360
) -> UserDoc | None:

routes/api_v1/me.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
DELETE /api/v1/me — request account deletion (grace period)
33
GET /api/v1/me/entitlements — plan, feature states, limits, version
4+
POST /api/v1/me/pro-onboarding — mark the Pro tour as seen
45
GET /api/v1/me/features — the features map alone
56
GET /api/v1/me/layouts/{page} — fetch the saved dashboard layout (null = default)
67
PUT /api/v1/me/layouts/{page} — save the layout document verbatim
@@ -30,7 +31,9 @@
3031
JwtUser,
3132
PageLayoutSvc,
3233
ProfilePictureSvc,
34+
UserRepo,
3335
)
36+
from errors import ForbiddenError
3437
from middleware.openapi import AUTH_RESPONSES
3538
from middleware.rate_limiter import Limits, limiter
3639
from schemas.dto.requests.account import DeleteAccountRequest
@@ -52,7 +55,7 @@
5255
AvailablePicturesResponse,
5356
ProfilePictureMessageResponse,
5457
)
55-
from services.features.catalog import int_features
58+
from services.features.catalog import Plan, int_features
5659

5760
router = APIRouter(prefix="/me", tags=["Me"])
5861

@@ -161,6 +164,7 @@ async def get_my_entitlements(
161164
status=entitlements.status.value if entitlements.status else None,
162165
until=entitlements.until,
163166
founding=entitlements.founding,
167+
renews=entitlements.renews,
164168
),
165169
features=features,
166170
limits={
@@ -171,6 +175,29 @@ async def get_my_entitlements(
171175
)
172176

173177

178+
@router.post(
179+
"/pro-onboarding",
180+
status_code=204,
181+
responses=AUTH_RESPONSES,
182+
operation_id="completeProOnboarding",
183+
summary="Complete Pro Onboarding",
184+
)
185+
@limiter.limit(Limits.DASHBOARD_WRITE)
186+
async def complete_pro_onboarding(
187+
request: Request, user: JwtUser, user_repo: UserRepo, entitlements: Entitled
188+
) -> None:
189+
"""Record that this account has seen the Pro tour. Idempotent: the first
190+
completion is kept, and it is read back as `user.pro_onboarded_at` on
191+
`GET /auth/me`.
192+
193+
**Authentication**: Required. 403 on the free plan.
194+
"""
195+
# Free is the only plan without the tour; self-host holds everything.
196+
if entitlements.plan is Plan.FREE:
197+
raise ForbiddenError("Pro plan required")
198+
await user_repo.mark_pro_onboarded(user.user_id)
199+
200+
174201
# Closed set: every dashboard board the frontend actually renders. An
175202
# allowlist (not just a pattern) caps per-user storage and rejects junk
176203
# slugs. Grows in lockstep with the frontend's boards.

schemas/dto/responses/auth.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ class UserProfileResponse(ResponseBase):
7676
default=None,
7777
description="When the user completed onboarding (null = never)",
7878
)
79+
pro_onboarded_at: UtcDatetime | None = Field(
80+
default=None,
81+
description="When the user completed the Pro tour (null = not yet)",
82+
)
7983
auth_providers: list[AuthProviderInfo] = Field(description="Linked OAuth providers")
8084
# pfp is absent from the JSON when None (route handlers use exclude_none=True)
8185
pfp: UserPfp | None = Field(
@@ -97,6 +101,7 @@ def from_user(cls, user: UserDoc, *, plan: str) -> UserProfileResponse:
97101
plan=plan,
98102
password_set=user.password_set,
99103
onboarded_at=user.onboarded_at,
104+
pro_onboarded_at=user.pro_onboarded_at,
100105
auth_providers=[
101106
AuthProviderInfo(
102107
provider=p.provider,

schemas/dto/responses/entitlements.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ class PlanBlock(ResponseBase):
2020
description="Term end, or the grace end while in grace; null when nothing ends",
2121
)
2222
founding: bool = Field(default=False, description="Founding cohort member")
23+
renews: bool = Field(
24+
default=False,
25+
description="True when the term renews on its own at `until`; false when it ends",
26+
)
2327

2428

2529
class LimitBlock(ResponseBase):

schemas/models/user.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ class UserDoc(MongoBaseModel):
117117
onboarded_at: datetime | None = None
118118
# HDYHAU attribution, captured once at onboarding completion.
119119
heard_from: str | None = None
120+
# First Pro tour completion; null = not yet.
121+
pro_onboarded_at: datetime | None = None
120122
status: UserStatus = UserStatus.ACTIVE
121123
# Account deletion (GDPR erasure): both set on PENDING_DELETION, both
122124
# cleared on restore. Null on every account that never requested deletion.

services/entitlements/resolver.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class Resolved(BaseModel):
2626
status: SubscriptionStatus | None = None
2727
until: datetime | None = None
2828
founding: bool = False
29+
# True only for an active recurring term; prepaid terms end, they do not renew.
30+
renews: bool = False
2931
values: dict[str, bool | int]
3032
version: int
3133
# True when this answer came from a fallback because the stores were

services/entitlements/service.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
EntitlementOverrideRepository,
2424
)
2525
from repositories.subscription_repository import SubscriptionRepository
26-
from schemas.models.subscription import SubscriptionDoc, SubscriptionStatus
26+
from schemas.models.subscription import (
27+
SubscriptionDoc,
28+
SubscriptionKind,
29+
SubscriptionStatus,
30+
)
2731
from services.entitlements.over_limit import OverLimitService
2832
from services.entitlements.resolver import ANONYMOUS, Resolved, for_plan, resolve
2933
from services.entitlements.state_machine import SubscriptionEvent, next_status
@@ -100,6 +104,11 @@ async def _compute(self, user_id: ObjectId) -> tuple[Resolved, bool]:
100104
status=sub.status if sub else None,
101105
until=sub.until if sub else None,
102106
founding=bool(sub and sub.founding),
107+
renews=bool(
108+
sub
109+
and sub.kind is SubscriptionKind.RECURRING
110+
and sub.status is SubscriptionStatus.ACTIVE
111+
),
103112
values=resolve(plan, overrides),
104113
version=version,
105114
)

tests/integration/api_v1/test_billing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ async def test_scenario_checkout_failure_recovery_cancel_grace_repurchase(self):
646646
"status": "active",
647647
"until": None,
648648
"founding": True,
649+
"renews": True,
649650
}
650651

651652
assert w.deliver("subscription_past_due").json()["outcome"] == "applied"

tests/integration/api_v1/test_me_entitlements.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def test_free_shape():
5858
"status": None,
5959
"until": None,
6060
"founding": False,
61+
"renews": False,
6162
}
6263
assert body["features"]["geo_targeting"] == "locked"
6364
assert set(body["limits"]) == {f.key for f in int_features()}
@@ -179,3 +180,45 @@ def test_over_limit_lists_paused_items_per_limit():
179180
body = client.get("/api/v1/me/entitlements").json()
180181
assert body["over_limit"] == {"webhook_endpoints_max": {"paused": ["aaa", "bbb"]}}
181182
assert body["limits"]["webhook_endpoints_max"] == {"max": 1, "used": 3}
183+
184+
185+
def _onboarding_client(plan: Plan) -> tuple[TestClient, AsyncMock]:
186+
from dependencies import get_user_repo
187+
188+
user = _make_user()
189+
repo = AsyncMock()
190+
repo.mark_pro_onboarded = AsyncMock(return_value=True)
191+
app = _build_test_app(
192+
{
193+
require_jwt: lambda: user,
194+
get_current_user: lambda: user,
195+
get_entitlements: lambda: for_plan(plan),
196+
get_user_repo: lambda: repo,
197+
}
198+
)
199+
return TestClient(app), repo
200+
201+
202+
def test_pro_account_marks_the_tour_seen():
203+
client, repo = _onboarding_client(Plan.PRO)
204+
with client:
205+
resp = client.post("/api/v1/me/pro-onboarding")
206+
assert resp.status_code == 204
207+
assert resp.content == b""
208+
repo.mark_pro_onboarded.assert_awaited_once()
209+
210+
211+
def test_free_account_cannot_stamp_the_pro_tour():
212+
client, repo = _onboarding_client(Plan.FREE)
213+
with client:
214+
resp = client.post("/api/v1/me/pro-onboarding")
215+
assert resp.status_code == 403
216+
assert resp.json()["code"] == "forbidden"
217+
repo.mark_pro_onboarded.assert_not_awaited()
218+
219+
220+
def test_selfhost_account_marks_the_tour_seen():
221+
client, repo = _onboarding_client(Plan.SELFHOST)
222+
with client:
223+
assert client.post("/api/v1/me/pro-onboarding").status_code == 204
224+
repo.mark_pro_onboarded.assert_awaited_once()

0 commit comments

Comments
 (0)