From 9facfd926efb0a521782cd170dcfc347ff4f3dcd Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 3 Apr 2026 09:20:42 +0100 Subject: [PATCH] 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 --- PROGRESS.md | 20 +- docs/plans/draft-publish-workflow.md | 336 ++++++++++++++++++++++++++- 2 files changed, 349 insertions(+), 7 deletions(-) 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