Files
berrypod/docs/plans/draft-publish-workflow.md
jamey 9facfd926e
All checks were successful
deploy / deploy (push) Successful in 43s
expand draft-publish-workflow plan with image lifecycle and versioning
Adds comprehensive documentation for:
- Page versions table schema and draft columns
- Image soft delete with tiered logic (block/warn/allow)
- Trash/recycle bin with restore and permanent delete
- Version retention policy (all/30d, weekly/90d, monthly/365d)
- Storage math and pruning cascade
- GDPR permanent erase option

Updates PROGRESS.md with complete 7-phase breakdown (~31h total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-03 09:20:42 +01:00

23 KiB
Raw Blame History

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: 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
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:

%{"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:

<%= if @image_url do %>
  <img src={@image_url} />
<% else %>
  <div class="image-placeholder">Image unavailable</div>
<% end %>

This only happens with force delete — normal deletion is blocked while references exist.

Soft delete implementation:

# 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:

%{"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:

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
  • 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