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_collegeandadmission_yearare 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 onUserDBModel(would pollute the global profile for multi-course users and misrepresent medical-only data as universal).
Not
attempt_year(decided).admission_yearis a new field.attempt_yearis semantically “exam-attempt year” (validated1900..2100, surfaced for UPSC); overloading it would corrupt those semantics.
Scope
MVP — persist and round-trip the two Medical onboarding facts, end to end:
user_course_profilescollection — new per-(user, course)record holding course-scoped onboarding facts (medical_college,admission_year), mirroring theuser_statsstack.- Extended profile write —
PATCH /users/infogains acourse_idand the two new fields; the handler fans out (name→usersdoc as today;medical_college/admission_year→ upsert the per-course row). - Course-scoped echo —
GET /auth/me?course_id=4mergesmedical_college/admission_yearintoUserDetailsResponse, exactly assubscription/global_starsare merged today.
Free-text college label (decided).
medical_collegeis stored as a trimmed free-textstr(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
isPendingSyncpattern). Noonboarding_completedfield 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_validatedall present), but the local flag remains the source of truth. Add a server flag later only if cross-device consistency is required.
Functional Requirements
- An authenticated Medical-course user can save their
medical_college(free-text) andadmission_yearin a singlePATCH /users/infocall that also carriescourse_id. - The same call may still carry
name;nameis written to the global user doc while the two medical fields are written to the per-course record. admission_yearoutside2007..current_yearis rejected (VALIDATION_FAILED).- When
medical_collegeoradmission_yearis present in the request,course_idis required; a request missing it is rejected (VALIDATION_FAILED). - A request carrying only
name(no medical fields, nocourse_id) behaves exactly as today — backward-compatible with existing UPSC callers. GET /auth/me?course_id=4returns the savedmedical_collegeandadmission_yearso they survive reinstall and render on the profile. Withoutcourse_id, those fields are absent (same contract assubscription/global_stars).- 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 touser_stats(user-id-first composite). A user enrolled in multiple courses has one row per course; Medical (course4) is the only writer today.
user_course_profiles row (new collection user_course_profiles):
| Field | Type | Notes |
|---|---|---|
unique_key | string | "{user_id}_{course_id}", unique. Auto-generated from user_id + course_id. |
user_id | ObjectId | Required. Owner. |
course_id | enum | Required. CourseEnum (Hela = 4). |
medical_college | string, optional | Free-text, whitespace-trimmed, max_length=200. Client sends the bundled label. |
admission_year | int, optional | Validated 2007 ≤ y ≤ current_year (dynamic upper bound). |
| audit | — | created_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/info → UserInfoUpdateRequest):
| Field | Type | Notes |
|---|---|---|
course_id | enum (query param) | Required when a medical field is present; optional otherwise. |
medical_college | string, optional | Free-text, trimmed, max_length=200. |
admission_year | int, optional | 2007..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 intoUserDetailsResponseonly whencourse_idis passed — consistent withsubscription/global_stars. The Hela client always sendscourse_id=4, so its onboarding-complete inference is unaffected.
API Surface
| Surface | Route | Auth | Notes |
|---|---|---|---|
| v1 (mobile, write) | PATCH /users/info (extended) | client_jwt_validator | adds course_id (query) + medical_college, admission_year (body). Fans out: name → users; medical fields → upsert user_course_profiles. Encrypted BaseResponse[UserDetailsResponse]. |
| v1 (mobile, read) | GET /auth/me (extended) | client_jwt_validator | when 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
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | user_course_profiles model + repo + service | New per-(user, course) collection with create_or_update (upsert by unique_key) and get_by_user_course — mirrors the user_stats stack. | pending | BE plan (TBD) |
| 2 | Extend PATCH /users/info write + fan-out | Request 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. | pending | BE plan (TBD) |
| 3 | Course-scoped echo on /auth/me | GET /auth/me?course_id=4 returns medical_college/admission_year; absent without course_id. | pending | BE plan (TBD) |
| 4 | Tests | Happy path (save + round-trip), missing-course_id-with-medical-field rejection, admission_year bounds, name-only backward-compat, upsert (re-submit updates in place). | pending | BE 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. Mirrorsuser_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/infowithcourse_idand 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
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
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. | Low | Med — inconsistent profile | Write 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. | Med | Med — data loss | Reject 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. | Low | Med — onboarding loop | Documented 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. | Low | Low | Dynamic 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. | Low | Med — corrupted UPSC semantics | Distinct 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 term | Code (to create / edit) |
|---|---|
| Course profile row | UserCourseProfileDBModel (collection user_course_profiles) / UserCourseProfileProjection |
| Unique key | create_user_course_profile_id(user_id, course_id) → "{user_id}_{course_id}" (mirrors create_user_stats_id) |
| Repository | user_course_profile_repository — create_or_update(), get_by_user_course() (mirror user_stats_repository) |
| Service | UserCourseProfileService.update_profile(), .get_by_user_course() (mirror UserStatsService, src/services/user_stats_services.py:26) |
| Write request fields | UserInfoUpdateRequest.medical_college, .admission_year + course_id query param (src/api/v1/user/schemas.py:812) |
| Write handler | update_user_info route (src/api/v1/user/routes.py:790) — fans out to UserService.update_user_info (name) + UserCourseProfileService.update_profile (medical) |
| Read handler | get_current_user route (src/api/v1/user/routes.py:567) — merges fields when course_id present, beside subscription/global_stars |
| Response fields | UserDetailsResponse.medical_college, .admission_year (src/api/v1/user/schemas.py:328) + explicit map lines in create_response (:519) |
Surface & semantics
| Surface | Routes | Notes |
|---|---|---|
| v1 (mobile, write) | PATCH /users/info | encrypted; course_id query; fans out name (global) + medical fields (per-course upsert). |
| v1 (mobile, read) | GET /auth/me | encrypted; 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 globalusersdoc and the existing UPSC fields (attempt_year, etc.) are untouched.