berrypod/docs/plans/draft-publish-workflow.md
jamey 9a506357eb
All checks were successful
deploy / deploy (push) Successful in 6m49s
complete editor panel reorganisation polish
- fix Site tab not loading theme state on direct URL navigation
- fix nav editor showing "Custom URL" for page links (detect by URL match)
- add Home option to nav page picker
- mark editor-reorganisation plan as complete
- add dynamic-url-customisation and draft-publish-workflow plans

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 18:50:07 +01:00

11 KiB

Draft-then-publish workflow

Status: Planned (after unified-editing-mode)

Problem

Current editing workflow uses explicit save — changes are either saved (immediately live) or lost on navigation. This creates:

  1. Data loss anxiety — navigate away accidentally, lose all work
  2. beforeunload warnings — browser-native dialogs with no custom options
  3. No experimentation — can't safely try changes without affecting live site

Solution

Implement a draft-then-publish model with site-level publishing and version history:

  1. Site starts unpublished — New sites are in draft mode until first publish
  2. Auto-save drafts — All edits auto-save, no risk of lost work
  3. Visitors see published only — No accidental exposure of drafts
  4. Version history — Each publish creates a snapshot, enabling rollback
  5. URLs are versioned — URL slugs are part of version data (ties into dynamic URL customisation)

Design principles

  1. Site-level lifecycle — Site has a first_published_at timestamp; until then, visitors see "coming soon"
  2. Drafts are per-user — each admin sees their own pending changes
  3. Visitors always see published — no accidental exposure of drafts
  4. Auto-save constantly — no risk of lost work
  5. Clear visual distinction — obvious when viewing draft vs published
  6. Single publish action — one button publishes all pending changes
  7. Version snapshots — each publish creates a version record for rollback
  8. Atomic rollback — rolling back restores content AND URL (redirect chain updates automatically)

Scope

Three types of editable content:

Type Storage Draft approach
Page blocks pages.blocks (JSON) draft_blocks column
Theme settings settings.theme_settings (JSON) draft_theme_settings key
Shop settings settings table (various keys) draft_* keys or settings_drafts table

Data model

Site-level publishing

# Add to settings or dedicated sites table
# Option 1: Settings key
%{"site_first_published_at" => nil}  # nil = unpublished, datetime = published

# Option 2: Dedicated sites table (if multi-tenant later)
create table(:sites, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :first_published_at, :utc_datetime_usec  # nil until first publish
  timestamps(type: :utc_datetime_usec)
end

Rather than draft columns, use a proper version table. Each publish creates a version snapshot.

create table(:page_versions, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :page_id, references(:pages, type: :binary_id, on_delete: :delete_all), null: false
  add :version_number, :integer, null: false
  add :blocks, :map, null: false
  add :url_slug, :string  # URL is part of version data
  add :meta_title, :string
  add :meta_description, :string
  add :published_at, :utc_datetime_usec
  add :published_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
  timestamps(type: :utc_datetime_usec)
end

create index(:page_versions, [:page_id, :version_number])
create unique_index(:page_versions, [:page_id, :version_number])
# Pages table changes
alter table(:pages) do
  add :published_version_id, references(:page_versions, type: :binary_id, on_delete: :nilify_all)
  add :draft_blocks, :map
  add :draft_url_slug, :string
  add :draft_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
  add :draft_updated_at, :utc_datetime_usec
end

How it works:

  • pages.draft_* fields hold current work-in-progress
  • On publish: create new page_versions record, update pages.published_version_id
  • On rollback: set published_version_id to older version, update redirect chain
  • Visitors always see the version pointed to by published_version_id

URL versioning and redirects

When a page version has a different URL than the current published version:

  1. The redirect system tracks URL history
  2. All old URLs redirect to current canonical URL
  3. On rollback, old URL becomes canonical again, redirect chain updates
# Example: Page starts at /about, renamed to /about-us, then rolled back

# Version 1: url_slug = "about"      <- created
# Version 2: url_slug = "about-us"   <- published, redirect /about → /about-us created
# Rollback to v1: redirect /about-us → /about created (or /about redirect deleted)

This ties directly into the dynamic URL customisation plan.

Implementation

Phase 0: Site-level publishing

Task Est Notes
Add site_first_published_at setting 15m nil until first publish
Update coming soon page to check this setting 30m Show until site is published
Add "Publish site" button to admin dashboard 30m First-time publish action
Hide "Publish site" after first publish 15m One-time action

Phase 1: Page versions and drafts

Task Est Notes
Create page_versions table 30m Migration with version data
Add draft columns and published_version_id to pages 30m Migration
Create PageVersions context module 1.5h create_version/2, rollback_to/2, list_versions/1
Update Pages context with draft functions 1h save_draft/2, publish_draft/1, discard_draft/1, has_draft?/1
Modify PageEditorHook to auto-save drafts 1h Debounced save on every change
Add "Publish" and "Discard" buttons to editor 1h Replace current "Save"
Show draft indicator when viewing page with unpublished changes 1h Banner or badge
Remove beforeunload warning (no longer needed) 15m Drafts persist

Phase 2: Theme drafts

Task Est Notes
Add draft_theme_settings to settings 30m New key
Update Settings context with theme draft functions 1h Similar to pages
Modify theme editor to auto-save drafts 1h
Theme preview shows draft, shop shows published 1h Conditional CSS injection
Add publish/discard to theme editor 30m

Phase 3: Settings drafts

Task Est Notes
Decide which settings are draftable 30m Not all need drafts (e.g. API keys)
Add draft handling to Settings context 1.5h
Update settings editor to use drafts 1h
Unified "Publish all" option 1h Publish page + theme + settings together

Phase 4: Version history and rollback

Task Est Notes
Version history panel in editor 1.5h List of published versions with timestamps
Rollback action with confirmation 1h "Revert to version 3?"
URL redirect chain update on rollback 1h Update redirects to point to restored URL
Version comparison view (stretch) 2h Side-by-side diff

Phase 5: Polish

Task Est Notes
Draft age indicator ("Last saved 5 mins ago") 30m
Draft conflict handling (stale draft warning) 1h Edge case: published changed since draft created
Version pruning (keep last N versions) 1h Prevent unbounded growth

UX flow

First-time site publish

  1. New shop completes setup wizard
  2. Site is in "coming soon" mode — visitors see placeholder
  3. Admin edits pages, theme, settings (all auto-saved as drafts)
  4. Admin clicks "Publish site" on dashboard
  5. Site goes live — first_published_at set, all current drafts become version 1

Editing a page

  1. Admin navigates to page, clicks "Edit"
  2. Editor panel opens, showing current published state
  3. Admin makes changes — auto-saved as draft every few seconds
  4. Admin can navigate away freely — draft persists
  5. Admin returns later — draft loaded automatically
  6. "Publish" button commits draft → new version
  7. "Discard" button deletes draft, reverts to current published version

Rolling back a page

  1. Admin opens version history in editor
  2. Sees list: "Version 3 (current) — 2 hours ago", "Version 2 — yesterday", etc.
  3. Clicks "Revert to version 2"
  4. Confirmation: "This will restore version 2 content and URL. Continue?"
  5. On confirm: published_version_id updated, redirect chain adjusted
  6. Current draft (if any) is NOT affected — can discard or keep editing

Visual indicators

┌─────────────────────────────────────────────────────────┐
│ ⚠️ You have unpublished changes (last saved 2 mins ago) │
│                                [Publish] [Discard]      │
└─────────────────────────────────────────────────────────┘

When admin views a page with a draft:

  • Banner at top of editor panel
  • "Unpublished" badge next to page title
  • Different background tint in editor (subtle)

Visitors see published only

The render_page/1 function checks:

  1. If admin is viewing AND has draft → show draft
  2. Otherwise → show published blocks
defp get_blocks_for_render(page, current_user) do
  if current_user && page.draft_user_id == current_user.id && page.draft_blocks do
    page.draft_blocks
  else
    page.blocks
  end
end

Migration path

  1. Deploy with draft support disabled (feature flag)
  2. Run migration to add draft columns
  3. Enable draft mode
  4. Existing pages continue working (no draft = show published)
  5. New edits create drafts

Future considerations

  • Multi-user drafts — currently single draft per page, could extend to per-user drafts
  • Draft preview link — shareable URL that shows draft to non-admins (for review)
  • Scheduled publishing — "Publish at 9am tomorrow"
  • Theme/settings versioning — extend version history to theme and shop settings
  • Bulk rollback — "Revert entire site to yesterday" across all pages

Dependencies

  • Unified editing mode (phases 1-7) must be complete first
  • This work builds on the editor panel UI established there
  • dynamic-url-customisation.md — URLs are versioned data; these plans integrate cleanly
  • Can be implemented in either order, but implementing together gives the cleanest result

Estimates

Phase Est
Phase 0: Site-level publishing 1.5h
Phase 1: Page versions and drafts 7h
Phase 2: Theme drafts 4h
Phase 3: Settings drafts 4h
Phase 4: Version history and rollback 5.5h
Phase 5: Polish 2.5h
Total 24.5h

References