Skip to content

Database Schema — Journeys, Home & Content

Draft 2 · 2026-08-12 · Companion to journeys-and-home.md — review/optimize before implementation

Design draft — target state, not all built

Content tables (Passage, Writing, Book, Article, Theme, Tag) are implemented. Home, progress, bookmark, and reflection tables are designed but not yet migrated. The Journey tables below are being built now (models + CMS only; no API).

Draft 2 — the Journey spine flattened

Draft 1 modelled a Journey as chapters that each contained ~4 pieces (data_chapter_piece, one is_primary). The updated designs drop that level: a chapter is one piece. data_chapter_piece is gone, its content refs moved onto data_journey_chapter, and a chapter can now reference an Article as well as a Passage or Writing.

Column-level schema for every table the Journeys + Home design creates or reshapes. Wagtail's own tables (revisions, images, documents) are referenced, not redefined. All content tables also get Wagtail DraftStateMixin columns (live, has_unpublished_changes, first_published_at, …) — shown once here, implied elsewhere.

Conventions: PK = bigint identity primary key (implied on every table). FKs are bigint with ON DELETE noted. = indexed beyond the automatic FK/unique indexes.

ER overview

erDiagram
    THEME ||--o{ PASSAGE : "primary_theme"
    THEME ||--o{ WRITING : "primary_theme"
    THEME ||--o{ JOURNEY : "primary_theme"
    BOOK ||--o{ WRITING : "source"
    BOOK ||--o{ PASSAGE : "source"
    WRITING ||--o{ PASSAGE : "source_writing"
    JOURNEY ||--o{ JOURNEY_CHAPTER : "has ordered"
    JOURNEY_CHAPTER }o--|| PASSAGE : "XOR ref"
    JOURNEY_CHAPTER }o--|| WRITING : "XOR ref"
    JOURNEY_CHAPTER }o--|| ARTICLE : "XOR ref"
    JOURNEY ||--o{ RELATED_JOURNEY : "none or 2-4 ordered"
    HOME ||--o{ HOME_JOURNEY_PIN : "≤5"
    HOME ||--o{ HOME_WHATS_NEW_PIN : ""
    HOME }o--|| THEME : "theme_of_week"
    USER ||--o{ JOURNEY_PROGRESS : ""
    USER ||--o{ CHAPTER_COMPLETION : ""
    USER ||--o{ BOOKMARK : ""
    USER ||--o{ REFLECTION : ""
    JOURNEY ||--o{ JOURNEY_PROGRESS : ""
    JOURNEY_CHAPTER ||--o{ CHAPTER_COMPLETION : ""

Taxonomy

data_theme

Column Type Constraints / notes
name varchar(200) unique
framing_line varchar(255) blank — default display copy above the theme name
colour varchar(7) HEX, validated #RRGGBB
icon FK → wagtailimages_image SET_NULL, null — SVG
background FK → wagtailimages_image SET_NULL, null — main background SVG
home_background FK → wagtailimages_image SET_NULL, null — Home blurb, SVG or raster
lottie FK → wagtaildocs_document SET_NULL, null — Lottie JSON
design_extra jsonb default {} — future scalar FE knobs, merged into the API design object
description richtext blank

The API projects colour/icon/background/home_background/lottie + design_extra as one design JSON object; assets stay individual FK columns so the CMS captures and validates each one properly (choosers, hex validation, referential integrity).

data_tagcategory and data_tag are unchanged from the current build.

Content (evergreen + editorial)

Shared columns on all four: review_status varchar(16) (ai_draft | reviewed, default ai_draft) ❖, is_ai_generated boolean (CMS-only, permanent), DraftState/Revision columns.

How primary theme is tracked. Each content model (and Journey) has a themes M2M for the full, unordered set (powers the graph/discovery) plus a scalar primary_theme_id FK column naming the one theme that drives visuals (reading-screen colour, card tint, theme assets). Primary is a dedicated column, not a flag on the M2M rows — the editor picks it in the CMS. The intended rule is primary_theme ∈ themes (it shouldn't point outside the set), though that is not yet enforced in clean(). The API returns primary_theme as a scalar theme_id (nullable — the FE resolves it against its client-cached theme set and falls back to the active theme when null); the theme object is never re-embedded per card. (Alternative considered: an is_primary boolean on a custom M2M through-table with a partial-unique "one-per-content" — rejected as heavier given the existing ParentalManyToManyField themes.)

data_passage

Column Type Constraints / notes
title varchar(255)
slug varchar(255) unique — stable identifier for API payloads and importer
body text RichTextField (rich text; restricted marks, served as Markdown)
interpretation richtext blank
reflection_prompt text blank
audio FK → wagtaildocs_document SET_NULL, null
source FK → data_book SET_NULL, null
source_writing FK → data_writing SET_NULL, null
primary_theme FK → data_theme SET_NULL, null
CHECK at most one of (source, source_writing)

(No is_new_eligiblePassage owns the hero and isn't part of What's New.)

M2M joins: data_passage_themes, data_passage_tags. Intended app-level rule primary_theme ∈ themesnot yet enforced (Passage.clean() validates only the source/source_writing XOR).

data_writing

Column Type Constraints / notes
kind varchar(12) CHECK kind IN ('letter','essay','chapter')
title varchar(255)
slug varchar(255) unique — stable identifier for API payloads and importer
body richtext
source FK → data_book PROTECT, null — single source per ADR-0006
recipient varchar(255) blank — required for letter (validation)
original_date date null — letter/essay
order smallint null — position within the source Work; required for chapter (validation)
reflection_prompt text blank
audio FK → wagtaildocs_document SET_NULL, null — MVP populates Passage audio only
is_new_eligible boolean default false — not indexed yet (see optimization notes)
primary_theme FK → data_theme SET_NULL, null — mirrors Passage

M2M joins: data_writing_themes, data_writing_tags.

data_book

Column Type Constraints / notes
title varchar(255)
slug varchar(255) unique — stable identifier for API payloads and importer
cover FK → wagtailimages_image SET_NULL, null
publication_year smallint null
synopsis text blank
primary_theme FK → data_theme PROTECT, null

M2M joins: data_book_themes, data_book_tags. (Future: buy_link.)

data_article

Column Type Constraints / notes
title / subtitle varchar(255) subtitle blank
slug varchar(255) unique
author varchar(255) contributor name; becomes an FK if authors get pages later
featured_image FK → wagtailimages_image SET_NULL, null
publication_date date null
body richtext
primary_theme FK → data_theme SET_NULL, null
is_new_eligible boolean default false — Article + Writing feed What's New; not indexed yet (see optimization notes)

Curation

data_journey

Column Type Constraints / notes
name varchar(255)
slug varchar(255) unique
central_question text blank — the question the journey exists to answer
editorial_framing text the card pitch; expands behind "see more" — a paragraph, not one line
colour varchar(7) HEX, blank until design assigns one
emblem FK → wagtailimages_image SET_NULL, null — one image: the journey-card artwork and the collectible. Replaces Draft 1's icon
emblem_title varchar(255) blank — e.g. "Journey walked"
emblem_line text blank — why this emblem, for this journey
primary_theme FK → data_theme SET_NULL, null
ingest_meta jsonb default {} — importer provenance; never served

M2M join: data_journey_themes. DraftState/Revision columns (publishable) — a Journey is the single publishable unit; its chapters are captured in the Journey's revision.

The emblem is one image, not two. It is the journey-card artwork, and it is the collectible the reader earns. The FE blurs it and lifts the blur as chapters complete (the "journey becomes a little clearer" beat), revealing it fully at the end. The backend stores the finished artwork once and serves no reveal states; progressive disclosure is a front-end effect driven by the reader's completion count.

Column Type Constraints / notes
from_journey FK → data_journey CASCADE
to_journey FK → data_journey CASCADE
sort_order smallint unique (from_journey, to_journey); none, or 2–4 — enforced at publish, not on save. Zero is what lets the first journeys publish, so the rule is never a dead end; exactly one is refused. Ordering falls back to pk when sort_order ties or is null

data_journey_chapter

Column Type Constraints / notes
journey FK → data_journey CASCADE
sort_order smallint unique (journey, sort_order) ❖
slug varchar(255) unique — the importer's idempotency key
title varchar(255) editorial title; need not match the piece's own
passage FK → data_passage PROTECT, null
writing FK → data_writing PROTECT, null
article FK → data_article PROTECT, null
opening_question text
intro_pages jsonb StreamField — ordered rich-text pages the reader moves through before the piece; blank, no cap
pause_line text blank — the interstitial beat after the meaning
practice text blank — "A small practice" the reader commits to
reveal_line text blank — shown with the emblem reveal at chapter end
CHECK exactly one of (passage, writing, article) — collectively NOT NULL

No taxonomy columns and no M2M join: a chapter is its piece, so its themes and primary theme are the piece's, read through at query time. Tagging the chapter as well would be the same answer entered twice, free to drift. The Journey carries the taxonomy for the course as a whole.

The closing reflection question is likewise the piece's own reflection_prompt — not duplicated here. Chapters have no independent publish state; they live and die with the Journey revision.

Same shape ×3:

Column Type Constraints / notes
journey / theme / book FK CASCADE, unique — an entity appears once per list
sort_order smallint unique per list

data_home (singleton)

Column Type Constraints / notes
theme_of_week FK → data_theme SET_NULL, null
framing_line_override varchar(255) blank — wins over the theme's default when set

data_home_journey_pin

Column Type Constraints / notes
home FK → data_home CASCADE
journey FK → data_journey CASCADE, unique with home
sort_order smallint unique (home, sort_order); ≤5 enforced in validation

data_home_whats_new_pin

Column Type Constraints / notes
home FK → data_home CASCADE
article / writing FKs CASCADE, CHECK exactly one set (Passage excluded — hero-only)
sort_order smallint unique (home, sort_order)

User data

All FKs to data_user are CASCADE (account deletion is soft-delete at the app layer; hard cleanup cascades).

data_user gains one column (the account model is owned by CSL-30/auth, listed here for completeness): preferred_theme_id FK → data_theme, nullable, on_delete=SET_NULL — the user's chosen active theme, null = fall back to Theme-of-the-Week. Read via GET /api/account/, set via PATCH /api/account/.

data_journey_progress

Column Type Constraints / notes
user FK → data_user unique with journey ❖
journey FK → data_journey PROTECT
current_chapter FK → data_journey_chapter SET_NULL, null
status varchar(12) active | completed
started_at / completed_at timestamptz completed_at null until finished

data_chapter_completion

Column Type Constraints / notes
user FK → data_user
chapter FK → data_journey_chapter PROTECT; unique (user, chapter)
completed_at timestamptz

There is no per-piece completion table: a chapter is one piece, so chapter completion is the only granularity. Position within a chapter's arc is presentation state, not a row per screen.

data_bookmark

Column Type Constraints / notes
user FK → data_user
passage / writing / article / journey / journey_chapter FKs CASCADE, all null
created_at timestamptz
CHECK exactly one target set · partial unique (user, target) per FK

data_reflection

Column Type Constraints / notes
user FK → data_user ❖ (user, created_at)
prompt_snapshot text what the user actually saw (the piece's reflection_prompt at save time)
response text
chapter FK → data_journey_chapter SET_NULL, null
passage / writing FKs SET_NULL, null — at most one set
created_at timestamptz

Optimization notes (pre-implementation review)

  • Home is per-section endpoints, not one aggregate query — hero + Further Reading random pools are ORDER BY random() over small tables at launch corpus size (fine now; swap to TABLESAMPLE past ~10k rows); every other section is a PK/list lookup. Splitting means each section caches independently (Cache-Control per endpoint) and no request waits on the slowest.
  • What's New — the auto-fill query (filter(live=True, is_new_eligible=True).order_by( first_published_at desc) over Article + Writing) would benefit from a partial index on (is_new_eligible, live) + first_published_at DESC. Not yet created (migration 0009 adds is_new_eligible as a plain boolean) — deferred while the corpus is small; add the composite partial index when volume warrants.
  • Journey landing + progress joins data_journey_chapter → completions per user: the unique (user, chapter) index doubles as the lookup index.
  • XOR refs (chapters, pins, bookmarks, reflections) are nullable FKs + CHECK, chosen over GenericForeignKey so select_related/prefetch_related work and FK integrity is real.
  • PROTECT on content FKs from curation (chapters, progress) — an editor cannot delete a piece that a Journey references; they must unlink it first. Deliberate friction.
  • The display ordinal is computed, never stored. "Chapter N" is derived at read time over the journey's authored chapters — like read time, computed so it can't drift. sort_order is nullable and can tie, so it orders the list but never numbers it. A live journey cannot lose a chapter (the CMS blocks unpublishing a piece it uses), so the numbering is stable for a reader whose progress is saved against particular chapters.