8.2 KiB
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-catalogbehind theauthenticatedmiddleware.
- Controllers:
src/api/controllers/content_catalog.controller.ts(readByType,list,create,findManagedByType,update,remove).
- Service (BLL):
src/services/content_catalog.ts(singleContentCatalogService:list,findByType,findManagedByType,create,update,delete). - Repository (DAL): queries run through
db.content_cataloginside the service (no separatedb/apifile). - 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.tswith payloads insrc/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts.
API
GET /api/content-catalog/read/:contentType->200the active content DTO for thatcontentType, scoped to the authenticated user. ThrowsValidationError('contentCatalogNotFound')when no active record exists.GET /api/content-catalog->200{ rows, count }. JWT + manage access. Supportslimit/page.POST /api/content-catalog->201the created DTO. JWT + manage access. Body isreq.body.data.GET /api/content-catalog/:contentType->200the active DTO for that type. JWT + manage access (delegates tofindByType).PUT /api/content-catalog/:contentType->200the updated DTO. JWT + manage access. Body isreq.body.data.DELETE /api/content-catalog/:contentType->204no body. JWT + manage access.
DTO fields: id, content_type, payload, updatedAt.
Access Rules
GET /api/content-catalog/read/:contentTyperequires an authenticated user and returns only records whereactive = true.- All
/api/content-catalogmanagement endpoints requireMANAGE_CONTENT_CATALOG.custom_permissionscan grant it andcustom_permissions_filtercan remove it for non-global users. Tenant/content-type scoping still constrains which row is edited. classroom-strategiesis an organization-scoped catalog. Management requiresMANAGE_CONTENT_CATALOGand 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-strategiesis 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-undefinedJSON value), optionalactive(defaults totrueunless explicitlyfalse), optionalimportHash. - Update input:
payload(required), optionalactive(set totrueunless explicitlyfalse). - Model fields:
id(UUID),content_type(text, not null),payload(JSONB, not null),active(boolean, not null, defaulttrue), nullable tenant ids (organizationId,schoolId,campusId,classId),importHash(nullable, unique),createdAt,updatedAt,deletedAt.paranoidsoft deletes;freezeTableName. - List pagination:
listusesresolvePagination(limit, page)and orders bycontent_type asc.
Behavior / Notes
createlooks up any existing row bycontent_typeplus its exact tenant owner withparanoid: false. If a non-deleted row exists it throwsValidationError. If a soft-deleted row exists it is restored and updated insidewithTransaction; otherwise a new row is created.updateanddeleterun insidewithTransactionand throwValidationError('contentCatalogNotFound')when the row is missing.deletesetsactive = falsethen soft-deletes (destroy).findManagedByTypeis the authenticated variant offindByType: it enforces manage access and then returns the active record.- For
classroom-strategies,findManagedByType,create,update, anddeletealso require organization effective scope so organization leaders manage the shared strategy library and descendant scopes remain read-only. - Managed
classroom-strategiesimages are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path fromfile.md. - Missing/inactive content types fail explicitly with
ValidationErrorrather 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.