Owner Burhan

Hela Onboarding — Course Profile (Medical College & Admission Year) — PRD

Context

The Hela onboarding flow already authenticates and verifies users end-to-end — Google/Email sign-in, mandatory phone verification, and full-name capture all map to existing keystone endpoints (/auth/login/google/mobile, /auth/verify-email-otp, /users/send-phone-otp, /users/verify-phone-otp, PATCH /users/info for name). No change is needed there.

Hela is the Android flavor of the Medical course (CourseEnum value 4). Its onboarding asks two extra questions the backend cannot persist today: “Which medical college do you go to?” and “Year of admission”. PATCH /users/info (UserInfoUpdateRequest, src/api/v1/user/schemas.py:812) is a closed 4-field schema (name, background_info, attempt_number, attempt_year) with a matching repository whitelist, so these two facts have nowhere to live.

This PRD is a delta: it adds those two fields as course-scoped data and round-trips them on login. It does not re-spec auth, OTP, tokens, the +91 rule, or the name write path. It does not touch the existing global UPSC fields (attempt_number, attempt_year, background_info).

Course-scoped, not global (decided). medical_college and admission_year are facts about a user within the Medical course, not global profile attributes. Keystone already models every course-scoped user fact (subscription, global_stars, mcq stats) as a separate per-(user, course) document merged into the response on read — never embedded on the user doc. We follow that exact precedent (user_stats). Rejected: flat top-level fields on UserDBModel (would pollute the global profile for multi-course users and misrepresent medical-only data as universal).

Not attempt_year (decided). admission_year is a new field. attempt_year is semantically “exam-attempt year” (validated 1900..2100, surfaced for UPSC); overloading it would corrupt those semantics.

Scope

MVP — persist and round-trip the two Medical onboarding facts, end to end:

  1. user_course_profiles collection — new per-(user, course) record holding course-scoped onboarding facts (medical_college, admission_year), mirroring the user_stats stack.
  2. Extended profile writePATCH /users/info gains a course_id and the two new fields; the handler fans out (nameusers doc as today; medical_college/admission_year → upsert the per-course row).
  3. Course-scoped echoGET /auth/me?course_id=4 merges medical_college/admission_year into UserDetailsResponse, exactly as subscription/global_stars are merged today.

Free-text college label (decided). medical_college is stored as a trimmed free-text str (the client sends the label from its static, app-bundled college list). The backend owns no college list/search endpoint and does no enum/id normalization. Rejected: a normalized college registry — contradicts the locked “list is static and bundled in-app” decision and adds a registry to maintain.

No server onboarding-complete flag (decided). The client is authoritative for onboarding completion (local-first; mirrors the app’s existing isPendingSync pattern). No onboarding_completed field or endpoint is added. Once the two fields round-trip, a client can infer “done” from /auth/me?course_id=4 (name + medical_college + admission_year + is_phone_validated all present), but the local flag remains the source of truth. Add a server flag later only if cross-device consistency is required.

Functional Requirements

  1. An authenticated Medical-course user can save their medical_college (free-text) and admission_year in a single PATCH /users/info call that also carries course_id.
  2. The same call may still carry name; name is written to the global user doc while the two medical fields are written to the per-course record.
  3. admission_year outside 2007..current_year is rejected (VALIDATION_FAILED).
  4. When medical_college or admission_year is present in the request, course_id is required; a request missing it is rejected (VALIDATION_FAILED).
  5. A request carrying only name (no medical fields, no course_id) behaves exactly as today — backward-compatible with existing UPSC callers.
  6. GET /auth/me?course_id=4 returns the saved medical_college and admission_year so they survive reinstall and render on the profile. Without course_id, those fields are absent (same contract as subscription/global_stars).
  7. Re-submitting the fields updates the existing per-course record in place (upsert by (user_id, course_id)), not a duplicate.

Data Model (requirements-level)

One record per (user, course) (decided). unique_key = "{user_id}_{course_id}", unique — identical convention to user_stats (user-id-first composite). A user enrolled in multiple courses has one row per course; Medical (course 4) is the only writer today.

user_course_profiles row (new collection user_course_profiles):

FieldTypeNotes
unique_keystring"{user_id}_{course_id}", unique. Auto-generated from user_id + course_id.
user_idObjectIdRequired. Owner.
course_idenumRequired. CourseEnum (Hela = 4).
medical_collegestring, optionalFree-text, whitespace-trimmed, max_length=200. Client sends the bundled label.
admission_yearint, optionalValidated 2007 ≤ y ≤ current_year (dynamic upper bound).
auditcreated_at, updated_at, is_deleted, created_by, updated_by from BaseBeanieDocumentModel.

Indexes (mirror user_stats): unique_key (unique); (user_id, course_id) composite.

Request additions (PATCH /users/infoUserInfoUpdateRequest):

FieldTypeNotes
course_idenum (query param)Required when a medical field is present; optional otherwise.
medical_collegestring, optionalFree-text, trimmed, max_length=200.
admission_yearint, optional2007..current_year.

Response additions (UserDetailsResponse, populated only when course_id is supplied): medical_college, admission_year.

Echo is course-conditional (decided, divergence from re-from-client-details.md). The original backend note (BR1/BR2) assumed unconditional echo on /auth/me. Because the fields are course-scoped, they are merged into UserDetailsResponse only when course_id is passed — consistent with subscription/global_stars. The Hela client always sends course_id=4, so its onboarding-complete inference is unaffected.

API Surface

SurfaceRouteAuthNotes
v1 (mobile, write)PATCH /users/info (extended)client_jwt_validatoradds course_id (query) + medical_college, admission_year (body). Fans out: nameusers; medical fields → upsert user_course_profiles. Encrypted BaseResponse[UserDetailsResponse].
v1 (mobile, read)GET /auth/me (extended)client_jwt_validatorwhen course_id present, merges medical_college/admission_year into the response alongside subscription/global_stars/mcq_stats.

New stack mirrors src/models/user_stats_models/ + UserStatsService / user_stats_repository.

Delivery Milestones

#MilestoneOutcomeStatusPlan
1user_course_profiles model + repo + serviceNew per-(user, course) collection with create_or_update (upsert by unique_key) and get_by_user_course — mirrors the user_stats stack.pendingBE plan (TBD)
2Extend PATCH /users/info write + fan-outRequest accepts course_id + the two fields; name writes to users as today, medical fields upsert the per-course row; course_id required when a medical field is present; admission_year range-validated.pendingBE plan (TBD)
3Course-scoped echo on /auth/meGET /auth/me?course_id=4 returns medical_college/admission_year; absent without course_id.pendingBE plan (TBD)
4TestsHappy path (save + round-trip), missing-course_id-with-medical-field rejection, admission_year bounds, name-only backward-compat, upsert (re-submit updates in place).pendingBE plan (TBD)

Plan key: BE plan = keystone backend (keystone/docs/superpowers/… once specced). All milestones are backend-only in keystone; the Hela mobile client consumes the contract separately and persists locally (DataStore) until the fields land, back-filling on the next successful profile sync.

Open Questions

None remaining — all resolved during PRD review:

  • Storage (resolved): separate per-(user, course) collection (user_course_profiles), not flat user-doc fields and not an embedded course-keyed map. Mirrors user_stats.
  • College representation (resolved): free-text str; no backend college registry.
  • Onboarding-complete signal (resolved): client-authoritative; no server flag in MVP.
  • Write contract (resolved): extend PATCH /users/info with course_id and fan out, rather than adding a dedicated course-profile endpoint.
  • Phone-as-primary sign-in (resolved): not needed for the locked flow; existing bearer phone-OTP endpoints suffice. No new unauthenticated SMS-OTP route.

Risks

RiskLikelihoodImpactMitigation
Split write path. A single PATCH /users/info now writes two collections (users for name, user_course_profiles for medical fields); a partial failure could persist one but not the other.LowMed — inconsistent profileWrite name and the per-course upsert in a defined order; surface a single error on failure; client retries the idempotent call (upsert by unique_key).
Missing course_id. Client omits course_id while sending medical fields → fields silently dropped.MedMed — data lossReject with VALIDATION_FAILED when a medical field is present without course_id (FR4); contract documented.
Course-conditional echo surprises client. /auth/me without course_id omits the fields; a caller expecting unconditional echo loops onboarding.LowMed — onboarding loopDocumented contract; Hela always sends course_id=4; client treats its local flag as authoritative (no server flag).
admission_year drift vs client list. Client bundles a runtime 2007..currentYear list; server validates the same window so they stay aligned year over year.LowLowDynamic current_year upper bound on the server; no backend year-list endpoint.
Two “year” fields confused. Devs may reuse attempt_year (UPSC, 1900..2100) for admission year.LowMed — corrupted UPSC semanticsDistinct field name + distinct validation window; recorded as a decision in this PRD.

Technical Details (as built)

Pending — not yet implemented. Greenfield collection modeled file-for-file on the user_stats stack; the PATCH /users/info and /auth/me paths are edited to write/merge the new fields.

Naming: PRD vs code (anticipated)

PRD termCode (to create / edit)
Course profile rowUserCourseProfileDBModel (collection user_course_profiles) / UserCourseProfileProjection
Unique keycreate_user_course_profile_id(user_id, course_id)"{user_id}_{course_id}" (mirrors create_user_stats_id)
Repositoryuser_course_profile_repositorycreate_or_update(), get_by_user_course() (mirror user_stats_repository)
ServiceUserCourseProfileService.update_profile(), .get_by_user_course() (mirror UserStatsService, src/services/user_stats_services.py:26)
Write request fieldsUserInfoUpdateRequest.medical_college, .admission_year + course_id query param (src/api/v1/user/schemas.py:812)
Write handlerupdate_user_info route (src/api/v1/user/routes.py:790) — fans out to UserService.update_user_info (name) + UserCourseProfileService.update_profile (medical)
Read handlerget_current_user route (src/api/v1/user/routes.py:567) — merges fields when course_id present, beside subscription/global_stars
Response fieldsUserDetailsResponse.medical_college, .admission_year (src/api/v1/user/schemas.py:328) + explicit map lines in create_response (:519)

Surface & semantics

SurfaceRoutesNotes
v1 (mobile, write)PATCH /users/infoencrypted; course_id query; fans out name (global) + medical fields (per-course upsert).
v1 (mobile, read)GET /auth/meencrypted; merges medical fields only when course_id supplied.
  • New error codes: none — reuses VALIDATION_FAILED, USER_NOT_FOUND.

Migration

  • No data backfill — new collection; rows created on demand via upsert. Beanie builds the indexes on startup. Existing users have medical_college/admission_year = null until they complete Hela onboarding. The global users doc and the existing UPSC fields (attempt_year, etc.) are untouched.