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 assertCurriculumNotInUseLocked → CURRICULUM_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 }, defaultDRAFT) 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)¶
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 }); addERROR_TEXT_PARAMSentries (CLASS_REQUIRES_READY_CURRICULUM: ['status'],CURRICULUM_IN_USE_BY_CLASSES: ['homeroomCount', 'subjectGroupCount']) — bijection with the{{}}placeholders.src/common/i18n/error-messages.catalog.ts— adden_US+it_IT, self-contained (all text params present as{{placeholders}}), in the "Curriculum Selection + lifecycle" block.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) andcurriculum.swagger.ts(the status-switch op description already narrates demote refusals — extend it to mention the classes refusal). NooneOfneeded; existingAppExceptionenvelope 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 →createthrowsCLASS_REQUIRES_READY_CURRICULUM,prisma.homeroom.createnot called. Existing create-success cases updated so their curriculum mock returnsstatus: 'READY'.src/subject-groups/subject-groups.service.spec.ts— new case: anchor whosecurriculum.statusis DRAFT →createthrowsCLASS_REQUIRES_READY_CURRICULUM,prisma.subjectGroup.createnot called. Existing success cases' anchor mock gainsstatus: '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:
wizardCurriculumSelectincludesstatus;curriculumSubjectAnchorSelect.curriculum.selectincludesstatus; the new count query. - E2E specs:
test/homerooms.e2e-spec.ts/test/subject-groups.e2e-spec.ts(or wherever class creation is exercised) — assert409on a DRAFT curriculum,201after 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
/verifyor 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.