Skip to content

Auto-seed standalone Subject Groups when a curriculum is offered

1. Problem distillation

  • Building classes starts from an empty Subject-Group catalog. For departments that are not homeroom-driven (option 3 of the brainstorm: a tenant mixes homeroom and non-homeroom departments), the admin has to hand-create one standalone SG per subject before any student/teacher assignment can happen.
  • When a curriculum becomes offered (status = READY), we already know every subject it teaches per grade (the grid cells). We can pre-create one empty standalone SG per (subject, offered grade) so the catalog is ready to fill.
  • The blocker is an existing invariant: a Subject Group referencing a subject — even an empty one — already hard-blocks deleting/renaming that subject (assertSubjectsNotInUseBySubjectGroups, keyed on SG existence). Auto-seeding empties everywhere would freeze the offered curriculum's grid solid. So auto-seed is inseparable from re-keying that lock from "an SG exists" → "an SG has students."
  • This iteration delivers both as one coherent change. The homeroom wizard is untouched; a grade's homeroom-bound SG and the standalone auto-SG coexist (a student lands in exactly one per subject — the @@unique([studentId, curriculumSubjectId, academicYearId]) backstop holds).

Success criteria (observable behavior that proves this works): - A curriculum entering READY — at three trigger sites: POST /curricula with status: READY; PATCH /curricula/:id on the DRAFT→READY rising edge; and the setup-wizard bulkSync, which (decision B) now forces every setup curriculum to READY and seeds (see §7) — creates exactly one standalone SG (homeroomId IS NULL) per grid cell of every subject — mandatory (optionBlockId IS NULL) and each in-block alternative (optionBlockId IS NOT NULL) — with empty roster and empty teacher list. Idempotent: a (subject, grade) cell that already has a standalone SG is skipped. - After a curriculum is offered, an admin can still delete or rename a subject whose SGs are all empty — the empty SGs are silently cascade-deleted so the Restrict FK doesn't fire. Deleting/renaming a subject (or removing a grade offering) whose SG has ≥1 student assignment returns a typed 409. - Removing a grade cell / unlinking a grade follows the same rule: empty → cleanup, has-students → 409. - Homeroom create/edit behavior is byte-for-byte unchanged. GET /subject-groups lists the new empty standalones.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Selection-window invalidation + resubmission when a chosen (in-block) subject is deleted during an open window — iteration 2 (separate spec). - Notifications / re-ping of referents — deferred past iteration 2. - Seeding more than one group per cell (class splits) — we seed "at least one"; admins add extras manually. - Seeding for DRAFT curricula, or auto-seeding subjects added after the curriculum is already offered (see §7 — seed fires only on the rising edge into READY; post-offer additions are manual or via demote→re-offer). - A SubjectGroup.origin (AUTO/MANUAL) flag — deliberately avoided (see §4); no schema change this iteration.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/homerooms/homerooms.service.ts (create() wizard cascade) Creating child SubjectGroups inside one $transaction; the nested-write shape; the "one SG per mandatory cell" enumeration Wizard seeds homeroom-bound SGs with a roster from a dedicated endpoint; ours seeds empty standalone SGs as a side-effect of a curriculum write, and includes in-block subjects
src/curriculum/curriculum-structure-sync.ts (assertSubjectsNotInUseBySubjectGroups, assertTracksNotInUse, onDelete guards) The guard-in-onDelete placement, lock-then-check, typed-409 (CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS) shape Current guard keys on SG existence; we re-key to student count and add empty-SG cleanup before the subject/cell delete
src/students/class-selection-sync.ts (applyClassAssignmentToSelection) Precedent for a required, non-fail-soft cross-module side-effect inside the existing tx, with idempotent "only fill what's absent" semantics That syncs selections from roster writes; ours seeds SGs from a curriculum status transition
src/curriculum/resolve-target-plan.ts + selection-consistency.ts (track/grade applicability predicates) Reuse the pure "subject applicable in (track, grade) and offered (has a cell)" enumeration so the seed set can't drift from the selection/coverage logic resolve-target-plan counts the target plan; we enumerate cells to seed — same predicates, different consumer
src/curriculum/curriculum.service.ts (assertCurriculumNotInUseLocked, demote path) lockCurriculumRow then count-in-use; the READY→DRAFT demote chokepoint Adds: block demote on SG-with-students; cleanup empty auto-SGs on demote

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Seeded SGs inherit tenantId from the curriculum; all guard queries scoped by tenant SubjectGroup is tenant-scoped already
Academic-year scope yes Seeded SGs get academicYearId = curriculum's AY (active year); guards count assignments in that AY SG + SGA both carry academicYearId
RBAC entity key existing (subject_groups, curricula) none new Reuses both entities
Scopes existing (curriculum.configuration) Seed + guards run inside the existing PATCH /curricula/:id (and setup wizard) path, already gated by curriculum.configuration write. No subject_groups scope needed — writes are server-side, not via the SG endpoints The admin who offers the curriculum already holds the curriculum-config write
Actions none No new action keys; read/update implicit Lifecycle change rides existing PATCH
Service base custom CurriculumService + curriculum-structure-sync.ts helpers; new pure helper ensure-standalone-subject-groups.ts Matches existing curriculum module shape (named functions, no repo classes)
queries.ts shape extend New named fns: findOfferableSubjectCells(tx, curriculumId), findExistingStandaloneSGKeys(tx, …), countStudentsInSubjectGroupsForSubjects(tx, subjectIds, ay), deleteEmptyStandaloneSGsForSubjects/Cells(tx, …) Keep raw Prisma in *.queries.ts per module convention
Error codes reuse + enrich Reuse CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS; change semantics to "has students", enrich params with studentCount. Same code covers grade-cell removal (params add gradeId). No new code Avoid code proliferation; the dispatcher already maps this token
DTO conventions none No request/response DTO change — seed/guards are internal to existing write paths The SG list/detail DTOs already render the new rows
File-backed sub-resources n/a — SGs hold no files
Custom fields n/a Seeded SGs default customFields = {} Existing column default
Profile completeness no Subject groups are not in completeness curation n/a

4. Data model plan

Schema deltas

  • None. Seeded rows are ordinary SubjectGroup records (all required columns — tenantId, academicYearId, name, curriculumSubjectId, gradeId — are derivable; homeroomId = NULL, customFields = {}, no teachers/assignments).

Why no origin flag. A naive "auto vs manual" flag would only be needed if we re-seeded on every post-offer edit and had to avoid resurrecting admin-deleted auto-SGs. We sidestep that by seeding only on the rising edge into READY (§7) and treating every empty standalone SG uniformly as disposable scaffolding. So no column, no migration.

Migration shape

  • n/a — no migration this iteration.

Seeded SG name

  • Deterministic: ${subjectName} — ${gradeName} (em-dash), truncated to VarChar(150). Distinct from the homeroom-bound name rule (${subjectName} - ${homeroomName}), so a standalone and a bound SG for the same (subject, grade) never collide on the unique key. The name is for display only; identity for idempotency is the (academicYearId, gradeId, curriculumSubjectId, homeroomId IS NULL) tuple, not the name.

Indexes and uniqueness

  • Relies on existing @@unique([academicYearId, gradeId, curriculumSubjectId, name]) for name-collision safety. Idempotency is enforced explicitly (compute the missing (subject, grade) set by querying existing standalone SGs for the curriculum) rather than by name-collision/skipDuplicates, so an admin-renamed auto-SG is not duplicated on a re-offer.

5. API surface

Verb Path Decorators Request DTO Response DTO
(none new)

The change is entirely behavioral on existing endpoints: PATCH /curricula/:id (status → READY seeds; structure edits re-keyed guard), the setup-wizard curriculum step, and the read side GET /subject-groups (now returns the seeded empties — no shape change).

Swagger considerations

  • Refresh the error example for CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS on PATCH /curricula/:id — wording shifts from "referenced by one or more subject groups" to "has students assigned in one or more subject groups", params gain studentCount. Keep it FE-facing copy only.

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 catalogue change: no new scope/action/entity; the seed and guards execute inside paths already gated by curriculum.configuration write.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
On-axis CRUD side-effects (chapter 16 §4–5) A curriculum write now produces SubjectGroup rows (a new cross-module cascade) and the curriculum-edit lock becomes a new student-keyed write-side gate — both off-axis The seed is the whole point; it must be a required (not fail-soft) side-effect in the same tx so an offered curriculum can never exist without its skeleton, and the grid can never be edited around a running class Off-axis cost reviewed and accepted; mitigated by no schema change and reuse of selection-consistency predicates
Idempotent top-up on every ready-state write Seed fires only on the rising edge into READY (status was ≠ READY before, = READY after — incl. create-as-READY / wizard finalize), not on edits while already READY Re-seeding on every READY-state edit would resurrect admin-deleted empty auto-SGs. Rising-edge-only avoids it without an origin flag. Post-offer subject additions are therefore not auto-seeded (admin creates the SG, or demote→re-offer re-runs the idempotent seed) Accept that a subject added to an already-offered curriculum has no auto-SG until a re-offer; accept that demote→re-offer recreates a manually-deleted empty auto-SG (re-offer is a deliberate republish)
Existing in-use guard keyed on SG existence Re-key assertSubjectsNotInUseBySubjectGroups (and grade-cell/grade-unlink removal) to student count, and cascade-delete empty SGs as part of the removal Required so offered curricula stay editable while empty auto-SGs exist (the brainstorm's "writable once offered" requirement) A subject/cell delete now has a write side-effect (deleting empty SGs); contained in the same structure-sync tx
Setup bulkSync leaves curricula at default status (DRAFT) (Decision B) bulkSync now sets status = READY on onCreate (always) and onUpdate (forces READY; seeds on the DRAFT→READY rising edge), so finishing the curriculum setup step offers + seeds every curriculum User chose B 2026-06-16: setup curricula should be offered-and-seeded immediately; referents stay gated by the (admin-opened, date-driven) selection window, and the SELECTION_WINDOW_PREREQ_FAILED gate still protects window-open Setup curricula are READY (offered) immediately; safe only because the edit-lock is now student-keyed, so empty-SG'd READY curricula remain freely editable. An admin who wants a DRAFT setup curriculum must demote post-setup

8. Pushback log

US says Conflicts with Proposed instead Status
"autocreate one SG for each subject of all curriculums" (incl. homeroom-driven depts, accept empties) Guaranteed-dead empties clutter GET /subject-groups + the SG placement/coverage boards for homeroom departments User explicitly accepted the empties ("unused at most, acceptable"). Keep seed unconditional across all offered curricula; no hide/filter this iteration Resolved (user accepted)
"create the SGs" implies one moment Grid stays editable after READY; a one-shot would never cover later edits Rising-edge-into-READY trigger + explicit idempotent enumeration (re-runs safe on re-offer) Resolved
"all subject groups, not mandatory-only" Homeroom wizard is mandatory-only by design Standalone auto-seed covers all subjects incl. in-block alternatives (standalone in-block SGs are already a supported flavour); homeroom wizard stays mandatory-only and untouched Resolved
"setup curricula are always READY" Code traced: bulkSync never sets status; Curriculum.status defaults to DRAFT; no READY literal in src/setup. Setup curricula are DRAFT today Make it true under decision B: bulkSync forces status = READY + seeds (§7). Window-open is still gated by SELECTION_WINDOW_PREREQ_FAILED (selection-window.service.ts:171) Resolved (B)

9. Deferrals

  • Selection-window invalidation cascade (delete a chosen in-block subject during an open window → invalidate impacted StudentOptionBlockChoice / selection → flag for resubmission) — keyed on StudentOptionBlockChoice/selection, a different population than this iteration's SG-assignment lock — follow-up: iteration-2 spec 2026-06-1x-selection-window-subject-deletion-invalidation.
  • Notifications / referent re-ping on invalidation — follow-up: past iteration 2.
  • Auto-seed of post-offer subject additions without resurrecting deletions (would need an origin/seeded-marker) — follow-up: revisit only if manual top-up proves painful.
  • Hiding guaranteed-empty auto-SGs in homeroom departments — follow-up: revisit if board/list clutter is reported.
  • "Only offered curricula visible to referents"confirmed already enforced (not deferred, nothing to build): window-open scans READY-curriculum coverage and throws SELECTION_WINDOW_PREREQ_FAILED (selection-window.service.ts:171–184); referent visibility is gated by the date-driven window status. This iteration leaves it untouched.

10. Open questions

  • None — all resolved (see §7/§8). The one judgment call to confirm at the spec-review gate: rising-edge-into-READY trigger (post-offer subject additions are not auto-seeded this iteration).

11. Verification plan

  • Unit specs:
  • src/curriculum/ensure-standalone-subject-groups.spec.ts (new) — seeds one SG per offered cell for mandatory and in-block subjects; skips cells that already have a standalone SG; never touches homeroom-bound SGs; respects track-scoped subject applicability; empty teachers/roster.
  • src/curriculum/curriculum-structure-sync.spec.ts (extend) — subject delete/rename: empty SGs → cascade-deleted + delete proceeds; with-students SG → CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS 409 with studentCount. Same for grade-cell removal / grade unlink.
  • src/curriculum/curriculum.service.spec.ts (extend) — rising-edge trigger (createCurriculum with status: READY seeds; updateCurriculum DRAFT→READY seeds; READY→READY edit does not re-seed; demote blocks on SG-with-students + cleans empty auto-SGs); bulkSync forces status: READY and seeds onCreate (always) + onUpdate (DRAFT→READY edge only).
  • E2E specs (test/curriculum*.e2e-spec.ts / test/subject-groups*.e2e-spec.ts):
  • Offer a curriculum → GET /subject-groups returns the seeded empties (one per cell, in-block included).
  • Delete an empty-SG subject → 200; assign a student to its SG then delete the subject → 409.
  • Create a homeroom in a seeded grade → bound child SG and standalone auto-SG coexist; a rostered student is in exactly one per subject (no @@unique violation).
  • Setup-wizard run that finalizes curricula to READY → subject_groups populated.
  • E2E discipline per feedback_e2e_isolation_patterns.md (unique markers, deleteMany cleanup, dedicated sis_e2e DB).
  • Manual verification: run the setup wizard locally to READY; npx prisma studio → confirm subject_groups has one empty homeroom_id IS NULL row per offered cell.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-16
  • Chat reference: brainstormed + approved by Fabio in chat 2026-06-16 — converged on option-3 (mixed homeroom/non-homeroom depts), all-subjects seed, rising-edge-into-READY trigger, student-keyed lock re-key; iteration-2 (selection-window invalidation) split off.

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