MCQ Current Affairs — PRD
Context
The MCQ module already ships — MCQs carry a question, options, an admin/client solution
(solution_admin → solution_client, encrypted at rest into sole), taxonomy, docket links,
and a course_id (CourseEnum). MCQs are authored in the admin app (keystone-web) and read by
the mobile client via the v1/v2 MCQ APIs.
This PRD is a delta: it adds a single embedded field, current_affairs, to the MCQ — a list of
{displayid, title, body} objects where body is a PlateJS rich-text JSON string. It adds a
UPSC-only admin page to manage those entries (manual + bulk upload) and exposes them read-only
on the client. It does not re-spec options, solutions, taxonomy, dockets, exams, or the existing
bulk-MCQ-create flow.
Course scoping (decided):
current_affairsis a UPSC concept. The admin web app is a single-course deployment —getCourseIdFromEnv()readsVITE_COURSE_ID— so the entire feature (page, bulk upload, menu entry) is gated behindcourseId === CourseEnum.UPSCand is never rendered in non-UPSC admin builds. The backend stores the field on every MCQ regardless of course, but it stays empty/nulloutside UPSC. This is acceptable and expected.
Scope
MVP — current affairs as a managed sub-list of each UPSC MCQ:
current_affairsfield on MCQ — an embedded list of{displayid, title, body(PlateJS)}objects, stored plaintext, populated only for UPSC.- Dedicated admin page (UPSC-only) —
/mcq/current-affairs: pick an MCQ, then add / edit / remove its current-affairs entries, with a reused PlateJS editor for the body. - Bulk upload (UPSC-only) — a JSON file keyed by MCQ
short_uidthat writes current-affairs lists onto many MCQs at once, upserting bydisplayid, all-or-nothing. - Client read exposure —
current_affairsreturned as-is on both v1 and v2 client MCQ responses; empty for non-UPSC MCQs.
Data Model (requirements-level)
Storage location (decided):
current_affairsis embedded on the MCQ document, following the existingmcq_docket_link: Optional[List[MCQDocketLinkInfo]]convention — not a standalone collection. There is one current-affairs item perdisplayid, living on exactly one MCQ.No encryption (decided): unlike
solution_client(encrypted at rest intosole), current affairs is informational and answer-neutral, sobodyis stored plaintext — no ciphertext column.Body fidelity (decided):
bodyis a PlateJS JSON string, stored as-is (same format the adminMCQPlateEditorFieldemits and the client already renders for solutions). The bulk file supplies editor-ready JSON; the backend does not wrap plain text.
New embedded object — current_affairs[] entry:
| Field | Type | Notes |
|---|---|---|
displayid | str | Required. Admin- / file-supplied. Unique per course (UPSC-wide) — see Validation. |
title | str | Required. Plain text label. |
body | str | Required. PlateJS JSON array, serialized to a string. Stored plaintext. |
MCQ changes
| Field | Type | Notes |
|---|---|---|
current_affairs | Optional[List[<entry>]] | Default null/empty. Populated only for UPSC MCQs. Order = insertion/file order (preserved across upserts). |
Validation
displayiduniqueness (decided): adisplayididentifies exactly one current-affairs item course-wide. Because items are embedded across many MCQ documents, this is a cross-document constraint MongoDB will not enforce natively, so it is enforced in the application layer on every write (manual create/update and bulk), with a compound index{course_id, "current_affairs.displayid"}as a backstop. ⚠️ The cross-document check is application-enforced — listed in Risks.
| Rule | When |
|---|---|
displayid non-empty, unique within the target MCQ’s list | every write |
displayid not already present on a different MCQ in the same course | every write |
body parses as a PlateJS JSON array | every write |
title non-empty | every write |
Target MCQ (short_uid) exists in the course | bulk upload |
Admin surface (av1, UPSC-only)
A new page at /mcq/current-affairs, rendered only when
getCourseIdFromEnv() === CourseEnum.UPSC. Non-UPSC admin builds neither route to nor link the page.
- Manual manage — search/select an MCQ by
short_uid; the page loads that MCQ’scurrent_affairsand lets the editor add / edit / remove entries. Each row is adisplayidfield, atitlefield, and the reusedMCQPlateEditorFieldforbody. Saving issues a normalPATCH /av1/mcqs/{id}carrying the updatedcurrent_affairsarray (subject to Validation). - Bulk upload — a button on the same page opens the JSON-file picker (reusing the
BulkCreateMcqsupload conventions: sample download, client-side pre-validation, size/row caps).
current_affairs is also added to the av1 MCQCreateRequest / MCQUpdateRequest so it flows through
the existing create/update service → repository path.
Bulk upload
File shape — JSON array of row-groups keyed by MCQ short_uid:
[
{ "mcq_short_uid": "MCQ-ab12cd",
"current_affairs": [
{ "displayid": "CA-0001", "title": "…", "body": "[{\"type\":\"p\",\"children\":[…]}]" }
] }
]
| Aspect | Behaviour |
|---|---|
| MCQ key | short_uid (matched within the UPSC course). |
| Merge | Upsert by displayid within each target MCQ’s list — matching displayid updates in place (position preserved); new displayid appends. |
| Failure mode | All-or-nothing. Any invalid row-group (unknown short_uid, duplicate/cross-MCQ-conflicting displayid, unparseable body, missing title) → 0 rows applied; the response returns the full per-row error list. Implies validate-entire-file-then-apply (single transaction). |
| Limits | Reuse existing bulk caps (≤500 row-groups, ≤5 MB JSON). |
| Auth | Admin/editor JWT, same role gate as MCQ create. UPSC course_id validated server-side. |
Client surface (read-only)
current_affairs is added to both the v1 and v2 client MCQ response DTOs, returned as-is
(plaintext list of {displayid, title, body}). Empty/omitted for non-UPSC MCQs. It is not indexed
into question_preview and does not participate in search. No client write path.
Migration & Rollout
No data backfill — current_affairs is a new optional embedded field; existing MCQ documents read as
null. Add the {course_id, "current_affairs.displayid"} index. Feature is dark for non-UPSC builds
by construction (env gate).
Delivery Milestones
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | Backend field + write path | MCQ stores current_affairs; av1 create/update accept it with per-course displayid uniqueness and PlateJS-body validation | ✅ done (PR #796) | BE plan ¹ |
| 2 | Bulk upload endpoint | Editors POST one JSON payload (keyed by short_uid) that upserts CA lists across many MCQs, all-or-nothing with a full per-row error report | ✅ done (PR #796) | BE plan ¹ |
| 3 | Client read exposure | v1 & v2 client MCQ responses include current_affairs | ✅ done (PR #796) | BE plan ¹ |
| 4 | Admin CA page (UPSC-only) | UPSC editors manage per-MCQ CA entries (manual add/edit/remove + PlateJS body) at /mcq/current-affairs; hidden in other courses | ✅ done (PR #483) | FE plan ² |
| 5 | Admin bulk-upload UI | UPSC editors bulk-upload CA via JSON with sample download and pre-validation | ✅ done (PR #483) | FE plan ² |
Plan key:
BE= backend (keystone);FE= frontend (keystone-web). ¹ All three backend milestones shipped in one plan,keystone/docs/superpowers/plans/2026-06-29-mcq-current-affairs-backend.md(spec:keystone/docs/superpowers/specs/2026-06-29-mcq-current-affairs-backend-design.md), merged via keystone PR #796 (2026-06-29, basedev). ² Both FE milestones shipped in one plan,keystone-web/docs/superpowers/plans/2026-06-29-mcq-current-affairs-fe.md(spec:keystone-web/docs/superpowers/specs/2026-06-29-mcq-current-affairs-fe-design.md), merged via keystone-web PR #483 (2026-06-29, basedev).
Open Questions
- None remaining — all resolved during PRD review (storage location, displayid scope, merge semantics, body format, failure mode, MCQ key, encryption, client versions, edit-surface placement).
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Cross-document displayid collision. App-layer uniqueness check races under concurrent writes; two MCQs end up sharing a displayid. | Low | Med — ambiguous course-wide reference | Compound {course_id, "current_affairs.displayid"} unique index as backstop; bulk applies in a single transaction. |
Malformed PlateJS body. Hand-built bulk file carries an invalid JSON body; client render breaks. | Med | Med — broken render for that entry | Server validates body parses as a PlateJS array before write; all-or-nothing rejects the file otherwise. |
Bulk upserts wrong MCQs. short_uid typo or wrong-course id silently targets the wrong record. | Low | Med — CA attached to wrong MCQ | All-or-nothing + existence check per short_uid within the UPSC course; full error report before any write. |
| Feature leaks to other courses. Env gate misconfigured; CA UI appears in a non-UPSC admin build. | Low | Low — empty, but confusing | Single courseId === CourseEnum.UPSC guard on route + menu; field is empty for non-UPSC so no data exposure. |
Technical Details (as built)
Backend (M1–M3) shipped in keystone PR #796 — feat(mcq): current affairs (embedded list,
bulk upsert, client read) — branch feature/mcq-current-affairs-backend, base dev, merged
2026-06-29. Frontend (M4–M5) shipped in keystone-web PR #483 — branch feat/mcq-current-affairs-fe,
base dev, merged 2026-06-29 (details under “Frontend (M4–M5, as built)” below).
- Spec:
keystone/docs/superpowers/specs/2026-06-29-mcq-current-affairs-backend-design.md - Plan:
keystone/docs/superpowers/plans/2026-06-29-mcq-current-affairs-backend.md - Approach: pure delta on the existing MCQ stack —
current_affairsis an embedded list on the MCQ document (mirrorsmcq_docket_link),bodystored plaintext.displayiduniqueness is enforced inMCQServiceon every write, backed by a compound unique partial index. The bulk endpoint validates the whole payload first (collecting per-row errors) then applies every update inside onerun_with_transaction, so it is all-or-nothing. - Tests:
keystone/src/tests/mcq/(test_current_affairs.py+ a featureconftest.pythat seeds taxonomy/admin, stubs the three count-task.delaycalls, and shimsrun_with_transactionfor mongomock, which has no sessions).
Naming: PRD vs code (as built)
| PRD term | Code (shipped) |
|---|---|
current_affairs field | McqDBModel.current_affairs: Optional[List[CurrentAffairsEntry]] — src/models/mcq_models/db_model.py:329; mirrored on McqProjection (projection_model.py:219); collection mcqs |
| current-affairs entry | CurrentAffairsEntry(BaseModel) {displayid, title, body} — db_model.py:63. Lenient value object (no raising validators); CA validation lives in the service so bulk can collect per-row errors |
| admin create/update carries CA | MCQCreateRequest / MCQUpdateRequest — src/api/av1/mcq/schemas.py; unpacked in routes.py create (~L116) + update (~L622); MCQService.create / update_by_id → McqRepository.create (current_affairs param) |
| service validation | MCQService._collect_ca_entry_errors (pure: body-is-array, non-empty, within-list dedup, ≤100 cap), _validate_current_affairs (fail-fast + cross-doc), _merge_ca_by_displayid (upsert preserving order) — services/mcq_services.py |
| cross-doc uniqueness | McqRepository.get_ca_displayid_owners(course_id, displayids) (mcq_repository.py:524); index mcq1_course_ca_displayid_idx (course_id, current_affairs.displayid), unique, partialFilterExpression={"current_affairs.displayid": {"$exists": True}} (db_model.py:647) — catches cross-document collisions only |
| bulk upload endpoint | POST /av1/mcqs/current-affairs/bulk, operation_id="av1_mcq_post_current_affairs_bulk" — routes.py:142; MCQService.bulk_upsert_current_affairs → McqRepository.bulk_set_current_affairs(..., session=) (bulk_write([UpdateOne]) in run_with_transaction). Schemas: MCQCurrentAffairsBulk{Row,Request,RowError,Response} |
| client read exposure | current_affairs: Optional[List[CurrentAffairsEntryResponse]] on v1 MCQDetailsListResponse (src/api/v1/mcq/schemas.py:398; /v1/mcqs/sync + /batch) and v2 MCQDetailsResponse (src/api/v2/mcq/schemas.py:144; /v2/mcqs/batch); also av1 MCQDetailsResponse for the manage-page round-trip |
| error code / limits | ErrorCode.MCQ_CURRENT_AFFAIRS_INVALID_DATA = 5308; MAX_CURRENT_AFFAIRS_PER_MCQ=100, MAX_MCQ_CA_BULK_ROWS=500, MAX_MCQ_CA_BULK_SIZE_MB=5 (constants/limits.py) |
| course gate (UPSC) | Not enforced server-side (see deviations). FE-only: CourseEnum.UPSC / getCourseIdFromEnv() in keystone-web |
| admin page (M4/M5) | /mcq/current-affairs (keystone-web) — src/pages/mcq/CurrentAffairsMcq.tsx; stacked entry cards reuse MCQPlateEditorField (src/components/mcq/current-affairs/); bulk dialog mirrors BulkCreateMcqs conventions; pure logic + Vitest in src/lib/current-affairs.ts; client mcqApi.bulkCurrentAffairs in src/api/mcqs.ts. See “Frontend (M4–M5, as built)“ |
Decisions & deviations from the PRD (as built)
- No server-side UPSC course gate. The bulk endpoint validates each
short_uidexists within the suppliedcourse_idbut does not requirecourse_id == UPSC; CA is stored on any course. The FE env-gate is the only UPSC gate. (PRD said “bulk validates UPSC server-side” — dropped by owner decision.) - Bulk caps are net-new. No backend bulk-MCQ endpoint or caps existed to reuse (
BulkCreateMcqsis a keystone-web FE component), soMAX_MCQ_CA_BULK_ROWS/MAX_MCQ_CA_BULK_SIZE_MB/MAX_CURRENT_AFFAIRS_PER_MCQwere defined fresh. - Bulk transport is a typed JSON body, not a multipart file upload (
MCQCurrentAffairsBulkRequest). - CA validation is centralized in
MCQService(model + request payload are lenient, no raising Pydantic validators) — a raising validator would 422 a whole bulk request on the first bad row, defeating the per-row error list. bodyplaintext, nosole-style column. On v1 the field rides the existingencrypt_v1_client_payloadtransport envelope (plaintext at rest, encrypted in transit like the rest of the v1 payload); v2 returns it plaintext alongsidesolution.- Within-MCQ
displayiddedup is app-layer, not the index (a unique multikey index only enforces uniqueness across separate documents).
Surface & semantics
| Surface | Routes | Notes |
|---|---|---|
| av1 (admin) | POST /mcqs + PATCH /mcqs/{id} (incl. current_affairs); POST /mcqs/current-affairs/bulk | Editor/Admin JWT. Update semantics: field omitted/null = unchanged, [] = cleared, list = replaced. Bulk returns {applied, mcqs_updated, entries_upserted, errors[]}; applied=false + 0 written on any error. No UPSC course gate. |
| v1 / v2 (client) | GET /v1/mcqs/sync, GET /v1/mcqs/batch, GET /v2/mcqs/batch | current_affairs read-only; null for MCQs without it. Not added to the v2 slim year-list or custom_test responses. No search / question_preview coupling. |
Migration
No data backfill — new optional embedded field; existing MCQs read as null. Index
mcq1_course_ca_displayid_idx {course_id, "current_affairs.displayid"} (unique, partial on
$exists) is created by Beanie on init.
Frontend (M4–M5, as built)
Shipped in keystone-web — branch feat/mcq-current-affairs-fe, merged via PR #483 (base dev,
2026-06-29). Verified: tsc -b --noEmit clean; 26/26 Vitest unit tests pass (src/lib/__tests__/).
- Spec:
keystone-web/docs/superpowers/specs/2026-06-29-mcq-current-affairs-fe-design.md - Plan:
keystone-web/docs/superpowers/plans/2026-06-29-mcq-current-affairs-fe.md - Approach: dedicated UPSC-gated page at
/mcq/current-affairs— search/select an MCQ (McqSearchSelect, debouncedmcqApi.list), manage{displayid, title, PlateJS body}entries as stacked editor cards (CurrentAffairsEntryCardreusingMCQPlateEditorField), save the full list viaPATCH /av1/mcqs/{id}(replace semantics;[]clears). The bulk dialog parses a JSON file, pre-validates locally (shape, body-is-array, in-filedisplayiddedup, caps), and POSTs typed JSON{rows}toPOST /av1/mcqs/current-affairs/bulk?course_id=…— all-or-nothing; onapplied=falseit renders the server’s per-row error report. Pure logic is isolated insrc/lib/current-affairs.ts.
Files (new unless noted):
| Area | Path |
|---|---|
| Page | src/pages/mcq/CurrentAffairsMcq.tsx — course guard, MCQ search, load/save, hosts bulk dialog; wrapped in the sidebar shell |
| Components | src/components/mcq/current-affairs/{McqSearchSelect,CurrentAffairsEntryCard,CurrentAffairsManager,CurrentAffairsBulkDialog}.tsx |
| Lib | src/lib/current-affairs.ts (caps + body (de)serialize + manual/bulk validation + sample); src/lib/api-error.ts (shared extractApiErrorMessage) |
| Tests | src/lib/__tests__/{api-error,current-affairs.body,current-affairs.validate,current-affairs.bulk}.test.ts (26 tests) + vitest.config.ts (new Vitest harness) |
| API client (mod) | src/api/mcqs.ts — CurrentAffairsEntry; current_affairs on MCQPayload/MCQResponse; bulk req/res types; mcqApi.bulkCurrentAffairs(rows, courseId) |
| Route (mod) | src/routes/AppRoutes.tsx — <Route path="/mcq/current-affairs"> under ProtectedRoute |
| Sidebar (mod) | src/components/sidebar/chatgpt-sidebar.tsx — UPSC-gated “Current Affairs” entry under the MCQ group |
FE deviations from the FE spec/plan (logged):
- Sidebar target. The menu entry landed in the active sidebar
chatgpt-sidebar.tsx(nested under the existing MCQ group), notapp-sidebar.tsxas the plan assumed (app-sidebaris not the rendered sidebar). UPSC gating unchanged (getCourseIdFromEnv() === CourseEnum.UPSCconditional spread). - Page sidebar shell.
CurrentAffairsMcqwraps its content inSidebarProvider/ChatGPTSidebar/SidebarInset(+ mobileSidebarTrigger) so the sidebar persists like other tabs. - Clear-confirm via
AlertDialog(notwindow.confirm) for the “save with no entries clears CA” flow. - Stale-load race guard (
loadSeqRef) so a slowgetByIdcan’t overwrite a newer MCQ selection; same guard on the search box.
Aligned with the backend (PR #796): typed JSON bulk body (no multipart); course_id as a query
param; HTTP-200 all-or-nothing error report (applied/errors[] inspected, not treated as an HTTP
error); caps mirror the BE (MAX_CURRENT_AFFAIRS_PER_MCQ=100, MAX_MCQ_CA_BULK_ROWS=500,
MAX_MCQ_CA_BULK_SIZE_MB=5). The manage page loads existing CA from the av1 MCQ detail response
(current_affairs on MCQDetailsResponse) and re-snapshots from the PATCH response.