diff --git a/PROGRESS.md b/PROGRESS.md
index a787ef0..35801ed 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -199,14 +199,28 @@ Comprehensive SEO tooling to rival Yoast/RankMath. Per-page SEO controls, enhanc
### Draft-then-publish workflow ([plan](docs/plans/draft-publish-workflow.md))
-Auto-save drafts, explicit publish. No more lost work or `beforeunload` warnings. Visitors always see published version.
+Auto-save drafts, explicit publish, version history with rollback. No more lost work or `beforeunload` warnings. Visitors always see published version. Includes image soft delete with trash/recycle bin pattern.
| Phase | Description | Depends on | Est | Status |
|-------|-------------|------------|-----|--------|
-| 1 | Page drafts (auto-save, publish/discard) | unified-editing-mode | 5h | planned |
+| 0 | Site-level publishing (coming soon until first publish) | unified-editing-mode | 1.5h | planned |
+| 1 | Page versions and drafts (auto-save, publish/discard, version table) | Phase 0 | 7h | planned |
| 2 | Theme drafts | Phase 1 | 4h | planned |
| 3 | Settings drafts | Phase 2 | 4h | planned |
-| 4 | Polish (age indicator, conflict handling) | Phase 3 | 5h | planned |
+| 4 | Version history and rollback (history panel, diff view, URL redirect chain) | Phase 3 | 5.5h | planned |
+| 5 | Image soft delete and trash (usage check, tiered delete, restore, auto-purge) | Phase 4 | 4.5h | planned |
+| 6 | Polish and pruning (draft age, conflict handling, version retention worker) | Phase 5 | 4.25h | planned |
+
+**Total: ~31h**
+
+Key design decisions:
+- Page versions stored in separate `page_versions` table (not draft columns)
+- Editorial images soft-deleted with 30-day grace period, protected while on current pages
+- Product images (from Printify/Printful) hard-deleted on sync — provider is source of truth
+- Images only in old versions can be deleted with warning (versions show placeholder)
+- Trash tab in media library with restore/permanent delete
+- Tiered version retention: all/30d, weekly/90d, monthly/365d, ~32 max versions
+- GDPR "permanently erase" option rewrites history
### Platform site
diff --git a/docs/plans/draft-publish-workflow.md b/docs/plans/draft-publish-workflow.md
index 50093e6..d105021 100644
--- a/docs/plans/draft-publish-workflow.md
+++ b/docs/plans/draft-publish-workflow.md
@@ -166,13 +166,28 @@ This ties directly into the [dynamic URL customisation plan](dynamic-url-customi
| 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
+### 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 |
-| Version pruning (keep last N versions) | 1h | Prevent unbounded growth |
+| 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
@@ -241,6 +256,317 @@ end
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
@@ -248,6 +574,7 @@ end
- **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
@@ -268,8 +595,9 @@ end
| 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** |
+| Phase 5: Image soft delete and trash | 4.5h |
+| Phase 6: Polish and pruning | 4.25h |
+| **Total** | **30.75h** |
## References