# 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](#overview) - [Schema](#schema) - [pages](#pages) - [landing_pages](#landing_pages) - [page_sections](#page_sections) - [page_blocks](#page_blocks) - [page_templates](#page_templates) - [page_user_groups](#page_user_groups) - [Relationships](#relationships) - [Design Decisions](#design-decisions) - [ENUM Types](#enum-types) --- ## 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 - **`pages` as root** — sections belong to `pages`, not directly to `landing_pages`. Adding a new page type (e.g. `course_page`) only requires a new detail table + adding its value to the `page_type` ENUM. No changes to `page_sections` or `page_blocks`. - **`page_templates.structure` is 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_id`** points to `pages(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` |