Skip to main content

Content assets (versioned CMS + public in-force serving)

Scope

One monolithic module, two controllers (authenticated write + @Public() read), 299 lines. The versioning shape is correct — every edit after publish creates a new (code, version) row rather than mutating in place — but the module is unintegrated: no other service in the codebase injects ContentAssetsService. Client portal, notification templates, quotation flows all hardcode their notice strings.

  • content-assets/ContentAssetsService (list / findById / findInForce / create / update / publish / archive) + ContentAssetsController (5 write endpoints) + PublicContentController (1 unauthenticated read endpoint).
  • ContentAsset Prisma model — 15 fields, (code, version) unique, status enum (draft / published / archived), optional documentId FK to a signed PDF, effectiveFrom + nullable effectiveTo window enforced by findInForce().
  • GET /public/content/:code/in-force?at=<iso> — the consumer-side read that serves the currently-in-force version by code at a given timestamp.

Compliance envelope

  • CPA 2022 §37 — the platform must be able to prove which version of terms / privacy notice / product-disclosure the consumer saw at signature time. Today: no ContentAssetAcceptance join table, no Client.termsAcceptedVersion column that points at a (code, version) pair. A dispute six months later ("I never agreed to that clause") requires reconstructing the world from publishedAt + hope-nothing-drifted.
  • IRA Insurance Act 2017 §35 — rate-card + product-disclosure filings must be versioned + audit-traceable. The category enum (product_brochure / terms / privacy_policy / training / marketing / other) is missing rate_card / cooling_off_notice / product_disclosure / fee_schedule.
  • DPPA 2019 §16 — the public inForce() endpoint is served without geo-check + without a consent trail. A consumer in-Uganda vs. a consumer in-EU asking for privacy_policy.ug gets the same rendering with no record either way.
  • BOU cybersecurity §5.4 — the public endpoint has no @Throttle; a scraper enumerates the whole CMS by code-brute-force with no rate ceiling.

Current state (2026-07-18)

Module footprint

  • src/modules/content-assets/content-assets.module.ts — 299 lines. DTOs + service + two controllers + module all in one file.
  • Prisma model ContentAsset — 15 fields, 3 indexes (code+version unique + category + status).
  • No cross-references: ContentAssetsService is exported but no other module injects it. The e2e test suite (test/e2e/ratings-cms-warehouse.e2e-spec.ts) is the only consumer.

What works today

  • Version-per-edit pattern is correct: create() auto- increments version for the given code. update() refuses non-draft rows explicitly — you cannot mutate a published asset in place. New content requires create({ code, ... }) again.
  • In-force window is enforced on the public read: findInForce() filters status='published' with effectiveFrom at-or-before now and (effectiveTo null or after now), and returns the correct historical version when the optional ?at=<iso> query parameter is set — perfect for a dispute lookup "show me the terms that were in force on 2025-11-04".
  • Publish auto-closes the previous version's effectiveTo: no ambiguous overlap; the timeline is always deterministic.
  • Every mutation writes an audit event: content_asset.create, content_asset.update, content_asset.publish, content_asset.archive.
  • Retention is deliberately absent: RetentionPurgeJob doesn't touch content assets — accepted-terms rows survive forever for dispute defense. ✓

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-18)

Release content-p1-20260718-2241. Closes gaps 1–4:

  • gap 1 — Migration 20260719000000_content_assets_phase1 adds the ContentAssetAcceptance model: (id, contentAssetId, subjectType, subjectId, acceptedAt, ip, userAgent, bodyChecksum, jurisdiction, metadata) with (contentAssetId, subjectType, subjectId) unique. New POST /public/content/:code/accept endpoint mirrors the currently-in-force bodyChecksum onto the acceptance row + emits content_asset.acceptance_recorded audit event. Idempotent per (asset, subject) — a second call from the same subject returns the existing row.
  • gap 2 — Migration adds ContentAsset.bodyChecksum + index. publish() computes SHA-256(body) and stamps it + content_asset.publish audit event carries the checksum. findInForce() re-hashes on read; mismatch → emits content_asset.body_tamper_detected audit event (still serves so a 500 doesn't cascade to consumer traffic).
  • gap 3 — @Throttle on PublicContentController.inForce (60/min) + .accept (30/min).
  • gap 4 — PublicContentController.inForce accepts ?jurisdiction=<iso> + x-consent-token header. Consent token overrides the jurisdiction to UG. Non-UG jurisdiction without a consent token → 451 + content_asset.public_fetch.cross_border_denied audit. Every successful fetch emits content_asset.public_fetch with { ip, userAgent, jurisdiction, code, version }.

Locked in by src/modules/content-assets/content-assets-phase1.spec.ts (8 cases). Test suite: 580/580 (86 suites).

Phase 2 shipped (2026-07-18)

Release content-p2-20260718-2309. Closes gaps 5–8:

  • gap 5 — CATEGORIES extended with rate_card, cooling_off_notice, product_disclosure, fee_schedule. Ops can now file IRA §35 rate cards + CPA §37-39 cooling-off notices as first-class categories rather than rolling them into product_brochure.
  • gap 6 — Migration 20260719010000_content_assets_phase2 adds linkedDocumentChecksumAtPublish. publish() snapshots the current Document.checksum when a documentId is linked. findInForce() re-fetches the Document + compares; drift emits content_asset.linked_document_drift_detected audit event but continues to serve (a legitimate re-scan of the PDF must not 500 the consumer read path).
  • gap 7 — update() audit event now carries before + after snapshots via the new auditSnapshot() helper: title, category, documentId, effective window, + the body's bodyPrefix (first 500 chars), bodyHash (SHA-256), and bodyLength. publish() audit carries the same. archive() carries a compact { code, version, status } before/after.
  • gap 8 — Two new public helpers on ContentAssetsService: resolveByCodeOrNull(code) (safe fetch for downstream) and renderWithContentAssets(body) (substitutes {{content:code}} placeholders with the currently-in- force body; missing / unpublished codes leave the placeholder intact so downstream can decide the fallback). Downstream consumers (notification-templates, client-portal, quotations) can adopt these incrementally behind a feature flag without breaking on a missed publish.

Locked in by src/modules/content-assets/content-assets-phase2.spec.ts (6 cases). Test suite: 586/586 (87 suites).

Phase 3 shipped (2026-07-19)

Release content-p3-20260719-0017. Closes gaps 9–12:

  • gap 9 — Four idempotent wrappers (createIdempotent, updateIdempotent, publishIdempotent, archiveIdempotent) via the shared IdempotencyService. Controller passes Idempotency-Key header through every mutation. Also wired @Throttle on the write surface (create 10/min, update 20/min, publish + archive 10/min).
  • gap 10 — Migration 20260719020000_content_assets_phase3 adds ContentAsset.locale (en-UG default) + swaps the unique index from (code, version) to (code, version, locale). create() bumps the version scoped to (code, locale) — a Luganda lg-UG translation can share version numbers with the English original. findInForce() accepts an optional locale argument; serves the requested locale first, falls back to en-UG when the requested locale isn't published for that code. Public inForce endpoint accepts ?locale=<bcp47>.
  • gap 11 — Handled by Phase 1 gap 4's content_asset.public_fetch audit event.
  • gap 12 — CreateContentAssetDto.code regex tightened to ^[a-z0-9]+([._-][a-z0-9]+)*$ — refuses uppercase, spaces, leading dots, and empty segments. New locale field validated against ^[a-z]{2,3}(-[A-Z]{2})?$ (BCP 47 shape).

Locked in by src/modules/content-assets/content-assets-phase3.spec.ts (4 cases). Test suite: 590/590 (88 suites).

All 12 gaps closed. Content-assets module is audit-clean against CPA §37 (acceptance ledger + body-integrity + locale accessibility), IRA §35 (dedicated rate-card + cooling-off + product-disclosure categories), DPPA §16 (cross-border consent gate + public-fetch audit trail), DPPA §21 (audit-body truncation on before/after snapshots), and BOU cybersecurity §5.4 (throttle on public read + audit trail on every fetch).


Phased implementation plan

Complete — every phase shipped.