9.5 KiB
Landing Pages / Page Builder — Schema Documentation
Migrations: 41b, 42, 43, 44, 45, 49
Base URL (planned): /api/admin/pages
Guards: authenticate → requireAdmin() → adminLimiter
TODO: Controller, service, and routes for this module have not been created yet.
Table of Contents
Overview
The page builder uses a generalized pages root table as the single owner of all page_sections. This means sections and blocks are not locked to landing_pages — any future page type (lesson_page, course_page, etc.) can reuse the same section/block system by linking to pages.
Hierarchy:
pages
├── landing_pages (1:1 via page_id)
└── page_sections (1:N via page_id)
└── page_blocks (1:N via page_section_id)
page_templates — standalone reusable layout snapshots
page_user_groups — controls which groups can see a page
Schema
pages
Root identity table. Every page type gets a row here first.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | BIGSERIAL | PK | |
| type | page_type | NOT NULL | Discriminator for the page type |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: idx_pages_type on (type)
landing_pages
Landing-page-specific metadata. One-to-one with pages.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | BIGSERIAL | PK | |
| page_id | BIGINT | NOT NULL, UNIQUE, FK → pages(id) | Link to root pages table |
| title | VARCHAR(255) | NOT NULL | |
| slug | VARCHAR(255) | NOT NULL, UNIQUE | URL path segment |
| meta_title | VARCHAR(255) | SEO title override | |
| meta_description | TEXT | SEO description | |
| status | landing_page_status | NOT NULL DEFAULT 'draft' | |
| published_at | TIMESTAMPTZ | Set when first published | |
| created_by | BIGINT | FK → users(user_id) SET NULL | Admin who created the page |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: idx_landing_pages_status on (status)
page_sections
Ordered sections within any page. References pages, not landing_pages.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | BIGSERIAL | PK | |
| page_id | BIGINT | NOT NULL, FK → pages(id) | Belongs to a page (any type) |
| type | page_section_type | NOT NULL | Section layout type |
| label | VARCHAR(255) | Admin-facing label | |
| position | INT | NOT NULL DEFAULT 0 | Display order |
| settings | JSONB | Background, padding, layout, etc. | |
| is_visible | BOOLEAN | NOT NULL DEFAULT true | Toggle section visibility |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: idx_page_sections_page_id on (page_id), idx_page_sections_order on (page_id, position)
page_blocks
Content blocks within a section.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | BIGSERIAL | PK | |
| page_section_id | BIGINT | NOT NULL, FK → page_sections(id) | |
| type | page_block_type | NOT NULL | Block content type |
| content | JSONB | Payload — varies by type (see below) | |
| position | INT | NOT NULL DEFAULT 0 | Display order within section |
| settings | JSONB | Block-level style overrides | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: idx_page_blocks_section_id on (page_section_id), idx_page_blocks_order on (page_section_id, position)
content shape by block type:
| type | content fields |
|---|---|
| text | { body: string } |
| image | { src: string, alt: string } |
| button | { label: string, href: string, variant: string } |
| video | { src: string, autoplay: boolean } |
| form | { form_id: number } |
| spacer | { height: number } |
page_templates
Reusable layout snapshots. Stored as a full JSONB dump of sections + blocks — not live FK references.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | BIGSERIAL | PK | |
| name | VARCHAR(255) | NOT NULL | Template display name |
| thumbnail_url | TEXT | Preview image URL | |
| structure | JSONB | Full snapshot of sections and blocks | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
page_user_groups
Junction table — controls which user groups can access a page.
| Column | Type | Constraints | Description |
|---|---|---|---|
| page_id | BIGINT | PK, FK → pages(id) ON DELETE CASCADE | |
| group_id | BIGINT | PK, FK → user_groups(group_id) | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: idx_page_user_groups_group_id on (group_id)
Primary Key: composite (page_id, group_id)
Relationships
pages 1 ──── 1 landing_pages
pages 1 ──── N page_sections
pages N ──── N user_groups (via page_user_groups)
page_sections 1 ──── N page_blocks
Cascade deletes flow top-down: deleting a pages row removes its landing_pages record, all its page_sections, and all nested page_blocks automatically.
Design Decisions
pagesas root — sections belong topages, not directly tolanding_pages. Adding a new page type (e.g.course_page) only requires a new detail table + adding its value to thepage_typeENUM. No changes topage_sectionsorpage_blocks.page_templates.structureis a snapshot — templates store a JSONB copy of sections/blocks, not live FK references. This keeps templates stable when source pages are edited.page_sections.page_idpoints topages(id)directly, giving sections access to any page type without schema changes.
ENUM Types
| Type | Values |
|---|---|
page_type |
landing_page, lesson_page, course_page |
landing_page_status |
draft, published, archived |
page_section_type |
hero, features, cta, testimonials, faq, pricing, gallery, custom |
page_block_type |
text, image, button, video, form, spacer |