17 — Backoffice (Catalog Editor)¶
Internal, embedded SPA at /backoffice for product-cycle domain experts to edit
the global preset catalog — across four tabs: evaluation-scale presets,
subject-level presets, curriculum presets, and room-type presets (plus, backend-only
for now, role presets). Platform-admin only; not per-tenant data. Design + plans:
- docs/superpowers/specs/2026-06-04-backoffice-catalog-editor-iteration-2-design.md (current; iteration-1 spec is superseded)
- docs/superpowers/plans/2026-06-04-backoffice-catalog-presets-to-db.md (backend)
- docs/superpowers/plans/2026-06-04-backoffice-preset-repoint.md (frontend repoint)
What it is¶
- A Vite + React + TS app in
backoffice/, built tobackoffice/dist, served by the monolith viaServeStaticModule(serveRoot: '/backoffice', same origin as/api/v1). Pure HTTP client. - Auth: the existing 3-step login; experts use platform-admin accounts
(
isPlatformAdmin). Preset writes are gated by@PlatformAdminOnly()on the backend. Presets are global (no tenant / AY), so the tenant logged into is irrelevant to what's edited.
Scope¶
- Evaluation scale presets (
tenantId IS NULL): full CRUD viaPOST/PATCH/DELETE /api/v1/evaluation-scales/presets(type immutable on edit; delete blocked when in use). The tab listsisPresetscales only. Bulk import/export: Export all downloads the whole collection asevaluation-scale-presets.json({ scales: [{ name, type, values:[…] }] }, no ids/ordinals); Import all validates the whole file then upserts by name (case-insensitive) — name match → PATCH values, new name → POST. Scaletypeis immutable, so a name match with a differenttypeis reported as a failed item. Seebackoffice/src/scale-import.ts. - Subject level presets (
SubjectLevel,tenantId IS NULL): preset CRUD viaPOST/PATCH/DELETE /api/v1/subject-levels/presets(name/code; delete blocked when referenced by a curriculum subject or rule). The tab listsisPresetlevels only (HL/SL ship as platform seeds). These feed the IB "Higher Level"/"Standard Level" dimension: a curriculum subject can carry alevelId, andLEVEL-scoped rules count subjects per level (see chapter 14). Bulk import/export: Export all downloadssubject-level-presets.json({ subjectLevels: [{ name, code? }] }); Import all validates then upserts by name (case-insensitive) — match → PATCH name/code, new → POST. Seebackoffice/src/subject-level-import.ts. - Room type presets (
RoomType,tenantId IS NULL): preset CRUD viaGET/POST/PATCH/DELETE /api/v1/room-types/presets(@PlatformAdminOnly,RoomTypesController). Names are uppercased; one global row per name via the partial uniqueroom_types_platform_name_key. All global types are backoffice-managed (create/rename/delete) except the name-protected ones:PROTECTED_ROOM_TYPES = ['CANTEEN']— the only name the code references literally (isCanteenTypedrives lunch shifts + the rooms-import canteen rejection). Rename/delete of a protected type, or create/rename to a protected name, →ROOM_TYPE_LOCKED409; delete is also blocked when any room references the type (ROOM_TYPE_IN_USE409). The list DTO carriesisProtected(computed from the name) so the SPA can lock those rows. Defaults are seeded fromDEFAULT_ROOM_TYPES(incl. theTO_BE_DEFINEDimport placeholder) byapplyDefaultRoomTypes(idempotent, create-only — re-seeding preserves backoffice edits). Unlike every other preset here, room types are shared-reference, not cloned: a tenant's room list is globals ∪ its own custom rows (tenant_idset), and rooms FK directly into the global rows — so adding a default reaches every tenant instantly with no clone or per-tenant SQL. Tenant custom types are managed through the setup wizard (syncRoomTypesdistinguishes globals by DB query —tenantId IS NULL— not the constant, and skips global-named submissions as references). The SPA "Room type presets" tab has single-item CRUD + 🔒 lock on protected rows, plus bulk Export all / Import all (room-type-presets.json={ roomTypes: [{ name }] }; import is create-missing — a name already present, incl. protected ones, is skipped since room types carry only a name; new names are created). Seebackoffice/src/room-type-import.ts. (Multi-select delete is still deferred.) See specs2026-06-15-room-types-platform-design.md,2026-06-25-room-types-backoffice-managed-design.md, and2026-06-30-backoffice-preset-bulk-import-export-delete-iteration-2-design.md. - Curriculum presets (
CurriculumPresetrows): full grid CRUD viaGET/POST/PATCH/DELETE /api/v1/curriculum-presets[/:key]over an inline preset-local grade set — each grade has a client-generated UUIDkeythat hours cells reference. The editor presents hours as a subject × grade matrix (rows = subjects with name inline + a ⚙ row-expander for the subject level, grading/criteria scales, and criteria; columns = the preset's grades; each cell = weekly hours). Option blocks and tracks reuse the same matrix. The Rules editor supportsENTIRE_PLAN/LEVELscopes (aLEVELrule picks a level from the preset-level dropdown; per-block "pick N of M" is the block's own min/max, not a rule). Year hours and per-(subject, grade) scale overrides are edited via the JSON tab (they don't fit a single-number cell). No department/AY/status and no rooms (presets are room-agnostic). At tenant setup,CurriculumService.expandPresetremaps the preset grade keys onto the tenant's chosen grade ids and carries every family — subjects, option blocks, tracks, and rules. Subject/rulelevelIds (platform-preset levels,tenantId NULL) are resolved onto the tenant's equivalent viaSubjectLevelsService.resolveTenantLevelIds(same-named tenant-owned level wins; else the visible preset id is kept). Content offered only in unmapped grades drops (subjects/alternatives with no surviving cell, then empty blocks, then empty tracks). Rules are whole-plan or per-level only, so none reference a block that could vanish. Bulk Export all / Import all (whole-collection, name-resolved, upsert-by-key) ports the catalog between environments — see "Whole-collection port" below. - Role presets (
Role,tenantId IS NULL): grant editing of the management presets (principal/hr/department_head) viaGET/PATCH /roles/presets(@PlatformAdminOnly).admin+ the profile-coupled trio (teacher/referent/student) are frozen (code-managed). Presets are cloned into each tenant at provisioning (cloneRolesIntoTenant); edits affect future clones only. Backend landed; the SPA "Roles" tab is the pending follow-on frontend plan. Spec:docs/superpowers/specs/2026-06-09-backoffice-role-preset-editing-design.md. - Multi-select + bulk delete (all three catalog tabs — Curriculum, Evaluation
scales, Subject levels): row checkboxes + a header select-all + a Delete
selected button that fans out the existing single-item
DELETE(client-side, no bulk endpoint) and renders aDeleted N · Failed Ksummary. In-use rows are non-selectable on scales/levels (their per-row delete is already disabled); curriculum rows have no in-use flag, so an in-use delete surfaces as a per-item failure. Bulk import and bulk delete sharebackoffice/src/bulk.ts(runBulk) and theBulkSummarybanner; download helpers live inbackoffice/src/download.ts. Design:docs/superpowers/specs/2026-06-24-backoffice-preset-bulk-import-export-delete-design.md. - Out of scope: role creation/deletion (custom roles); per-tenant curriculum/scale editing.
Curriculum JSON import (curricula tab)¶
- Workflow it serves: domain experts author a curriculum in a
.docx(quadro orario), hand it to Claude outside the backoffice along with the filling guide, and get back a JSON file they import. The backoffice never parses.docx— Claude does that conversion; the SPA only imports JSON. - Friendly template, not the raw shape: the import format is a separate,
name-keyed schema (
CurriculumImportTemplateinbackoffice/src/curriculum-import.ts) — grades are an ordered name array, subject hours are a{ "<grade name>": <weekly hours> }map, no UUIDs.templateToPreset()validates it (path-anchoredErrormessages) and transforms it into the internalPresetDefinitionInput(mints grade UUIDkeys, wires hours cells, numbers criteria, auto-derives missing track codes, resolves a subject'sleveland aLEVELrule'slevelRefto a level id by name/code).templateToPresettakes the fetched preset levels as a second argument; callers pass(await listSubjectLevels()).filter(l => l.isPreset). This is distinct from the editor's existing JSON tab, which round-trips the raw internal shape for power users. - Scope (v1): subjects (incl. optional
level) + option blocks + tracks + rules (incl.LEVELscope vialevelRef). Grading scales and per-cell scale overrides are intentionally not in the template — they are picked in the editor after import (presets rarely pin a scale, and matching scale names by string is fragile). Levels, by contrast, ARE resolved at import because they match a small fixed catalog by name/code. - UI:
PresetsPageexposes Import JSON (→ opens the editor pre-filled via the newPresetEditorinitialprop) plus download links for a blank template, a worked example, and the filling guide (all generated client-side from constants incurriculum-import.ts).PresetEditoralso has an in-editor Import JSON button to replace the current grid (keeping the key when editing an existing preset). Imported data lands in the form for review; Save goes through the normalPOST/PATCH /curriculum-presetspath, so the backend stays the validation authority — no backend changes. - Contract coupling: the friendly template maps onto the same grid input
DTOs as everything else (
src/curriculum/dto/scopes/curriculum-configuration.dto.ts, mirrored inbackoffice/src/types.ts). If those grid DTOs change, reconciletypes.tsandcurriculum-import.tstogether.
Whole-collection port (curricula tab: Export all / Import all)¶
Distinct from the friendly per-file Import JSON above. Import JSON authors
one curriculum from a lossy, name-keyed .docx-derived template and opens the
editor for review (omits scales). Export all / Import all round-trip the whole
preset collection losslessly for porting a catalog between environments (DEV→PROD),
upserting by preset key. See backoffice/src/curriculum-port.ts (kept separate from
curriculum-import.ts) and spec
2026-06-30-backoffice-preset-bulk-import-export-delete-iteration-2-design.md.
- The only env-specific data is the scale/level UUIDs. A preset
definitionreferences evaluation-scale ids (gradingScaleIdat preset / grade / subject / per-cell level, pluscriteriaGradingScaleIdat subject + per-cell) and subject-level ids (subjectlevelId,LEVEL-rulelevelId). These ids differ per environment, and the backend validates them (assertScaleRefsArePresets/assertLevelRefsArePresets), so a raw dump is rejected cross-env. Export all renders every such id as the scale/level name (gradingScale,criteriaGradingScale,level); Import all re-resolves names to the target env's ids. Everything else (gradekeys, hours, criteria, structure) round-trips verbatim becausedefinitionis stored as an opaque blob, replaced wholesale on PATCH. - Import order matters: in the target env, import the Evaluation scale presets
and Subject level presets first, then Import all curricula. A curriculum
referencing a scale/level name the target env lacks is a per-item failure
(
scale "X" not found — import scale presets first) and is not partially written; others in the same file still import. File shape is validated before any write. curriculum-presets.json={ curricula: [<preset with scale/level names>] }. Upsert bykey: key match → PATCH (full-replace), new key → POST. Idempotent re-import.
Run / build¶
- Dev:
npm --prefix backoffice run dev(Vite at :5173, proxies/api→ :8080; the backend must be running, else the proxyECONNREFUSEDs). Use Chrome (localhost is a secure context, so auth cookies are stored). - Build: root
npm run buildalso builds the SPA (vianpm --prefix backoffice install && ... run build). Deploy servesbackoffice/distat/backoffice. - The
curriculum_presettable must be migrated + seeded (npx prisma migrate dev npx prisma db seed) before the curriculum-presets tab works.
Deployed-env config¶
- The backend's own public origin must be in
CORS_ORIGINso same-origin writes passOriginCheckGuard(it validates Origin/Referer on POST/PATCH/DELETE).
Contract coupling¶
- The preset editor's
definitionreuses the grid input DTOs (src/curriculum/dto/scopes/curriculum-configuration.dto.ts), hand-mirrored inbackoffice/src/types.ts. Because the backend storesdefinitionas authored,GET /curriculum-presets/:keyreturns it already in editable shape (no response→input mapper). If those grid DTOs change, reconciletypes.tsfirst. backoffice/package.jsonmust NOT carry astudent-information-system-be: file:..dependency (a tooling artifact that somenpm installruns re-add) — it makes the SPA depend on the whole backend. Strip it if it reappears.