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).ContentAssetPrisma model — 15 fields,(code, version)unique,statusenum (draft/published/archived), optionaldocumentIdFK to a signed PDF,effectiveFrom+ nullableeffectiveTowindow enforced byfindInForce().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
ContentAssetAcceptancejoin table, noClient.termsAcceptedVersioncolumn that points at a(code, version)pair. A dispute six months later ("I never agreed to that clause") requires reconstructing the world frompublishedAt+ 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 missingrate_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 forprivacy_policy.uggets 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+versionunique +category+status). - No cross-references:
ContentAssetsServiceis 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- incrementsversionfor the givencode.update()refuses non-draft rows explicitly — you cannot mutate a published asset in place. New content requirescreate({ code, ... })again. - In-force window is enforced on the public read:
findInForce()filtersstatus='published'witheffectiveFromat-or-before now and (effectiveTonull 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:
RetentionPurgeJobdoesn't touch content assets — accepted-terms rows survive forever for dispute defense. ✓
Gaps
All 12 gaps closed. See shipped-notes below.
Release content-p1-20260718-2241. Closes gaps 1–4:
- gap 1 — Migration
20260719000000_content_assets_phase1adds theContentAssetAcceptancemodel:(id, contentAssetId, subjectType, subjectId, acceptedAt, ip, userAgent, bodyChecksum, jurisdiction, metadata)with(contentAssetId, subjectType, subjectId)unique. NewPOST /public/content/:code/acceptendpoint mirrors the currently-in-forcebodyChecksumonto the acceptance row + emitscontent_asset.acceptance_recordedaudit event. Idempotent per(asset, subject)— a second call from the same subject returns the existing row. - gap 2 — Migration adds
ContentAsset.bodyChecksum+ index.publish()computesSHA-256(body)and stamps it +content_asset.publishaudit event carries the checksum.findInForce()re-hashes on read; mismatch → emitscontent_asset.body_tamper_detectedaudit event (still serves so a 500 doesn't cascade to consumer traffic). - gap 3 —
@ThrottleonPublicContentController.inForce(60/min) +.accept(30/min). - gap 4 —
PublicContentController.inForceaccepts?jurisdiction=<iso>+x-consent-tokenheader. Consent token overrides the jurisdiction toUG. Non-UG jurisdiction without a consent token → 451 +content_asset.public_fetch.cross_border_deniedaudit. Every successful fetch emitscontent_asset.public_fetchwith{ 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).
Release content-p2-20260718-2309. Closes gaps 5–8:
- gap 5 —
CATEGORIESextended withrate_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 intoproduct_brochure. - gap 6 — Migration
20260719010000_content_assets_phase2addslinkedDocumentChecksumAtPublish.publish()snapshots the currentDocument.checksumwhen adocumentIdis linked.findInForce()re-fetches the Document + compares; drift emitscontent_asset.linked_document_drift_detectedaudit 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 carriesbefore+aftersnapshots via the newauditSnapshot()helper:title,category,documentId, effective window, + the body'sbodyPrefix(first 500 chars),bodyHash(SHA-256), andbodyLength.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) andrenderWithContentAssets(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).
Release content-p3-20260719-0017. Closes gaps 9–12:
- gap 9 — Four idempotent wrappers
(
createIdempotent,updateIdempotent,publishIdempotent,archiveIdempotent) via the sharedIdempotencyService. Controller passesIdempotency-Keyheader through every mutation. Also wired@Throttleon the write surface (create 10/min, update 20/min, publish + archive 10/min). - gap 10 — Migration
20260719020000_content_assets_phase3addsContentAsset.locale(en-UGdefault) + swaps the unique index from(code, version)to(code, version, locale).create()bumps the version scoped to(code, locale)— a Lugandalg-UGtranslation can share version numbers with the English original.findInForce()accepts an optionallocaleargument; serves the requested locale first, falls back toen-UGwhen the requested locale isn't published for that code. PublicinForceendpoint accepts?locale=<bcp47>. - gap 11 — Handled by Phase 1 gap 4's
content_asset.public_fetchaudit event. - gap 12 —
CreateContentAssetDto.coderegex tightened to^[a-z0-9]+([._-][a-z0-9]+)*$— refuses uppercase, spaces, leading dots, and empty segments. Newlocalefield 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.