# 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 %>
Image unavailable
<% end %> ``` This only happens with force delete — normal deletion is blocked while references exist. **Soft delete implementation:** ```elixir # Migration alter table(:images) do add :deleted_at, :utc_datetime_usec end # Context def delete_image(image) do if image_in_use?(image.id) do {:error, :in_use, usage_description(image.id)} else image |> change(%{deleted_at: DateTime.utc_now()}) |> Repo.update() end end def image_usage(image_id) do # Returns structured usage info for delete decision %{ # CURRENT usage (blocks deletion) current_pages: pages_using_image(image_id), # ["Homepage", "About"] current_settings: settings_using_image(image_id), # ["logo", "header"] product_images: product_images_using(image_id), # count reviews: reviews_using_image(image_id), # count # HISTORICAL usage (warning only) old_versions: versions_using_image(image_id) # count } end def can_delete_image?(image_id) do usage = image_usage(image_id) cond do usage.current_pages != [] -> {:blocked, "Used in #{Enum.join(usage.current_pages, ", ")}. Edit those pages first."} usage.current_settings != [] -> {:blocked, "Used as #{Enum.join(usage.current_settings, ", ")}. Change in settings first."} usage.product_images > 0 -> {:blocked, "Linked to #{usage.product_images} product image(s)."} usage.reviews > 0 -> {:blocked, "Used in #{usage.reviews} review(s)."} usage.old_versions > 0 -> {:warn, "Appears in #{usage.old_versions} old version(s). They'll show placeholder."} true -> :ok end end # Periodic cleanup workers def find_orphaned_images() do # Find images not referenced anywhere AND not soft-deleted # These accumulate when versions are pruned Image |> where([i], is_nil(i.deleted_at)) |> Repo.all() |> Enum.reject(&image_in_use?(&1.id)) end def purge_soft_deleted_images() do # Hard delete images that were soft-deleted > 30 days ago Image |> where([i], not is_nil(i.deleted_at)) |> where([i], i.deleted_at < ago(30, "day")) |> Repo.all() |> Enum.each(&hard_delete_image/1) end ``` **Media library delete UX:** ``` User clicks "Delete" on image │ ├── Image on CURRENT pages/settings? │ └── BLOCKED: "This image is used in Homepage and About page. │ Edit those pages to use a different image, then delete." │ ├── Image only in OLD VERSIONS (not current)? │ └── WARNING: "This image isn't on any live pages, but appears in │ 3 old page versions. Those versions will show │ 'Image removed' if you roll back to them." │ [Move to trash] [Cancel] │ └── Image not used anywhere └── Move to trash (soft delete) ``` **Trash/recycle bin:** Media library has two tabs: `[All images] [Trash (3)]` Trash tab shows: - Soft-deleted images with thumbnails - "Deleted 5 days ago — auto-deletes in 25 days" - [Restore] button — sets `deleted_at` to nil, back in library - [Delete permanently] button — hard delete, URL returns 404 **Soft delete behaviour:** - Image hidden from main library and image picker - Image **still serves** via `/image_cache/:id` (grace period) - Auto hard-deleted after 30 days by `PruneImagesWorker` **Key UX principles:** 1. **Can't break live pages** — deletion blocked while image is on current pages 2. **Old versions are read-only** — can't edit them, so deletion allowed with warning 3. **Familiar trash pattern** — easy recovery, clear timeline 4. **URLs work during grace period** — soft-deleted images still serve **For GDPR/privacy erasure:** Add "Permanently erase" option (separate from normal delete): - Removes image from ALL versions (rewrites history) - Purges from image cache immediately - No 30-day grace period - Requires confirmation: "This cannot be undone and will affect 3 page versions" This handles the case where someone uploaded something they shouldn't have and needs it truly gone. ### Product images (synced from providers) Current flow: 1. `ProductSyncWorker` syncs products from Printify/Printful 2. `ProductImage` created with `src` (CDN URL), `image_id` = nil 3. `ImageDownloadWorker` downloads from CDN → creates `Media.Image` → links via `image_id` 4. `ProductImage.url/2` prefers local `image_id`, falls back to CDN `src` **Cleanup on sync:** When a product image URL changes, the old `Media.Image` is **hard deleted** immediately. This is correct because: - Provider is source of truth — their update is canonical - CDN URLs are the key, not local image IDs - Re-downloading from the same URL would create a fresh image anyway - No user-facing "undo" exists for product image changes **Why product images aren't versioned:** - Provider controls the lifecycle, not you - Product data can change independently of page edits - Syncs can happen at any time (manual or webhook-triggered) **Product references in page blocks:** Product blocks store `product_id`, not product data: ```elixir %{"type" => "featured_product", "settings" => %{"product_id" => "prod-123"}} ``` Rollback restores the product reference. The CURRENT product data is displayed (not a snapshot). If the product was deleted, show "Product no longer available" gracefully. ### Version diff and images When showing what changed between versions, detect image changes: ```elixir def diff_block_images(old_block, new_block) do old_image = get_in(old_block, ["settings", "image_id"]) new_image = get_in(new_block, ["settings", "image_id"]) cond do old_image == new_image -> nil is_nil(old_image) -> %{type: :image_added, image_id: new_image} is_nil(new_image) -> %{type: :image_removed, image_id: old_image} true -> %{type: :image_changed, old: old_image, new: new_image} end end ``` In the diff UI, show thumbnail previews of old/new images for visual comparison. ### Version retention and storage **Version retention policy:** | Time period | Retention | Example | |-------------|-----------|---------| | Last 30 days | All versions | 15 versions if publishing twice daily | | 31-90 days | 1 per week | ~8 versions | | 91-365 days | 1 per month | ~9 versions | | Older | Deleted | — | Maximum versions per page: ~32 (assuming active editing). Configurable via settings. **Storage math for page versions:** Page version snapshots are lightweight — just JSON with block definitions and UUIDs: ``` Typical page JSON: 5-20 KB 32 versions × 20 KB = 640 KB per page 50 pages × 640 KB = 32 MB total version storage ``` This is negligible compared to image storage. **Storage math for editorial images:** Images are the real storage concern. Key insight: **images are stored once, referenced many times**. ``` Typical editorial image: 500 KB - 2 MB source + variants Average: ~1.5 MB per image (including WebP/AVIF variants) Scenario A: Page uses same image across 10 versions Storage: 1.5 MB (image stored once, referenced 10 times) Scenario B: Page changes image every version, 10 versions Storage: 15 MB (10 different images, each stored once) ``` The soft-delete protection means old images stick around until: 1. No page version references them, AND 2. 30-day grace period passes **Worst case:** User uploads new hero image every publish for a year - 52 weeks × 1.5 MB = 78 MB for one page's hero images - After version pruning kicks in: ~32 versions × 1.5 MB = 48 MB **Typical case:** Hero image changes 2-3 times per year - 3 images × 1.5 MB = 4.5 MB **Pruning cascades to images:** When `PruneVersionsWorker` deletes old versions: 1. Version record deleted (e.g., version 5 from 8 months ago) 2. Image X was only referenced by version 5 3. `image_in_use?("X")` now returns `false` 4. Image X is now **orphaned** — not soft-deleted, just unreferenced 5. `PruneImagesWorker` finds orphaned images and soft-deletes them 6. After 30-day grace period, soft-deleted images are hard deleted **Key distinction:** - Images referenced by ANY existing version are **protected** (cannot be deleted) - Images become orphaned only when ALL referencing versions are pruned - The 30-day grace period is for recovering from accidental deletion, not for version retention This means image storage naturally follows version retention — if you keep 32 versions max, you keep at most the images referenced by those 32 versions. **Admin visibility:** Add storage stats to admin dashboard: - Total image storage (MB) - Images by type (editorial, product, review) - Soft-deleted images pending purge - "Clean up now" button for force purge ## 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 - **Image version history** — track when images were replaced (not just which version used them) ## Dependencies - Unified editing mode (phases 1-7) must be complete first - This work builds on the editor panel UI established there ## Related Plans - [dynamic-url-customisation.md](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: Image soft delete and trash | 4.5h | | Phase 6: Polish and pruning | 4.25h | | **Total** | **30.75h** | ## References - [Squarespace draft model](https://support.squarespace.com/hc/en-us/articles/205815578-Saving-and-publishing-site-changes) - [Shopify theme versions](https://shopify.dev/docs/themes/architecture#theme-versions) - [WordPress autosave](https://developer.wordpress.org/plugins/post/autosave/)