Content Module Backend Documentation
Data model, validation architecture, and module composition for the Happy House page-section CMS.
Content Module - Backend Documentation
1. Backend Scope and Boundaries
Content backend owns:
- page-level CMS metadata (
pageKey, title, optionalseoId) - section-level payload CRUD
- strict page/section compatibility enforcement
- strict payload validation per section key
- public read projection (enabled, ordered sections)
Content backend does not own:
- blog listing source data
- FAQ listing source data
- banner placements
Those data sources remain in their existing modules and are composed by frontend runtime.
2. Module Composition (Aggregate + Leaf)
ContentModule composes:
ContentAdminModuleContentCustomerModule
Shared service wiring:
ContentSharedModuleprovides and exportsContentService
Leaf ownership model:
ContentAdminModule: admin HTTP surface + guards + permissionsContentCustomerModule: public/mobile HTTP read surface
3. Routing and Composition
3.1 Direct routes
- Admin base:
admin/content/pages - Public base:
content/pages
3.2 Mobile composition
MobileModule imports ContentCustomerModule and includes it in RouterModule.register([{ path: "mobile", children: [...] }]).
Resulting mobile route:
GET /api/mobile/content/pages/:pageKey
4. Database Model (Drizzle / PostgreSQL)
4.1 content_page
Columns:
idserial PKpage_keyvarchar(80), uniquetitlevarchar(255)seo_iduuid nullable FK ->seo.id(ON DELETE SET NULL)created_attimestamptzupdated_attimestamptz
Indexes and constraints:
- unique index on
page_key - index on
seo_id - partial unique index on
seo_id(IS NOT NULL)
4.2 content_section
Columns:
idserial PKpage_idinteger FK ->content_page.id(ON DELETE CASCADE)section_keyvarchar(80)positioninteger default0is_enabledboolean defaulttruepayload_jsonjsonbcreated_attimestamptzupdated_attimestamptz
Indexes and constraints:
- unique
(page_id, section_key) - index on
page_id - index on
(page_id, position)
5. Permission and Authorization Model
Permission module registration:
- Added
Contentto permission module list.
Generated permission codes:
Content_CREATEContent_READContent_UPDATEContent_DELETE
Controller use:
GET /admin/content/pages/:pageKey->Content_READPATCH /admin/content/pages/:pageKey/meta->Content_UPDATEPUT /admin/content/pages/:pageKey/sections/:sectionKey->Content_UPDATEDELETE /admin/content/pages/:pageKey/sections/:sectionKey->Content_DELETE
Guards:
JwtAuthGuardRoleGuard- per-route throttling with
IpThrottlerGuard
6. Service Architecture and Read/Write Semantics
ContentService is the single business service and handles:
- page existence policy
- section allowlist checks
- payload validation
- SEO foreign-key pre-validation
- admin/public response shaping
6.1 Page existence policy
Admin paths (getPageForAdmin, updatePageMeta, upsertSection) use ensurePage:
- if page row does not exist, create it with default title from
CONTENT_PAGE_DEFAULT_TITLES
Public path (getPageForPublic) uses getPageByKey only:
- missing page throws
404 CONTENT_NOT_FOUND
6.2 Section upsert policy
Section writes are idempotent by (page_id, section_key):
- implemented with
onConflictDoUpdate - updates
position,isEnabled,payloadJson, andupdatedAt
6.3 Section delete policy
Delete requires existing page + section row:
- missing page =>
404 CONTENT_NOT_FOUND - missing section =>
404 CONTENT_NOT_FOUND
7. Validation Architecture
Validation happens at two levels:
- Param DTO validation:
pageKeymust be one ofCONTENT_PAGE_KEYS(8 keys)sectionKeymust be one ofCONTENT_SECTION_KEYS(18 keys)
- Service-level registry validation:
- section must be allowed for page (
isAllowedSectionForPage) - payload must satisfy section schema (
validateContentSectionPayload)
7.1 Validation constants
MAX_LABEL_CHARS = 60MAX_HEADING_CHARS = 110MAX_DESCRIPTION_CHARS = 320MAX_CARD_TITLE_CHARS = 60MAX_CARD_DESCRIPTION_CHARS = 220
7.2 URL safety rule
safeUrlSchema accepts:
- absolute HTTP(S) URLs
- site-relative URLs that start with
/
7.3 Tiptap doc rule
tiptapDocSchema requires:
type: "doc"- non-empty
content[]
Size guard:
- JSON stringified length capped by
maxChars * 12
7.4 document section — paragraphs is optional
legalDocumentSectionSchema in apps/api/src/modules/content/content-schema.registry.ts:109-135
makes paragraphs optional (.array(...).min(1).max(20).optional()), alongside the
already-optional bullets. A refinement on legalDocumentSectionWithBodySchema (lines 142-146)
requires at least one of the two to be non-empty:
.refine(
(section) => (section.paragraphs?.length ?? 0) > 0 || (section.bullets?.length ?? 0) > 0,
{ message: "A document section needs paragraphs, bullets, or both." },
)This exists because several sections of the real return/privacy policy are pure bullet lists —
"Eligibility" and "How to Start a Return" have no prose paragraph at all. The schema previously
required a non-empty paragraphs, which forced the first content seed to invent four sentences of
connective legal prose (including "A return is accepted when all of the following hold") that
nobody approved. The fix relaxed the schema rather than the copy; the invented sentences were
deleted from the seed and a live-database sweep confirmed zero rows contain them.
content-seed-payloads.spec.ts mutation-tests this: reverting paragraphs to required fails the
suite on exactly privacy_policy/document and return_policy/document.
8. Page and Section Registries
8.1 Page keys
homeaboutfaqprivacy_policyreturn_policyterms_of_serviceglobal_footerglobal_headercontactblogs
return_policy was added to serve /return-policy from the CMS instead of the storefront's static
src/lib/data/policies.ts fixture. Because page_key is varchar(80) and not a Postgres enum
(packages/db/src/schema/content/content-page.schema.ts:23), adding it required no migration — only
application-constant changes: CONTENT_PAGE_KEYS, CONTENT_SECTIONS_BY_PAGE (return_policy: ["document"]), and CONTENT_PAGE_DEFAULT_TITLES (return_policy: "Return Policy Page"), all in
apps/api/src/modules/content/content.contract.ts. The cache-invalidation tag registry needed a
fourth site: CONTENT_PAGE_TO_SHARED_TAG in
apps/api/src/modules/cache-invalidation/cache-invalidation.tags.ts maps it to
page:return-policy. pnpm check-types confirmed these four sites were the only ones referencing
the page-key union.
global_header was added the same way and for the same reason return_policy was: the site's top
utility bar — the phone number, the email, the promotional strip and the About / FAQ / Contact links
— was hard-coded in the storefront with nowhere for an operator to change it. It is the counterpart
to global_footer, which already existed.
It required no migration either, and the same four constant sites: CONTENT_PAGE_KEYS,
CONTENT_SECTIONS_BY_PAGE (global_header: ["announcement_bar", "contact_info", "quick_links"]),
CONTENT_PAGE_DEFAULT_TITLES, and CONTENT_PAGE_TO_SHARED_TAG → layout:header.
That fourth site is a compile error, not a convention. CONTENT_PAGE_TO_SHARED_TAG is declared
Record<ContentPageKey, string>, and so are the sections and titles maps — all three are exhaustive
over the key union, so adding a key without registering its revalidation tag, its section list and
its title does not compile. This is the mechanism that stops a page key shipping with no
invalidation, which is the class of gap return_policy was on the admin side.
Only one new section key came with it, announcement_bar; the header's contact block and utility
links reuse the footer's contact_info and quick_links, which a per-page allowlist makes free.
The admin panel has been brought level with both. It is a separate repo with its own copy of
the page and section unions (lib/mobile-api/admin/cms-pages-types.ts), and nothing in either
repo's compiler can see across the boundary — so a page key added here is a page an operator
simply cannot open there, with no error anywhere. What caught it was
tests/lib/mobile-api/admin/cms-contract-parity.test.ts, which parses this repo's constant
file and diffs it against the admin's copy; it failed on global_header the moment the backend
change landed, which is exactly the job it exists to do.
The admin now declares global_header and announcement_bar, groups header and footer under a
layout tab, and ships an editor for the announcement strip and for the two fields the footer's
contact_info had gained (address, openingHours). One detail worth knowing when writing any
section editor against this module: the section schemas use .min(1) on their optional
collections, so an editor must omit an empty field rather than send "" or [] — the
validation rejects the whole section otherwise.
A matching guard now covers the checkout enums
(tests/lib/mobile-api/admin/checkout-enum-parity.test.ts), which had the same shape of exposure
and no test: a gateway_timeout close reason added here rendered as an empty badge there, telling
a support agent nothing about why a real customer's checkout had closed.
terms_of_service is deliberately unseeded. The page key exists and the route resolves, but no
row exists for it, so GET /api/mobile/content/pages/terms_of_service 404s
(CONTENT_NOT_FOUND). No approved terms copy exists in any of the three repos, and writing
placeholder legal text would read as binding — a visible 404 is honest, invented terms are not. When
real copy is supplied, seeding it is one entry in PAGE_SEEDS
(packages/db/src/seed/content-seed-data.ts) and nothing else; no code change is required.
8.2 Section keys
herohow_it_worksfaq_introrelated_blogs_introcta_bannerquotestory_blockswhy_choosedocumentdata_flow_summarybrand_blurbquick_linkslegal_linkscopyrightcontact_infocontact_formabout_statsour_mission
8.3 Allowlist mapping
CONTENT_SECTIONS_BY_PAGE enforces exact allowed section sets for each page key with 8 page entries and 16 actively assigned section keys (2 reserved for future use).
9. Response Construction and Projection
Response DTO root:
ContentPageResponseDto
Fields returned:
- page metadata (
id,pageKey,title,seoId, timestamps) - resolved
seoobject ifseoIdexists sections[]
Sorting strategy:
position ASCid ASC
Public projection filter:
isEnabled = true
10. Error Handling Contract
10.1 Content-specific error codes used
CONTENT_NOT_FOUNDCONTENT_SECTION_KEY_INVALIDCONTENT_SECTION_PAYLOAD_INVALIDCONTENT_SEO_NOT_FOUND
10.2 Error scenarios
| Scenario | HTTP | errorCode |
|---|---|---|
| Missing page on public read | 404 | CONTENT_NOT_FOUND |
| Invalid section for page | 400 | CONTENT_SECTION_KEY_INVALID |
| Invalid payload shape/Tiptap/URL | 400 | CONTENT_SECTION_PAYLOAD_INVALID |
| Invalid SEO id on meta update | 400 | CONTENT_SEO_NOT_FOUND |
11. Data Integrity and Migration Notes
Migration reset model:
- single baseline migration file generated for full schema
- baseline includes
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Why extension is required:
- existing catalog/product/tag trigram indexes use
gin_trgm_ops - baseline migration must always provision
pg_trgmbefore those indexes
12. Performance and Query Notes
Current query model is simple and bounded:
- single page row query with left join to
seo - section query filtered by page id (+ enabled flag for public)
- deterministic sort with indexed columns
Current write model:
- one upsert per section write
- one update for metadata write
- one delete for section removal
No N+1 behavior exists in current implementation.
13. Concurrency and Consistency
13.1 Concurrent section edits
Because section writes use upsert with unique (page_id, section_key), concurrent writes converge to last-write-wins.
13.2 Partial update semantics
PATCH meta: only provided fields are updatedseoId: nullclears SEO relation
13.3 Read consistency with cache
Reads are cache-aside with Redis:
- public/mobile reads use
content:page:public:key space - admin reads use
content:page:admin:key space
Every successful write (PATCH meta, PUT section, DELETE section) invalidates both key spaces for that page key, so post-write reads see fresh DB state.
13.5 Seed Data
| File | Role |
|---|---|
packages/db/src/seed/content-seed-data.ts | Pure payloads only — exports PAGE_SEEDS. No terms_of_service entry (deliberate, see §8.1). |
packages/db/src/seed/seed-content.ts | Runner. Exports seedContent(); upserts content_page and content_section rows from PAGE_SEEDS. Registered as pnpm db:seed:content. |
packages/db/src/seed/seed-faq.ts | Exports seedFaqs(); inserts FAQ rows, idempotent by question equality. Registered as pnpm db:seed:faq. |
packages/db/src/seed/seed-blog-products.ts | Exports seedBlogPostProducts(); links blog posts to products by slug, called from seedBlogData(). |
Seed idempotency is verified: a second run adds 0 rows. content-seed-payloads.spec.ts runs all 17
seeded payloads through the real validateContentSectionPayload registry — the seed writes with
Drizzle directly and never otherwise passes through that validation, so this spec is the only gate
proving the fixtures are valid against the same rules the admin write path enforces.
14. Test Coverage
Unit tests:
content.service.spec.tscontent-admin.controller.spec.tscontent-customer.controller.spec.tscontent-schema.registry.spec.ts
Covered scenarios:
- invalid page/section pair rejection
- invalid payload rejection before DB write
- missing public page returns
CONTENT_NOT_FOUND - deterministic public/admin cache key + TTL usage
- cache read fallback to DB on Redis failure
- write-path cache invalidation for meta/section write/delete
- schema validation happy/sad paths for all 18 section keys
- page-to-section allowlist coverage for all 8 pages
15. Operational Checklist
- DB migrate applied successfully in target env.
- Role permissions seeded include
Content_*codes. - Admin roles updated to include required content permissions.
- Redis configured and reachable in runtime env.
-
CONTENT_PAGE_PUBLIC_CACHE_TTL_SECONDSset (or default accepted). -
CONTENT_PAGE_ADMIN_CACHE_TTL_SECONDSset (or default accepted). - Frontend mapped page keys correctly for route usage.
- Frontend handles
CONTENT_NOT_FOUNDfallback path. - Tiptap JSON emitted by admin is valid
docroot shape.
16. Future Extension Points
Planned-compatible extension options:
- draft/publish workflow (
content_page_version) - locale support (
locale, localized section rows) - scheduled publish windows
- admin list/search endpoint for pages
Current implementation intentionally stays immediate-live for v1.
17. File Map
Core implementation:
apps/api/src/modules/content/content.module.tsapps/api/src/modules/content/content-shared.module.tsapps/api/src/modules/content/content.service.ts
HTTP surfaces:
apps/api/src/modules/content/admin/content-admin.controller.tsapps/api/src/modules/content/admin/content-admin.module.tsapps/api/src/modules/content/customer/content-customer.controller.tsapps/api/src/modules/content/customer/content-customer.module.ts
Schema and contracts:
apps/api/src/modules/content/content.contract.tsapps/api/src/modules/content/content-schema.registry.tsapps/api/src/modules/content/dto/*
Database:
packages/db/src/schema/content/content-page.schema.tspackages/db/src/schema/content/content-section.schema.tspackages/db/src/migrations/0000_fresh_baseline_reset.sql
Seeds:
packages/db/src/seed/content-seed-data.tspackages/db/src/seed/seed-content.tspackages/db/src/seed/seed-faq.tspackages/db/src/seed/seed-blog-products.ts
Permissions/error wiring:
apps/api/src/common/authorization/permissions.types.tspackages/db/src/seed/seed-auth.tsapps/api/src/common/types/error-codes.ts
Time fields in this module are stored as timezone-aware values and should be handled as ISO-8601 instants by API consumers.
See Also
- Feature Guide: See Content Module - Feature List for page/section capability matrix.
- API Guide: See Content Module - API & Integration Guide for endpoint payload contracts.