Skip to content

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 to backoffice/dist, served by the monolith via ServeStaticModule (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 via POST/PATCH/DELETE /api/v1/evaluation-scales/presets (type immutable on edit; delete blocked when in use). The tab lists isPreset scales only. Bulk import/export: Export all downloads the whole collection as evaluation-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. Scale type is immutable, so a name match with a different type is reported as a failed item. See backoffice/src/scale-import.ts.
  • Subject level presets (SubjectLevel, tenantId IS NULL): preset CRUD via POST/PATCH/DELETE /api/v1/subject-levels/presets (name/code; delete blocked when referenced by a curriculum subject or rule). The tab lists isPreset levels only (HL/SL ship as platform seeds). These feed the IB "Higher Level"/"Standard Level" dimension: a curriculum subject can carry a levelId, and LEVEL-scoped rules count subjects per level (see chapter 14). Bulk import/export: Export all downloads subject-level-presets.json ({ subjectLevels: [{ name, code? }] }); Import all validates then upserts by name (case-insensitive) — match → PATCH name/code, new → POST. See backoffice/src/subject-level-import.ts.
  • Room type presets (RoomType, tenantId IS NULL): preset CRUD via GET/POST/PATCH/DELETE /api/v1/room-types/presets (@PlatformAdminOnly, RoomTypesController). Names are uppercased; one global row per name via the partial unique room_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 (isCanteenType drives lunch shifts + the rooms-import canteen rejection). Rename/delete of a protected type, or create/rename to a protected name, → ROOM_TYPE_LOCKED 409; delete is also blocked when any room references the type (ROOM_TYPE_IN_USE 409). The list DTO carries isProtected (computed from the name) so the SPA can lock those rows. Defaults are seeded from DEFAULT_ROOM_TYPES (incl. the TO_BE_DEFINED import placeholder) by applyDefaultRoomTypes (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_id set), 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 (syncRoomTypes distinguishes 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). See backoffice/src/room-type-import.ts.

  • Curriculum presets (CurriculumPreset rows): full grid CRUD via GET/POST/PATCH/DELETE /api/v1/curriculum-presets[/:key] over an inline preset-local grade set — each grade has a client-generated UUID key that 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 supports ENTIRE_PLAN/LEVEL scopes (a LEVEL rule 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.expandPreset remaps the preset grade keys onto the tenant's chosen grade ids and carries every family — subjects, option blocks, tracks, and rules. Subject/rule levelIds (platform-preset levels, tenantId NULL) are resolved onto the tenant's equivalent via SubjectLevelsService.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/presets lists the global management catalogue and PATCH /roles/presets/:key applies sparse grant changes. admin and 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 and src/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 a Deleted N · Failed K summary. 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 share backoffice/src/bulk.ts (runBulk) and the BulkSummary banner; download helpers live in backoffice/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 by src/tenants/ (@PlatformAdminOnly + @NoTenantTx): GET /tenants (cross-tenant read via AdminPrismaService) and POST /tenants (one withTenantGuc(mintedId) transaction creating tenant → bootstrap DRAFT year → cloned preset roles → admin user → chosen Teacher/Staff anchor → person-anchored admin UserRole and 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 of tools/reset-tenant.sql: one withTenantGuc transaction that wipes everything created during/after the setup wizard and returns the tenant to its freshly-provisioned state (setup step SCHOOL, import stamps cleared). Kept: the tenant row, cloned roles, admin-role user(s) + their UserRole + Teacher/Staff anchors + their anchoring academic years; global presets are never touched. Pre-flight aborts with TENANT_RESET_BLOCKED (NO_ADMIN_USER / legacy NO_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 on NODE_ENV ∈ {local, development, test}; stage/prod answer TENANT_RESET_DISABLED (403) and keep the psql script as their only path. The SPA hides the button by probing GET /tenants/capabilities ({ tenantReset }). Per-model disposal is classified in src/tenants/tenant-reset.coverage.ts; its drift spec pins the map to STRICTLY_TENANTED_MODELS, so a new tenant-bearing model fails CI until it is classified AND handled in resetTenantSetupData (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 (CurriculumImportTemplate in backoffice/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-anchored Error messages) and transforms it into the internal PresetDefinitionInput (mints grade UUID keys, wires hours cells, numbers criteria, auto-derives missing track codes, resolves a subject's level and a LEVEL rule's levelRef to a level id by name/code). templateToPreset takes 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. LEVEL scope via levelRef). 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: PresetsPage exposes Import JSON (→ opens the editor pre-filled via the PresetEditor initial prop) plus download links for a blank template, a worked example, and the filling guide (all generated client-side from constants in curriculum-import.ts). PresetEditor also 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 normal POST/PATCH /curriculum-presets path, 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 in backoffice/src/types.ts). If those grid DTOs change, reconcile types.ts and curriculum-import.ts together.

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 definition references evaluation-scale ids (gradingScaleId at preset / grade / subject / per-cell level, plus criteriaGradingScaleId at subject + per-cell) and subject-level ids (subject levelId, LEVEL-rule levelId). 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 (grade keys, hours, criteria, structure) round-trips verbatim because definition is 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 by key: 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 proxy ECONNREFUSEDs). Authentication uses the backend's cookie contract; inspect browser cookie handling if a session does not persist.
  • Build: root npm run build also builds the SPA (via npm --prefix backoffice install && ... run build). Deploy serves backoffice/dist at /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_ORIGIN so same-origin writes pass OriginCheckGuard (it validates Origin/Referer on POST/PATCH/DELETE). The guard is enforced only on NODE_ENV=production; dev/stage skip it.

Contract coupling

  • The preset editor's definition reuses the grid input DTOs (src/curriculum/dto/scopes/curriculum-configuration.dto.ts), hand-mirrored in backoffice/src/types.ts. Because the backend stores definition as authored, GET /curriculum-presets/:key returns it already in editable shape (no response→input mapper). If those grid DTOs change, reconcile types.ts first.
  • Keep the SPA's source dependency boundary at the HTTP contract. Browser code should not import backend providers, Prisma, or server-only packages.