# Content Catalog Backend ## Purpose `content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for editable/scoped domain/content records. Product-static catalogs such as personality quiz content and classroom-timer presets live in frontend constants instead. Authenticated reads return the active payload scoped to the current user, and authenticated management endpoints allow runtime configuration of catalog records. ## Slice Files (by layer) - Routes: - `src/routes/content_catalog.ts` — authenticated read (`GET /read/:contentType`) plus management (`GET /`, `POST /`, `GET /:contentType`, `PUT /:contentType`, `DELETE /:contentType`). Mounted at `/api/content-catalog` behind the `authenticated` middleware. - Controllers: - `src/api/controllers/content_catalog.controller.ts` (`readByType`, `list`, `create`, `findManagedByType`, `update`, `remove`). - Service (BLL): `src/services/content_catalog.ts` (single `ContentCatalogService`: `list`, `findByType`, `findManagedByType`, `create`, `update`, `delete`). - Repository (DAL): queries run through `db.content_catalog` inside the service (no separate `db/api` file). - Model: `src/db/models/content_catalog.ts` (no model associations). - Shared used: `db/with-transaction.ts`, `services/shared/access.ts` (`hasFeaturePermission`, tenant helpers), `shared/constants/content-catalog.ts`, `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. - Seeds: `src/db/seeders/20260608103000-content-catalog.ts` with payloads in `src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`. ## API - `GET /api/content-catalog/read/:contentType` -> `200` the active content DTO for that `contentType`, scoped to the authenticated user. Throws `ValidationError('contentCatalogNotFound')` when no active record exists. - `GET /api/content-catalog` -> `200` `{ rows, count }`. JWT + manage access. Supports `limit`/`page`. - `POST /api/content-catalog` -> `201` the created DTO. JWT + manage access. Body is `req.body.data`. - `GET /api/content-catalog/:contentType` -> `200` the active DTO for that type. JWT + manage access (delegates to `findByType`). - `PUT /api/content-catalog/:contentType` -> `200` the updated DTO. JWT + manage access. Body is `req.body.data`. - `DELETE /api/content-catalog/:contentType` -> `204` no body. JWT + manage access. DTO fields: `id`, `content_type`, `payload`, `updatedAt`. ## Access Rules - `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`. - All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited. - `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library. ## Tenant Scope Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns: - Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level. - School-scoped types read the caller's resolved school row. - Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows. - Shared/global types use all-null tenant ids. Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors. ## Data Contract - Create input: `content_type` (required non-empty string), `payload` (required; any non-`undefined` JSON value), optional `active` (defaults to `true` unless explicitly `false`), optional `importHash`. - Update input: `payload` (required), optional `active` (set to `true` unless explicitly `false`). - Model fields: `id` (UUID), `content_type` (text, not null), `payload` (JSONB, not null), `active` (boolean, not null, default `true`), nullable tenant ids (`organizationId`, `schoolId`, `campusId`, `classId`), `importHash` (nullable, unique), `createdAt`, `updatedAt`, `deletedAt`. `paranoid` soft deletes; `freezeTableName`. - List pagination: `list` uses `resolvePagination(limit, page)` and orders by `content_type asc`. ## Behavior / Notes - `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created. - `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`). - `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record. - For `classroom-strategies`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage the shared strategy library and descendant scopes remain read-only. - Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`. - Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads. ### Seeded content types The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`. The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps the Classroom Support library shared at the organization level. ### Content authoring rules - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. - Frontend constants stay limited to UI config, labels, query keys, timing values, presentation tokens, and product-static catalogs. - If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs. ## Tests Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for `classroom-strategies`, and organization-only seeding for the preset Classroom Support library. ## Related - Frontend: `frontend/docs/content-catalog-integration.md`. - Related backend slice: communications (`backend/docs/communications.md`) covers internal alerts and direct guardian/staff messages.