Skip to content

Curriculum READY gate for classes (no classes on DRAFT; clean demote)

1. Problem distillation

  • Today a Homeroom or standalone Subject Group can be created against a DRAFT curriculum — neither create path even selects Curriculum.status, let alone checks it.
  • Because classes anchor on curriculum subjects (homeroom-bound child SGs are auto-created per mandatory subject), an admin who is still undecided about a curriculum gets their subjects locked prematurely: they can no longer remove a subject once a class exists on it.
  • The fix is a lifecycle split: DRAFT = "still deciding" → fully mutable, zero classes allowed; READY = "committed" → classes allowed. The safe editing sandbox becomes DRAFT, so an undecided admin simply keeps the curriculum in DRAFT and edits freely.
  • To keep that split airtight, demoting a curriculum (READY→DRAFT) must be refused while any class still hangs off it — otherwise DRAFT could hold classes and the "DRAFT is always editable" guarantee breaks.

Success criteria (observable behavior that proves this works): - POST /homerooms against a DRAFT curriculum → 409 CLASS_REQUIRES_READY_CURRICULUM; no rows written. - POST /subject-groups whose anchor subject belongs to a DRAFT curriculum → 409 CLASS_REQUIRES_READY_CURRICULUM; no rows written. - Both succeed unchanged once the curriculum is READY. - PATCH /curricula/:id/status { status: DRAFT } on a curriculum that has ≥1 Homeroom or ≥1 SubjectGroup → 409 CURRICULUM_IN_USE_BY_CLASSES; status unchanged. - The same demote succeeds once all homerooms/SGs are deleted (and no selection references it — existing rule). - A DRAFT curriculum, having no classes, remains freely grid-editable (subject add/remove) exactly as today.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Not changing the subject-removal guard (guardAndClearStandaloneSGs). Per the 2026-07-06 decision, removing a subject on a READY curriculum stays gated on courses in genuine use (students); empty standalone courses are still silently cleared. (This is the deliberate reconciliation of the written "no courses linked" ask — see §8.) - Not cascading/auto-deleting classes on demote — demote refuses instead. - Not touching the referent selection window (already READY-gated). - Not touching removeCurriculum (hard delete) — it keeps clearing empty standalone SGs so the FK cascade works. - No new RBAC entity/scope/action; no schema change.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/curriculum/curriculum.service.ts switchCurriculumStatus (READY→DRAFT branch calling assertCurriculumNotInUseLockedCURRICULUM_IN_USE_BY_SELECTIONS) The exact "refuse a status transition while dependents exist, inside the row-locked tx" shape. CURRICULUM_IN_USE_BY_CLASSES is a sibling of CURRICULUM_IN_USE_BY_SELECTIONS. Selections are counted via a relation on the curriculum; classes need a two-source count (homerooms by curriculumId, SGs by curriculumSubject.curriculumId).
src/students/curriculum-selection gate → SELECTION_CURRICULUM_NOT_READY (params { curriculumId, status }) The "this curriculum is not READY, here's its current status" error idiom + params shape — CLASS_REQUIRES_READY_CURRICULUM mirrors it verbatim. That gate is on the referent selection write; ours is on the two class-create service methods.
src/homerooms/homerooms.service.ts resolveCurriculumForWizard (throws CURRICULUM_GRADE_NOT_COVERED after loading the wizard curriculum row) The always-run pre-tx validation seam where the READY check belongs; wizardCurriculumSelect just gains status: true. Fits cleanly.
src/subject-groups/subject-groups.service.ts create + findCurriculumSubjectAnchor (curriculumSubjectAnchorSelect already nests curriculum: { select: { academicYearId } } and null-checks it) The anchor-load seam; the select gains status, and the READY check sits right after the existing academicYearId !== yearId guard. Fits cleanly.
curriculum-structure-sync.ts guardAndClearStandaloneSGs / guardAndClearSubjectGroupsForCurriculum Reference for the unchanged subject-removal semantics we are deliberately NOT altering (§8). Also: demote's now-dead guardAndClearSubjectGroupsForCurriculum call is removed. It's a clear-or-block helper; the demote guard we add is refuse-only (no clearing).

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Both create paths already resolve under tenantId (homeroom via findCurriculumForWizard({tenantId,...}); SG via curriculum: { tenantId } in the anchor where). Demote already filters tenantId. No new tenant surface.
Academic-year scope yes Both creates resolve the active year and match academicYearId; the READY check reads status off the same already-loaded, year-matched row. Demote resolves active year via resolveActiveYear. Status is per-(tenant, AY) curriculum row.
RBAC entity key existing none — homerooms/subject-groups/curriculum entities unchanged. Pure write-side gate; no visibility change.
Scopes existing none.
Actions none none — create (homeroom/SG) and the curricula.configuration write behind PATCH /curricula/:id/status are unchanged. The gate is a service-layer precondition, not a new permission.
Service base custom HomeroomsService / SubjectGroupsService / CurriculumService (none extend BaseTenantedCrudService). Checks live in the existing service methods. Matches these modules' hand-rolled create/transition shape.
queries.ts shape include/select consts wizardCurriculumSelect (homerooms.queries.ts) += status; curriculumSubjectAnchorSelect (subject-groups.queries.ts) curriculum.select += status; new countClassesForCurriculum(tx, curriculumId) (or inline homeroom count + SG count) in curriculum.queries.ts for the demote guard. Keeps Prisma in queries.ts per convention.
Error codes new (2) CLASS_REQUIRES_READY_CURRICULUM (params { curriculumId, status }, text param status) and CURRICULUM_IN_USE_BY_CLASSES (params { curriculumId, homeroomCount, subjectGroupCount }, text params homeroomCount+subjectGroupCount). Both 409. See §5 for the 3-file plumbing.
DTO conventions existing No DTO change — the gate rejects before/inside the existing create flow; response DTOs unchanged.
File-backed sub-resources n/a — no files involved.
Custom fields n/a — no custom-field surface touched.
Profile completeness n/a — not a person entity.

4. Data model plan

Schema deltas

  • None. Curriculum.status (CurriculumStatus { DRAFT, READY }, default DRAFT) already exists.

Migration shape

  • n/a — no schema change, no migration.

Indexes and uniqueness

  • n/a — no schema change. Demote-guard counts run against existing FKs (Homeroom.curriculumId, SubjectGroup → CurriculumSubject.curriculumId).

5. API surface

No new routes. Three existing endpoints gain a precondition:

Verb Path Decorators Request DTO Response DTO
POST /homerooms unchanged (@RequireAction(HOMEROOM, 'create')) CreateHomeroomDto HomeroomDetailDto (or 409 CLASS_REQUIRES_READY_CURRICULUM)
POST /subject-groups unchanged (@RequireAction(SUBJECT_GROUP, 'create')) CreateSubjectGroupDto SubjectGroupDetailDto (or 409 CLASS_REQUIRES_READY_CURRICULUM)
PATCH /curricula/:id/status unchanged (curricula.configuration write) SwitchCurriculumStatusDto CurriculumStatusResponseDto (or 409 CURRICULUM_IN_USE_BY_CLASSES on demote)

Error-code plumbing (3 files each, per the i18n contract)

  1. src/common/constants/error-codes.ts — add both enum members; add params shapes to the typed map (CLASS_REQUIRES_READY_CURRICULUM: { curriculumId: string; status: string }; CURRICULUM_IN_USE_BY_CLASSES: { curriculumId: string; homeroomCount: number; subjectGroupCount: number }); add ERROR_TEXT_PARAMS entries (CLASS_REQUIRES_READY_CURRICULUM: ['status'], CURRICULUM_IN_USE_BY_CLASSES: ['homeroomCount', 'subjectGroupCount']) — bijection with the {{}} placeholders.
  2. src/common/i18n/error-messages.catalog.ts — add en_US + it_IT, self-contained (all text params present as {{placeholders}}), in the "Curriculum Selection + lifecycle" block.
  3. src/common/constants/error-examples.ts — add a Swagger example for each.

Swagger considerations

  • Add the two 409 examples to homerooms.swagger.ts / subject-groups.swagger.ts (create ops) and curriculum.swagger.ts (the status-switch op description already narrates demote refusals — extend it to mention the classes refusal). No oneOf needed; existing AppException envelope covers it.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none
*_SCOPES runtime constant none

No RBAC change — this is a service-layer precondition on existing actions.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Demote branch clears empty standalone SGs (guardAndClearSubjectGroupsForCurriculum) then flips status Removing that call from the demote path (kept in removeCurriculum) and refusing on any class instead Under the new invariant DRAFT holds zero classes, so demote must refuse-not-clear; the clear call becomes unreachable once the refuse-on-any-class guard passes Demote is now stricter (empty classes block it too); admin deletes classes first. Consistent with the chosen "refuse while classes exist" option.
CURRICULUM_IN_USE_BY_* codes so far cover selections only Adding a parallel CURRICULUM_IN_USE_BY_CLASSES rather than overloading the selections code Distinct remediation (delete classes vs. clear selections) deserves a distinct code + params One more error code.

8. Pushback log

US says Conflicts with Proposed instead Status
"When a curriculum is ready, it can be modified by removing a subject only if there are no courses linked" (implies ANY course, incl. empty, blocks) The 2026-07-06 answer selected "any course with students only", i.e. keep today's guardAndClearStandaloneSGs (empty standalone courses silently cleared) Leave the subject-removal guard unchanged; the real problem (premature locking of an undecided curriculum) is solved by R1 (no classes on DRAFT) since a DRAFT curriculum then always stays freely editable Resolved — user picked the "no-op on subject-removal" option in chat 2026-07-06; flagged and confirmed.

9. Deferrals

  • Widening the subject-removal guard to block on empty courses too — explicitly declined this iteration (§8) — follow-up: revisit only if the DRAFT-editing sandbox proves insufficient in practice.
  • Auto-clear-on-demote (delete empty classes instead of refusing) — declined in favor of refuse — follow-up: revisit if the manual "delete classes then demote" step is too heavy.
  • The removeCurriculum (hard delete) path — untouched; still clears empty standalone SGs and blocks on SG-with-students. Not in scope.

10. Open questions

  • None — both design decisions resolved in chat 2026-07-06 (subject-removal = no change; demote = refuse while classes exist).

11. Verification plan

  • Unit specs:
  • src/homerooms/homerooms.service.spec.ts — new case: DRAFT curriculum → create throws CLASS_REQUIRES_READY_CURRICULUM, prisma.homeroom.create not called. Existing create-success cases updated so their curriculum mock returns status: 'READY'.
  • src/subject-groups/subject-groups.service.spec.ts — new case: anchor whose curriculum.status is DRAFT → create throws CLASS_REQUIRES_READY_CURRICULUM, prisma.subjectGroup.create not called. Existing success cases' anchor mock gains status: 'READY'.
  • src/curriculum/curriculum.service.spec.ts — new case: demote (READY→DRAFT) with a homeroom count > 0 and/or SG count > 0 → CURRICULUM_IN_USE_BY_CLASSES; status unchanged. Existing demote-success case gains zero-class counts. Confirm the selections check still fires first/independently.
  • Query-shape specs: wizardCurriculumSelect includes status; curriculumSubjectAnchorSelect.curriculum.select includes status; the new count query.
  • E2E specs:
  • test/homerooms.e2e-spec.ts / test/subject-groups.e2e-spec.ts (or wherever class creation is exercised) — assert 409 on a DRAFT curriculum, 201 after flipping to READY. Audit existing fixtures: any that create a class must first set the curriculum READY (the seed/fixture default is DRAFT).
  • test/curriculum.e2e-spec.ts — demote refused with a live class; succeeds after deletion.
  • Manual verification (via /verify or curl): create curriculum (DRAFT) → attempt homeroom (expect 409) → flip READY → homeroom succeeds → attempt demote (expect 409 classes) → delete homeroom → demote succeeds.

Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns.md for E2E discipline.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-06
  • Chat reference: design walkthrough with Fabio, 2026-07-06; two decisions locked via AskUserQuestion (subject-removal = no change; demote = refuse while classes exist); design approved ("go on") and spec reviewed + approved ("go on") same session.

Until this section is filled, no implementation code is written.