17 — Backoffice (Catalog Editor)¶
Internal, embedded SPA at /backoffice for domain experts to edit
the global preset catalog and provision schools. The SPA has Tenants, Evaluation scale presets, Subject level presets, Curriculum presets, and Room type presets tabs. Role preset editing is an API surface without a corresponding SPA tab. All administration requires a platform-admin account.
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. -
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):GET /roles/presetslists the global management catalogue andPATCH /roles/presets/:keyapplies sparse grant changes.adminand the profile roles (teacher,staff,referent,student) are code-managed and cannot be patched. Provisioning clones presets into the new tenant; an API edit does not update existing clones. Seed/reconciliation commands also converge code-managed RBAC definitions, so the code catalogue remains authoritative. See permissions andsrc/permissions/roles.controller.ts. -
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. - The backoffice does not expose custom-role creation/deletion or per-tenant curriculum/scale editors.
Tenants (onboarding)¶
Shared-login flow: submit without admin.password initially. 400 TENANT_ADMIN_PASSWORD_REQUIRED reveals the new-password field; 400 TENANT_ADMIN_PASSWORD_NOT_ALLOWED clears/hides it and asks the operator to retry without it. Email edits clear purpose-dependent password state. Existing identities keep their credential; their profile in another school does not constrain the new membership. No tenant is created by these validation failures. See authentication.
Profile choice: the operator chooses Teacher or Staff. POST /tenants requires admin.profileType: 'TEACHER' | 'STAFF'; missing, null, and other values fail validation. Both choices receive Administrator access with a matching bootstrap employee record and base role. The choice survives password retries and clears after success. Each school membership retains its established profile type.
-
A Tenants tab (
backoffice/src/components/tenants/TenantsPage.tsx) lists every tenant with its admin(s) and provisions new ones. Backed bysrc/tenants/(@PlatformAdminOnly+@NoTenantTx):GET /tenants(cross-tenant read viaAdminPrismaService) andPOST /tenants(onewithTenantGuc(mintedId)transaction creating tenant → bootstrap DRAFT year → cloned preset roles → admin user → chosen Teacher/Staff anchor → person-anchored adminUserRoleand matching user-only profile role; the global identity is created or reused before the tx). A duplicate slug →TENANT_SLUG_TAKEN(409). This provisions tenant-scoped rows — hence the two Prisma surfaces (GUC create, AdminPrisma list). The tenant surface provides provisioning and a read-only list. -
Dev-only tenant reset: each row has a Reset button (type-the-slug confirm) →
POST /tenants/:id/reset, the app-side port oftools/reset-tenant.sql: onewithTenantGuctransaction that wipes everything created during/after the setup wizard and returns the tenant to its freshly-provisioned state (setup stepSCHOOL, import stamps cleared). Kept: the tenant row, cloned roles, admin-role user(s) + theirUserRole+ Teacher/Staff anchors + their anchoring academic years; global presets are never touched. Pre-flight aborts withTENANT_RESET_BLOCKED(NO_ADMIN_USER/ legacyNO_STAFF_ANCHOR, meaning neither employee type supplies an anchor). Both employee types use the same kept-admin-user rule. Hard env gate server-side — allowed only onNODE_ENV∈ {local, development, test}; stage/prod answerTENANT_RESET_DISABLED(403) and keep the psql script as their only path. The SPA hides the button by probingGET /tenants/capabilities({ tenantReset }). Per-model disposal is classified insrc/tenants/tenant-reset.coverage.ts; its drift spec pins the map toSTRICTLY_TENANTED_MODELS, so a new tenant-bearing model fails CI until it is classified AND handled inresetTenantSetupData(tenants.queries.ts).
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. - Template content: 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 thePresetEditorinitialprop) 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. - 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) .
- 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). Authentication uses the backend's cookie contract; inspect browser cookie handling if a session does not persist. - Build: root
npm run buildalso builds the SPA (vianpm --prefix backoffice install && ... run build). Deploy servesbackoffice/distat/backoffice. - The database must have the schema and platform preset seeds required by the corresponding APIs. Follow migrations and seeding for the target environment.
Deployed-env config¶
- On production, the backend's own public origin must be in
CORS_ORIGINso same-origin writes passOriginCheckGuard(it validates Origin/Referer on POST/PATCH/DELETE). The guard is enforced only onNODE_ENV=production; dev/stage skip it.
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. - Keep the SPA's source dependency boundary at the HTTP contract. Browser code should not import backend providers, Prisma, or server-only packages.