Owner Burhan Kapdawala

MCQ Current Affairs — PRD

Context

The MCQ module already ships — MCQs carry a question, options, an admin/client solution (solution_adminsolution_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_affairs is a UPSC concept. The admin web app is a single-course deploymentgetCourseIdFromEnv() reads VITE_COURSE_ID — so the entire feature (page, bulk upload, menu entry) is gated behind courseId === CourseEnum.UPSC and is never rendered in non-UPSC admin builds. The backend stores the field on every MCQ regardless of course, but it stays empty/null outside UPSC. This is acceptable and expected.

Scope

MVP — current affairs as a managed sub-list of each UPSC MCQ:

  1. current_affairs field on MCQ — an embedded list of {displayid, title, body(PlateJS)} objects, stored plaintext, populated only for UPSC.
  2. 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.
  3. Bulk upload (UPSC-only) — a JSON file keyed by MCQ short_uid that writes current-affairs lists onto many MCQs at once, upserting by displayid, all-or-nothing.
  4. Client read exposurecurrent_affairs returned as-is on both v1 and v2 client MCQ responses; empty for non-UPSC MCQs.

Data Model (requirements-level)

Storage location (decided): current_affairs is embedded on the MCQ document, following the existing mcq_docket_link: Optional[List[MCQDocketLinkInfo]] convention — not a standalone collection. There is one current-affairs item per displayid, living on exactly one MCQ.

No encryption (decided): unlike solution_client (encrypted at rest into sole), current affairs is informational and answer-neutral, so body is stored plaintext — no ciphertext column.

Body fidelity (decided): body is a PlateJS JSON string, stored as-is (same format the admin MCQPlateEditorField emits 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:

FieldTypeNotes
displayidstrRequired. Admin- / file-supplied. Unique per course (UPSC-wide) — see Validation.
titlestrRequired. Plain text label.
bodystrRequired. PlateJS JSON array, serialized to a string. Stored plaintext.

MCQ changes

FieldTypeNotes
current_affairsOptional[List[<entry>]]Default null/empty. Populated only for UPSC MCQs. Order = insertion/file order (preserved across upserts).

Validation

displayid uniqueness (decided): a displayid identifies 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.

RuleWhen
displayid non-empty, unique within the target MCQ’s listevery write
displayid not already present on a different MCQ in the same courseevery write
body parses as a PlateJS JSON arrayevery write
title non-emptyevery write
Target MCQ (short_uid) exists in the coursebulk 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.

  1. Manual manage — search/select an MCQ by short_uid; the page loads that MCQ’s current_affairs and lets the editor add / edit / remove entries. Each row is a displayid field, a title field, and the reused MCQPlateEditorField for body. Saving issues a normal PATCH /av1/mcqs/{id} carrying the updated current_affairs array (subject to Validation).
  2. Bulk upload — a button on the same page opens the JSON-file picker (reusing the BulkCreateMcqs upload 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\":[…]}]" }
    ] }
]
AspectBehaviour
MCQ keyshort_uid (matched within the UPSC course).
MergeUpsert by displayid within each target MCQ’s list — matching displayid updates in place (position preserved); new displayid appends.
Failure modeAll-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).
LimitsReuse existing bulk caps (≤500 row-groups, ≤5 MB JSON).
AuthAdmin/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

#MilestoneOutcomeStatusPlan
1Backend field + write pathMCQ stores current_affairs; av1 create/update accept it with per-course displayid uniqueness and PlateJS-body validation✅ done (PR #796)BE plan ¹
2Bulk upload endpointEditors 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 ¹
3Client read exposurev1 & v2 client MCQ responses include current_affairs✅ done (PR #796)BE plan ¹
4Admin 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 ²
5Admin bulk-upload UIUPSC 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, base dev). ² 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, base dev).

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

RiskLikelihoodImpactMitigation
Cross-document displayid collision. App-layer uniqueness check races under concurrent writes; two MCQs end up sharing a displayid.LowMed — ambiguous course-wide referenceCompound {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.MedMed — broken render for that entryServer 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.LowMed — CA attached to wrong MCQAll-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.LowLow — empty, but confusingSingle 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 #796feat(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_affairs is an embedded list on the MCQ document (mirrors mcq_docket_link), body stored plaintext. displayid uniqueness is enforced in MCQService on 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 one run_with_transaction, so it is all-or-nothing.
  • Tests: keystone/src/tests/mcq/ (test_current_affairs.py + a feature conftest.py that seeds taxonomy/admin, stubs the three count-task .delay calls, and shims run_with_transaction for mongomock, which has no sessions).

Naming: PRD vs code (as built)

PRD termCode (shipped)
current_affairs fieldMcqDBModel.current_affairs: Optional[List[CurrentAffairsEntry]]src/models/mcq_models/db_model.py:329; mirrored on McqProjection (projection_model.py:219); collection mcqs
current-affairs entryCurrentAffairsEntry(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 CAMCQCreateRequest / MCQUpdateRequestsrc/api/av1/mcq/schemas.py; unpacked in routes.py create (~L116) + update (~L622); MCQService.create / update_by_idMcqRepository.create (current_affairs param)
service validationMCQService._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 uniquenessMcqRepository.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 endpointPOST /av1/mcqs/current-affairs/bulk, operation_id="av1_mcq_post_current_affairs_bulk"routes.py:142; MCQService.bulk_upsert_current_affairsMcqRepository.bulk_set_current_affairs(..., session=) (bulk_write([UpdateOne]) in run_with_transaction). Schemas: MCQCurrentAffairsBulk{Row,Request,RowError,Response}
client read exposurecurrent_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 / limitsErrorCode.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_uid exists within the supplied course_id but does not require course_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 (BulkCreateMcqs is a keystone-web FE component), so MAX_MCQ_CA_BULK_ROWS/MAX_MCQ_CA_BULK_SIZE_MB/MAX_CURRENT_AFFAIRS_PER_MCQ were 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.
  • body plaintext, no sole-style column. On v1 the field rides the existing encrypt_v1_client_payload transport envelope (plaintext at rest, encrypted in transit like the rest of the v1 payload); v2 returns it plaintext alongside solution.
  • Within-MCQ displayid dedup is app-layer, not the index (a unique multikey index only enforces uniqueness across separate documents).

Surface & semantics

SurfaceRoutesNotes
av1 (admin)POST /mcqs + PATCH /mcqs/{id} (incl. current_affairs); POST /mcqs/current-affairs/bulkEditor/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/batchcurrent_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, debounced mcqApi.list), manage {displayid, title, PlateJS body} entries as stacked editor cards (CurrentAffairsEntryCard reusing MCQPlateEditorField), save the full list via PATCH /av1/mcqs/{id} (replace semantics; [] clears). The bulk dialog parses a JSON file, pre-validates locally (shape, body-is-array, in-file displayid dedup, caps), and POSTs typed JSON {rows} to POST /av1/mcqs/current-affairs/bulk?course_id=… — all-or-nothing; on applied=false it renders the server’s per-row error report. Pure logic is isolated in src/lib/current-affairs.ts.

Files (new unless noted):

AreaPath
Pagesrc/pages/mcq/CurrentAffairsMcq.tsx — course guard, MCQ search, load/save, hosts bulk dialog; wrapped in the sidebar shell
Componentssrc/components/mcq/current-affairs/{McqSearchSelect,CurrentAffairsEntryCard,CurrentAffairsManager,CurrentAffairsBulkDialog}.tsx
Libsrc/lib/current-affairs.ts (caps + body (de)serialize + manual/bulk validation + sample); src/lib/api-error.ts (shared extractApiErrorMessage)
Testssrc/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.tsCurrentAffairsEntry; 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):

  1. Sidebar target. The menu entry landed in the active sidebar chatgpt-sidebar.tsx (nested under the existing MCQ group), not app-sidebar.tsx as the plan assumed (app-sidebar is not the rendered sidebar). UPSC gating unchanged (getCourseIdFromEnv() === CourseEnum.UPSC conditional spread).
  2. Page sidebar shell. CurrentAffairsMcq wraps its content in SidebarProvider/ChatGPTSidebar/SidebarInset (+ mobile SidebarTrigger) so the sidebar persists like other tabs.
  3. Clear-confirm via AlertDialog (not window.confirm) for the “save with no entries clears CA” flow.
  4. Stale-load race guard (loadSeqRef) so a slow getById can’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.