Skip to content

fix(onboarding): source topic list from DB to stop selections being silently dropped #598

Description

@nicholasjjlim

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 (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:

  1. 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.
  2. 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

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]
    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).
  • Modify src/routes/(main)/api/onboarding/+server.ts (accept collectionIds; resolve strictly).
  • No new files required.

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 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):

interface OnboardingTopic {
  id: string;
  title: string;
  description: string;
}

API contract

POST /api/onboarding

Request headers: Content-Type: application/json (otherwise 415).

Request body (changed: topics of titles becomes collectionIds of UUIDs):

interface OnboardingRequest {
  collectionIds: string[]; // collection UUIDs, minimum 3 entries
  frequency: string;       // maps to LearningFrequency
  csrfToken: string;
}

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


🌊 Managed by Currents · Task ef49fc33-d6ab-4006-b54c-dabd0637fd38

{"metadata":{"sourceType":"github"},"rank":"a0","taskId":"ef49fc33-d6ab-4006-b54c-dabd0637fd38","version":1}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions