# 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
```elixir
# 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
```
### Page versions (recommended approach)
Rather than draft columns, use a proper version table. Each publish creates a version snapshot.
```elixir
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])
```
```elixir
# 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
```elixir
# 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](dynamic-url-customisation.md).
## 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: Image soft delete and trash
| Task | Est | Notes |
|------|-----|-------|
| Add `deleted_at` column to images table | 15m | Migration |
| Implement `image_usage/1` check | 45m | Check current pages, settings, reviews, old versions |
| Update `delete_image/1` with tiered logic | 30m | Block/warn/allow based on usage |
| Add Trash tab to media library | 1h | List soft-deleted images with restore/delete buttons |
| Filter soft-deleted from main library and picker | 15m | Hidden but still serves |
| Add restore and permanent delete actions | 30m | Trash tab buttons |
| Create `PruneImagesWorker` Oban job | 45m | Hard delete after 30 days |
| Add GDPR "Permanently erase" option | 30m | Rewrites versions, immediate purge |
### Phase 6: Polish and pruning
| 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 |
| Create `PruneVersionsWorker` Oban job | 1.5h | Tiered retention: all/30d, weekly/90d, monthly/365d |
| Add version retention settings | 30m | Admin configurable limits |
| Storage stats on admin dashboard | 45m | Image storage by type, pending purges |
## 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`
```elixir
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
## Image lifecycle and versioning
Page blocks reference images by `image_id` (UUID), not by embedding image data. This design works well with versioning:
### Two types of images
| Type | Source | Versioned? | Delete behaviour |
|------|--------|------------|------------------|
| **Editorial images** | Admin uploads | Referenced by ID in page versions | Soft delete, protected while referenced |
| **Product images** | Synced from Printify/Printful | Not versioned (provider is source of truth) | Independent lifecycle |
### Editorial images (Media.Image)
Used in page blocks (hero images, image_text blocks, etc.). Referenced by `image_id` in block settings:
```elixir
%{"type" => "image_text", "settings" => %{"image_id" => "abc-123", ...}}
```
**Versioning behaviour:**
- Page version snapshots store `image_id` references, not image data
- Rollback restores the reference — image should exist (shows placeholder if deleted)
- Images on CURRENT pages are protected (deletion blocked)
- Images only in OLD versions can be deleted with warning (versions show placeholder)
**Delete scenarios:**
| Scenario | Behaviour |
|----------|-----------|
| Image on CURRENT pages/settings | **Blocked** — "Used in Homepage, About. Remove from pages first." |
| Image only in OLD VERSIONS | **Warning → Trash** — "Not on live pages, but in 3 old versions. They'll show placeholder." |
| Image not used anywhere | **Trash** — silent move to trash |
| GDPR/privacy erase | **Permanent** — rewrites versions, purges cache, immediate |
All "trash" scenarios result in soft delete:
- Image hidden from library and picker
- Image **still serves** via URL (30-day grace period)
- Can be restored from trash
- Auto hard-deleted after 30 days
**Why this model?**
- Current pages are editable — user can remove the image, then delete it
- Old versions are immutable — can't ask user to edit them, so just warn
- Live visitor experience protected — only current page usage blocks deletion
- Trash gives recovery option for all deletions
**What happens when an image is missing?**
If a page references a deleted `image_id`, the renderer returns `nil` and the template shows a placeholder:
```heex
<%= if @image_url do %>
<% else %>