Happy House - Ecommerce Docs
Developer ResourcesContent

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, optional seoId)
  • 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:

  • ContentAdminModule
  • ContentCustomerModule

Shared service wiring:

  • ContentSharedModule provides and exports ContentService

Leaf ownership model:

  • ContentAdminModule: admin HTTP surface + guards + permissions
  • ContentCustomerModule: 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:

  • id serial PK
  • page_key varchar(80), unique
  • title varchar(255)
  • seo_id uuid nullable FK -> seo.id (ON DELETE SET NULL)
  • created_at timestamptz
  • updated_at timestamptz

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:

  • id serial PK
  • page_id integer FK -> content_page.id (ON DELETE CASCADE)
  • section_key varchar(80)
  • position integer default 0
  • is_enabled boolean default true
  • payload_json jsonb
  • created_at timestamptz
  • updated_at timestamptz

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 Content to permission module list.

Generated permission codes:

  • Content_CREATE
  • Content_READ
  • Content_UPDATE
  • Content_DELETE

Controller use:

  • GET /admin/content/pages/:pageKey -> Content_READ
  • PATCH /admin/content/pages/:pageKey/meta -> Content_UPDATE
  • PUT /admin/content/pages/:pageKey/sections/:sectionKey -> Content_UPDATE
  • DELETE /admin/content/pages/:pageKey/sections/:sectionKey -> Content_DELETE

Guards:

  • JwtAuthGuard
  • RoleGuard
  • 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, and updatedAt

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:

  1. Param DTO validation:
  • pageKey must be one of CONTENT_PAGE_KEYS (8 keys)
  • sectionKey must be one of CONTENT_SECTION_KEYS (18 keys)
  1. Service-level registry validation:
  • section must be allowed for page (isAllowedSectionForPage)
  • payload must satisfy section schema (validateContentSectionPayload)

7.1 Validation constants

  • MAX_LABEL_CHARS = 60
  • MAX_HEADING_CHARS = 110
  • MAX_DESCRIPTION_CHARS = 320
  • MAX_CARD_TITLE_CHARS = 60
  • MAX_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

  • home
  • about
  • faq
  • privacy_policy
  • return_policy
  • terms_of_service
  • global_footer
  • global_header
  • contact
  • blogs

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_TAGlayout: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

  • hero
  • how_it_works
  • faq_intro
  • related_blogs_intro
  • cta_banner
  • quote
  • story_blocks
  • why_choose
  • document
  • data_flow_summary
  • brand_blurb
  • quick_links
  • legal_links
  • copyright
  • contact_info
  • contact_form
  • about_stats
  • our_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 seo object if seoId exists
  • sections[]

Sorting strategy:

  • position ASC
  • id ASC

Public projection filter:

  • isEnabled = true

10. Error Handling Contract

10.1 Content-specific error codes used

  • CONTENT_NOT_FOUND
  • CONTENT_SECTION_KEY_INVALID
  • CONTENT_SECTION_PAYLOAD_INVALID
  • CONTENT_SEO_NOT_FOUND

10.2 Error scenarios

ScenarioHTTPerrorCode
Missing page on public read404CONTENT_NOT_FOUND
Invalid section for page400CONTENT_SECTION_KEY_INVALID
Invalid payload shape/Tiptap/URL400CONTENT_SECTION_PAYLOAD_INVALID
Invalid SEO id on meta update400CONTENT_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_trgm before 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 updated
  • seoId: null clears 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

FileRole
packages/db/src/seed/content-seed-data.tsPure payloads only — exports PAGE_SEEDS. No terms_of_service entry (deliberate, see §8.1).
packages/db/src/seed/seed-content.tsRunner. Exports seedContent(); upserts content_page and content_section rows from PAGE_SEEDS. Registered as pnpm db:seed:content.
packages/db/src/seed/seed-faq.tsExports seedFaqs(); inserts FAQ rows, idempotent by question equality. Registered as pnpm db:seed:faq.
packages/db/src/seed/seed-blog-products.tsExports 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.ts
  • content-admin.controller.spec.ts
  • content-customer.controller.spec.ts
  • content-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_SECONDS set (or default accepted).
  • CONTENT_PAGE_ADMIN_CACHE_TTL_SECONDS set (or default accepted).
  • Frontend mapped page keys correctly for route usage.
  • Frontend handles CONTENT_NOT_FOUND fallback path.
  • Tiptap JSON emitted by admin is valid doc root 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.ts
  • apps/api/src/modules/content/content-shared.module.ts
  • apps/api/src/modules/content/content.service.ts

HTTP surfaces:

  • apps/api/src/modules/content/admin/content-admin.controller.ts
  • apps/api/src/modules/content/admin/content-admin.module.ts
  • apps/api/src/modules/content/customer/content-customer.controller.ts
  • apps/api/src/modules/content/customer/content-customer.module.ts

Schema and contracts:

  • apps/api/src/modules/content/content.contract.ts
  • apps/api/src/modules/content/content-schema.registry.ts
  • apps/api/src/modules/content/dto/*

Database:

  • packages/db/src/schema/content/content-page.schema.ts
  • packages/db/src/schema/content/content-section.schema.ts
  • packages/db/src/migrations/0000_fresh_baseline_reset.sql

Seeds:

  • packages/db/src/seed/content-seed-data.ts
  • packages/db/src/seed/seed-content.ts
  • packages/db/src/seed/seed-faq.ts
  • packages/db/src/seed/seed-blog-products.ts

Permissions/error wiring:

  • apps/api/src/common/authorization/permissions.types.ts
  • packages/db/src/seed/seed-auth.ts
  • apps/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