You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As a learner completing onboarding, I want every topic I select to be saved to my interests, so that my feed reflects all of my choices and onboarding does not silently fail.
Background
The onboarding topic picker renders a list of topics that is hardcoded in the frontend (collectionsList in src/lib/components/OnboardingView/OnboardingView.svelte). On submit, the POST handler at src/routes/(main)/api/onboarding/+server.ts matches the submitted topic titles against the database with an exact, case-sensitive query: Collection.title IN (topics) AND isTopic = true.
Because the UI list and the database are two independent sources of truth, their strings drift. A production check found 4 of 12 onboarding topics did not match any isTopic = true collection (examples at the time: UI sent Artificial Intelligence while the DB had AI; Educator Voices vs Educators Voices; In the News vs In The News; Learn with Bob vs Learn with BOB).
This produced a real production failure. Log: {"handler":"api_update_onboarding","topics":["Artificial Intelligence","Educator Voices","In the News"],"msg":"No valid collections found"}
Failure modes observed:
All selected topics fail to match: the handler logs "No valid collections found", returns 400, and no UserProfile is created. The learner cannot complete onboarding.
A mix of matching and non-matching topics: the query returns only the matches, collections.length > 0 so no error is raised, and the handler silently saves only the matched topics. The learner sees success but some picks are dropped with no log and no record of intent.
This issue covers the durable fix so the picker and the validator can never drift again: source the selectable topics from the database and submit a stable identifier instead of the display title.
Acceptance criteria
Selectable topics are sourced from the database
Given a set of collections flagged as topics (isTopic = true) exists in the database
When a learner opens the onboarding topic-selection screen
Then the screen displays exactly those topic collections, with no hardcoded topic list in the frontend
Every selected topic is saved to the learner's interests
Given a learner on the onboarding topic-selection screen
When they select one or more topics and submit
Then a user_interests row is created for every topic they selected, and none are silently dropped
A selection that cannot be resolved is reported, not silently dropped
Given a learner submits a selection containing a topic that can no longer be resolved to an existing topic collection
When the submission is processed
Then the system does not save a partial profile silently, and the unresolved selection results in an observable error response rather than a 200 with missing interests
Out of scope
Admin tooling for creating or editing topic collections.
flowchart TD
subgraph Current
A1[Hardcoded topic titles in UI] -->|submit titles| B1[POST /api/onboarding]
B1 --> C1{title IN topics AND isTopic=true}
C1 -->|0 matches| D1[400 + No valid collections found]
C1 -->|partial match| E1[200, only matched topics saved silently]
C1 -->|all match| F1[200, all saved]
end
subgraph Intended
A2[Topic list loaded from DB isTopic=true] -->|submit ids| B2[POST /api/onboarding]
B2 --> C2{ids resolve to collections}
C2 -->|all resolve| F2[200, all saved]
C2 -->|any unresolved| D2[error, no silent partial save]
end
Loading
Technical context
The onboarding flow spans three areas:
Topic list source (frontend):src/lib/components/OnboardingView/OnboardingView.svelte holds the hardcoded collectionsList (lines ~138-191) and uses each entry's title as both the displayed label and the submitted value (handleTopicChange, handleConfirm). This component is a modal mounted in src/routes/(main)/(protected)/+layout.svelte (line 332), shown when page.data.onboarded is falsy. It has no own load, so any server data must flow through the layout load.
Server data load:src/routes/(main)/(protected)/+layout.server.ts is where the topic collections should be queried and returned so the component can receive them as a prop. There is an existing query for topical collections to follow at src/routes/(main)/(protected)/(core)/home/+page.server.ts (lines 176-200: db.collection.findMany with where: { isTopic: true }).
Submission handler:src/routes/(main)/api/onboarding/+server.ts validates the body and writes UserProfile + nested UserInterest rows.
Approach: serve the selectable topics from the database (Collection where isTopic = true, returning id, title, description), render those instead of the hardcoded list, and change the submission to send collection ids. The handler then resolves the submitted ids and rejects the request if any do not resolve to an isTopic = true collection, instead of silently saving the subset.
Patterns to follow:
Topical-collections query and select shape: src/routes/(main)/(protected)/(core)/home/+page.server.ts:176-200.
Load placement, satisfies *FindUniqueArgs, *GetPayload, and return shape: src/routes/(main)/(protected)/+layout.server.ts.
satisfies *FindManyArgs typing for Prisma query args: src/routes/(main)/(protected)/collection/+page.server.ts.
File placement:
Modify src/routes/(main)/(protected)/+layout.server.ts (add the topic-collections query to the load and return it).
Modify src/routes/(main)/(protected)/+layout.svelte (pass the collections down to OnboardingView).
Modify src/lib/components/OnboardingView/OnboardingView.svelte (remove collectionsList; accept a typed topics prop; render from it; submit ids).
No Prisma schema change and no migration. Existing models are sufficient:
Collection: id (UUID), title (String), description (String), isTopic (Boolean, default false). The topic list reads id, title, description where isTopic = true.
UserProfile: userId (PK), learningFrequency (LearningFrequency?). Created on successful onboarding.
UserInterest: composite PK (userId, collectionId). One row created per selected topic via the nested interests.create write.
The onboarding view's selectable-topic shape (load to component prop):
Success response: 200 with body null. Side effect: one UserProfile row plus one UserInterest row per entry in collectionIds.
Error contract
Condition
Status
Body
No authenticated user
401
null
Content-Type not application/json
415
null
Request JSON fails to parse
400
null
Body invalid (missing/wrong-typed fields, fewer than 3 collectionIds, non-string entries)
422
null
One or more submitted collectionIds do not resolve to a collection with isTopic = true (count of resolved collections does not equal count of submitted ids)
422
null
Database error during lookup or write
500
null
Success
200
null
The current collections.length === 0 -> 400 branch is replaced by the strict resolution check: reject (422) whenever resolved count does not equal submitted count, so a partial selection is never silently saved. On the resolution failure, log a warning that includes the submitted and unresolved ids.
Additional test scenarios
All submitted ids resolve to topic collections
Given a request whose collectionIds all reference isTopic = true collections
When the handler processes it
Then it returns 200 and creates exactly one UserInterest row per submitted id
A single unresolved id fails the whole request atomically
Given a request whose collectionIds contains one id that does not reference an isTopic = true collection
When the handler processes it
Then it returns 422, creates no UserProfile, and writes no UserInterest rows
Fewer than three ids is rejected
Given a request with fewer than 3 collectionIds
When the handler validates the body
Then it returns 422 and performs no writes
Duplicate ids do not create duplicate interests
Given a request whose collectionIds contains the same id more than once
When the handler processes it
Then it does not attempt to create duplicate UserInterest rows for that learner and collection
Database failure surfaces as a server error
Given the database call fails during lookup or write
When the handler runs
Then it logs the error and returns 500
Hard constraints
Do not add or modify the Prisma schema and do not create a migration (existing Collection, UserProfile, UserInterest fields are sufficient).
Do not reintroduce a hardcoded topic list (titles or descriptions) in the frontend; the list must come from the database.
Do not remove the central auth enforcement; the endpoint's own auth check stays as defense-in-depth (per CLAUDE.md).
Follow the repo's Prisma conventions (CLAUDE.md): order query keys in SQL clause order, type args with satisfies *FindManyArgs / *FindUniqueArgs, and derive rows with *GetPayload; never as const.
Keep the existing csrfToken handling and LearningFrequency mapping behavior unchanged.
Do not add a new runtime dependency.
🤖 Generated with aif-create-issue 🤖 Groomed with aif-groom-issue
User story
As a learner completing onboarding, I want every topic I select to be saved to my interests, so that my feed reflects all of my choices and onboarding does not silently fail.
Background
The onboarding topic picker renders a list of topics that is hardcoded in the frontend (
collectionsListinsrc/lib/components/OnboardingView/OnboardingView.svelte). On submit, the POST handler atsrc/routes/(main)/api/onboarding/+server.tsmatches the submitted topic titles against the database with an exact, case-sensitive query:Collection.title IN (topics) AND isTopic = true.Because the UI list and the database are two independent sources of truth, their strings drift. A production check found 4 of 12 onboarding topics did not match any
isTopic = truecollection (examples at the time: UI sentArtificial Intelligencewhile the DB hadAI;Educator VoicesvsEducators Voices;In the NewsvsIn The News;Learn with BobvsLearn with BOB).This produced a real production failure. Log:
{"handler":"api_update_onboarding","topics":["Artificial Intelligence","Educator Voices","In the News"],"msg":"No valid collections found"}Failure modes observed:
UserProfileis created. The learner cannot complete onboarding.collections.length > 0so no error is raised, and the handler silently saves only the matched topics. The learner sees success but some picks are dropped with no log and no record of intent.This issue covers the durable fix so the picker and the validator can never drift again: source the selectable topics from the database and submit a stable identifier instead of the display title.
Acceptance criteria
Selectable topics are sourced from the database
isTopic = true) exists in the databaseEvery selected topic is saved to the learner's interests
user_interestsrow is created for every topic they selected, and none are silently droppedA selection that cannot be resolved is reported, not silently dropped
Out of scope
Design assets
Current (buggy) flow versus intended flow:
flowchart TD subgraph Current A1[Hardcoded topic titles in UI] -->|submit titles| B1[POST /api/onboarding] B1 --> C1{title IN topics AND isTopic=true} C1 -->|0 matches| D1[400 + No valid collections found] C1 -->|partial match| E1[200, only matched topics saved silently] C1 -->|all match| F1[200, all saved] end subgraph Intended A2[Topic list loaded from DB isTopic=true] -->|submit ids| B2[POST /api/onboarding] B2 --> C2{ids resolve to collections} C2 -->|all resolve| F2[200, all saved] C2 -->|any unresolved| D2[error, no silent partial save] endTechnical context
The onboarding flow spans three areas:
src/lib/components/OnboardingView/OnboardingView.svelteholds the hardcodedcollectionsList(lines ~138-191) and uses each entry'stitleas both the displayed label and the submitted value (handleTopicChange,handleConfirm). This component is a modal mounted insrc/routes/(main)/(protected)/+layout.svelte(line 332), shown whenpage.data.onboardedis falsy. It has no ownload, so any server data must flow through the layout load.src/routes/(main)/(protected)/+layout.server.tsis where the topic collections should be queried and returned so the component can receive them as a prop. There is an existing query for topical collections to follow atsrc/routes/(main)/(protected)/(core)/home/+page.server.ts(lines 176-200:db.collection.findManywithwhere: { isTopic: true }).src/routes/(main)/api/onboarding/+server.tsvalidates the body and writesUserProfile+ nestedUserInterestrows.Approach: serve the selectable topics from the database (
CollectionwhereisTopic = true, returningid,title,description), render those instead of the hardcoded list, and change the submission to send collectionids. The handler then resolves the submittedids and rejects the request if any do not resolve to anisTopic = truecollection, instead of silently saving the subset.Patterns to follow:
selectshape:src/routes/(main)/(protected)/(core)/home/+page.server.ts:176-200.satisfies *FindUniqueArgs,*GetPayload, and return shape:src/routes/(main)/(protected)/+layout.server.ts.satisfies *FindManyArgstyping for Prisma query args:src/routes/(main)/(protected)/collection/+page.server.ts.File placement:
src/routes/(main)/(protected)/+layout.server.ts(add the topic-collections query to the load and return it).src/routes/(main)/(protected)/+layout.svelte(pass the collections down toOnboardingView).src/lib/components/OnboardingView/OnboardingView.svelte(removecollectionsList; accept a typedtopicsprop; render from it; submitids).src/routes/(main)/api/onboarding/+server.ts(acceptcollectionIds; resolve strictly).Data model
No Prisma schema change and no migration. Existing models are sufficient:
Collection:id(UUID),title(String),description(String),isTopic(Boolean, default false). The topic list readsid,title,descriptionwhereisTopic = true.UserProfile:userId(PK),learningFrequency(LearningFrequency?). Created on successful onboarding.UserInterest: composite PK (userId,collectionId). One row created per selected topic via the nestedinterests.createwrite.The onboarding view's selectable-topic shape (load to component prop):
API contract
POST /api/onboardingRequest headers:
Content-Type: application/json(otherwise 415).Request body (changed:
topicsof titles becomescollectionIdsof UUIDs):Success response:
200with bodynull. Side effect: oneUserProfilerow plus oneUserInterestrow per entry incollectionIds.Error contract
401nullContent-Typenotapplication/json415null400nullcollectionIds, non-string entries)422nullcollectionIdsdo not resolve to a collection withisTopic = true(count of resolved collections does not equal count of submitted ids)422null500null200nullThe current
collections.length === 0 -> 400branch is replaced by the strict resolution check: reject (422) whenever resolved count does not equal submitted count, so a partial selection is never silently saved. On the resolution failure, log a warning that includes the submitted and unresolved ids.Additional test scenarios
All submitted ids resolve to topic collections
collectionIdsall referenceisTopic = truecollectionsUserInterestrow per submitted idA single unresolved id fails the whole request atomically
collectionIdscontains one id that does not reference anisTopic = truecollectionUserProfile, and writes noUserInterestrowsFewer than three ids is rejected
collectionIdsDuplicate ids do not create duplicate interests
collectionIdscontains the same id more than onceUserInterestrows for that learner and collectionDatabase failure surfaces as a server error
Hard constraints
Collection,UserProfile,UserInterestfields are sufficient).satisfies *FindManyArgs/*FindUniqueArgs, and derive rows with*GetPayload; neveras const.csrfTokenhandling andLearningFrequencymapping behavior unchanged.🤖 Generated with aif-create-issue
🤖 Groomed with aif-groom-issue
{"metadata":{"sourceType":"github"},"rank":"a0","taskId":"ef49fc33-d6ab-4006-b54c-dabd0637fd38","version":1}