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

607 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 %>
<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:**
```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/)