Skip to content

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 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. (Multi-select delete is still deferred.) See specs 2026-06-15-room-types-platform-design.md, 2026-06-25-room-types-backoffice-managed-design.md, and 2026-06-30-backoffice-preset-bulk-import-export-delete-iteration-2-design.md.
  • 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): grant editing of the management presets (principal/hr/department_head) via GET/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 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. 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 (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.
  • Scope (v1): 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 new 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 — 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 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) 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 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). Use Chrome (localhost is a secure context, so auth cookies are stored).
  • Build: root npm run build also builds the SPA (via npm --prefix backoffice install && ... run build). Deploy serves backoffice/dist at /backoffice.
  • The curriculum_preset table 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_ORIGIN so same-origin writes pass OriginCheckGuard (it validates Origin/Referer on POST/PATCH/DELETE).

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.
  • backoffice/package.json must NOT carry a student-information-system-be: file:.. dependency (a tooling artifact that some npm install runs re-add) — it makes the SPA depend on the whole backend. Strip it if it reappears.