Skip to content

Record deletion: unreachable from the UI, mis-describes its own blast radius, and needs media-aware confirmation #384

Description

@holden

Summary

A signed-in collection owner has no reachable way to delete a record, and the one hidden path that does work describes its own blast radius incorrectly — it implies the price guide entry, artist, and label are destroyed along with the record. They aren't.

Scope: hard delete of records, and the confirmation UX around it.

Core design principle: deletion friction should scale with what is actually destroyed. A record with no media is disposable metadata; a record with photographs and audio represents real, unrecoverable work. The UI should treat those two cases completely differently — and it should show the user which one they're in rather than describing it.

⚠️ Blocked by #385. Deletion is currently hard to reach, which makes the missing dependent: :destroy cascade a latent bug. Making delete easy before that fix lands would manufacture an orphan backlog that doesn't currently exist.


What exists today

1. Admin destroy — the only working path

  • Route: config/routes.rb:12namespace :admin { resources :records, only: [..., :destroy] }
  • Action: app/controllers/admin/records_controller.rb:151 — scoped to user_id: 1, rescues ActiveRecord::InvalidForeignKey, redirects with a notice.
  • UI: app/views/admin/records/show.html.erb:257-277 — a "Danger Zone" <details> block, collapsed by default, at the very bottom of the admin record page.

2. Public destroy — dead code

  • app/controllers/records_controller.rb:125 defines #destroy with require_user!, require_collection_owner!, and set_record
  • …but config/routes.rb:59 is resources :records, except: [:destroy]. There is no DELETE /records/:id route. The action has never been reachable.

3. Attachment-level delete (works, separate concern)

Admin::RecordsController#destroy_attachment (:201) removes individual images/audio via signed blob id, properly scoped. This one is fine — and it becomes the escape hatch in the flow below.

4. Blast radius — the good news

Record belongs_to its price / artist / label / genre / format / discogs_release, so destroying it only removes the join. Verified against the DB:

  • Price guide entry survives. Price has_many :records with no dependent: option.
  • ✅ Artist, Label, Genre, RecordFormat, DiscogsRelease all survive.
  • ✅ Active Storage images/songs are purged (has_many_attached defaults to dependent: :purge_later).

Confirmed by data: 252,228 of 264,914 prices have no record attached at all. The price guide is a reference catalog that mostly doesn't correspond to owned records — further reason it must never be touched by a record delete.


What's broken

A. The feature is effectively invisible

No delete affordance anywhere a normal user works:

  • ❌ Public record show page (app/views/records/show.html.erb) — has an "Edit" button (:72) linking to the admin page, but no delete
  • ❌ Public record edit page — no delete
  • ❌ Records index / grid / list rows — no delete
  • ✅ Admin record show page — but inside a collapsed <details> at the bottom of a long page, reachable only via "Edit" on the public show page and then scrolling to the end

B. The confirmation copy overstates the damage

Current: "Permanently delete this record and all associated data. This cannot be undone."

This reads as though the price guide entry, artist, and label go too. They don't. "and all associated data" is the core sin — it's banned in the copy deck below. Always enumerate what goes and what stays.

C. No tests

grep -rn "destroy" test/ returns nothing. test/controllers/ contains only static_controller_test.rb. Delete has zero coverage.


Measurements

From the dev DB, 20,388 records. Script in the comments; re-run against production before building the UI.

Media coverage — this drives the whole UX

Population Count %
Records with images 10,960 53.8%
Records with audio 4,708 23.1%
Records with images and audio 4,281 21.0%
Records with neither — cheap to delete 9,001 44.1%

⚠️ Correcting an assumption: "most of these won't have images" is not true of the collection as a whole — it's close to a coin flip. A blanket high-friction confirm would fire on the majority of deletes, which is exactly how confirmation dialogs become muscle memory and stop working.

The instinct behind it is still right, though: the records anyone actually wants to delete (junk entries, duplicates, import mistakes) are overwhelmingly the ones nobody bothered to photograph. That's precisely why the friction must be conditional on media rather than uniform — the common case stays cheap, and the expensive case gets loud.

Image-count distribution (records that have images):

Images Records
1 87
2 6,791 ← front/back scan, the dominant pattern
3 68
4 2,560
5 870
6 309
7–16 275

Bytes destroyed per delete (avg, for records that have media): 4.6 MB of images, 6.2 MB of audio.

Catalog linkage — nearly every record points at shared data that must survive:

  • 18,998 records (93.2%) have a price guide entry
  • 16,794 records (82.4%) have a Discogs match

UX design: friction that scales with consequence

Two tiers, chosen by whether the record has any Active Storage media. The thumbnails are the friction — seeing four photographs you took yourself is a stronger deterrent than any warning text, and it self-calibrates: a blank media panel means you click straight through.

Tier A — no media Tier B — has media
Population 9,001 records (44.1%) 11,387 records (55.9%)
Interaction Inline confirm dialog Dedicated confirmation page
Confirmation Click "Delete" Review media, then type DELETE
Clicks to destroy 2 4 + typing
Escape hatch "Remove individual files instead"

Wireframe 1 — Owner actions on the record show page

Replaces the lone "Edit" button at app/views/records/show.html.erb:72.

┌──────────────────────────────────────────────────────────────┐
│  MILES DAVIS                                                 │
│  Kind of Blue · Columbia · LP · 1959                         │
│  ┌────────────┐                                              │
│  │ Near Mint  │                                              │
│  └────────────┘                                              │
│                                                              │
│  [ ✎  Edit ]   [ 🗑  Delete ]         ← collection owner only │
│                  ^^^^^^^^^^                                  │
│                  olive-100 bg, red-700 text on hover.        │
│                  Never red-filled: it sits next to Edit and  │
│                  must not read as the primary action.        │
└──────────────────────────────────────────────────────────────┘

Wireframe 2 — Tier A: no media (44.1% of records)

A standard turbo_confirm. No interstitial page — this is disposable metadata and the flow should feel that way.

┌────────────────────────────────────────────────────┐
│  Delete this record?                               │
│                                                    │
│  Bill Evans — Waltz for Debby                      │
│  Riverside · LP · Very Good                        │
│                                                    │
│  No images or audio are attached, so only the      │
│  catalog entry is removed. The price guide entry,  │
│  artist, and label all stay in the database.       │
│                                                    │
│                    [ Cancel ]  [ Delete record ]   │
└────────────────────────────────────────────────────┘

Wireframe 3 — Tier B: has media (55.9% of records) — the main screen

GET /records/:id/confirm_delete. Full page, not a modal: there's too much to show, and a page is linkable, back-button-safe, and screen-reader friendly.

┌──────────────────────────────────────────────────────────────────────┐
│  ←  Back to record                                                   │
│                                                                      │
│  Delete this record?                                                 │
│  ────────────────────────────────────────────────────────────────    │
│  MILES DAVIS — Kind of Blue                                          │
│  Columbia · LP · 1959 · Near Mint                                    │
│                                                                      │
│  ┏━ PERMANENTLY DESTROYED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓  │
│  ┃                                                                ┃  │
│  ┃  4 images · 5.2 MB                                             ┃  │
│  ┃  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐                   ┃  │
│  ┃  │        │ │        │ │        │ │        │                   ┃  │
│  ┃  │ front  │ │  back  │ │ label  │ │ sleeve │                   ┃  │
│  ┃  │        │ │        │ │        │ │        │                   ┃  │
│  ┃  └────────┘ └────────┘ └────────┘ └────────┘                   ┃  │
│  ┃                                                                ┃  │
│  ┃  2 audio files · 11.4 MB                                       ┃  │
│  ┃  ♪  So What.mp3                                      5.8 MB    ┃  │
│  ┃  ♪  Blue in Green.mp3                                5.6 MB    ┃  │
│  ┃                                                                ┃  │
│  ┃  ⚠  These files are deleted from storage and cannot be         ┃  │
│  ┃     recovered. There is no undo.                               ┃  │
│  ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛  │
│                                                                      │
│  ┌─ KEPT — not affected by this delete ─────────────────────────┐    │
│  │  ✓  Price guide entry   Columbia CL 1355 · $80–$200          │    │
│  │  ✓  Artist              Miles Davis · 312 other records      │    │
│  │  ✓  Label               Columbia · 1,204 other records       │    │
│  │  ✓  Genre               Jazz                                 │    │
│  │  ✓  Discogs release     Kind of Blue (1959)                  │    │
│  └──────────────────────────────────────────────────────────────┘    │
│                                                                      │
│  Type  DELETE  to confirm                                            │
│  ┌──────────────────────────┐                                        │
│  │                          │                                        │
│  └──────────────────────────┘                                        │
│                                                                      │
│  [ Delete record and 6 files ]   [ Cancel ]                          │
│    ^ disabled until input matches                                    │
│                                                                      │
│  ─────────────────────────────────────────────────────────────       │
│  Only wanted to clear out the files?                                 │
│  Remove individual images or audio and keep the record  →            │
└──────────────────────────────────────────────────────────────────────┘

Why each piece is there:

  • Thumbnails, not a count. "4 images will be deleted" is abstract. Four pictures of a record you photographed is not.
  • The KEPT panel is the direct fix for the mis-described-damage problem. The "312 other records" counts make preservation concrete rather than a promise.
  • Typed DELETE is deliberately reserved for Tier B only. Applied to all deletes it would fire on 100% of them and become reflex; at 55.9% it still carries signal, and the 44.1% no-media path stays frictionless.
  • The escape hatch routes to the existing, already-working destroy_attachment flow, which is very often what the user actually wanted.
  • Button label counts the files — "Delete record and 6 files" restates the consequence at the moment of commitment.

Wireframe 4 — Mobile (Tier B)

┌───────────────────────────┐
│ ← Back                    │
│                           │
│ Delete this record?       │
│ MILES DAVIS               │
│ Kind of Blue              │
│ Columbia · LP · Near Mint │
│                           │
│ ┏━ DESTROYED ━━━━━━━━━━━┓ │
│ ┃ 4 images · 5.2 MB     ┃ │
│ ┃ ┌─────┐ ┌─────┐       ┃ │
│ ┃ │     │ │     │  +2   ┃ │
│ ┃ └─────┘ └─────┘       ┃ │
│ ┃                       ┃ │
│ ┃ 2 audio · 11.4 MB     ┃ │
│ ┃ ♪ So What.mp3         ┃ │
│ ┃ ♪ Blue in Green.mp3   ┃ │
│ ┃                       ┃ │
│ ┃ ⚠ Cannot be recovered ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━┛ │
│                           │
│ ┌─ KEPT ────────────────┐ │
│ │ ✓ Price guide entry   │ │
│ │ ✓ Artist · Label      │ │
│ │ ✓ Genre · Discogs     │ │
│ └───────────────────────┘ │
│                           │
│ Type DELETE to confirm    │
│ ┌───────────────────────┐ │
│ └───────────────────────┘ │
│                           │
│ ┌───────────────────────┐ │
│ │ Delete record + 6     │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Cancel                │ │
│ └───────────────────────┘ │
└───────────────────────────┘

Thumbnails cap at 2 visible with a +N overflow chip. Cancel is full-width below Delete so a thumb reaching the bottom of the screen hits Cancel, not Delete.

Wireframe 5 — Result states

SUCCESS (redirect to /records)
┌──────────────────────────────────────────────────────────────┐
│ ✓  Deleted "Miles Davis — Kind of Blue" along with 4 images  │
│    and 2 audio files. The price guide entry was kept.        │
└──────────────────────────────────────────────────────────────┘

SUCCESS, no media
┌──────────────────────────────────────────────────────────────┐
│ ✓  Deleted "Bill Evans — Waltz for Debby".                   │
└──────────────────────────────────────────────────────────────┘

FAILURE (re-render confirm page, nothing removed)
┌──────────────────────────────────────────────────────────────┐
│ ✕  Couldn't delete this record: {reason}.                    │
│    Nothing was removed.                                      │
└──────────────────────────────────────────────────────────────┘

CONFIRMATION MISMATCH (re-render, inline)
┌──────────────────────────────────────────────────────────────┐
│  Type  DELETE  to confirm                                    │
│  ┌──────────────────────────┐                                │
│  │ delete me                │  ← red border                  │
│  └──────────────────────────┘                                │
│  ✕ Type DELETE exactly to confirm.                           │
└──────────────────────────────────────────────────────────────┘

Wireframe 6 — Admin "Danger Zone" replacement

Currently a collapsed <details> (app/views/admin/records/show.html.erb:257). The collapse hides the feature without adding any real safety. Replace with a visible section that routes into the same shared confirmation flow — one code path, one set of copy, one place to test.

┌─ Danger Zone ────────────────────────────────────────────────┐
│                                                              │
│  This record has 4 images and 2 audio files (16.6 MB).       │
│  Deleting removes them permanently. The price guide entry,   │
│  artist, and label are not affected.                         │
│                                                              │
│  [ Delete this record… ]   → /records/:id/confirm_delete     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The trailing ellipsis signals "opens a confirmation step," not "deletes now."

Copy deck

Context String
Show-page button Delete
Tier A dialog title Delete this record?
Tier A body No images or audio are attached, so only the catalog entry is removed. The price guide entry, artist, and label all stay in the database.
Tier B page title Delete this record?
Destroyed panel heading Permanently destroyed
Destroyed warning These files are deleted from storage and cannot be recovered. There is no undo.
Kept panel heading Kept — not affected by this delete
Typed-confirm label Type DELETE to confirm
Typed-confirm error Type DELETE exactly to confirm.
Submit (with media) Delete record and {n} files
Submit (no media) Delete record
Escape hatch Only wanted to clear out the files? Remove individual images or audio and keep the record →
Success (media) Deleted "{title}" along with {i} images and {a} audio files. The price guide entry was kept.
Success (no media) Deleted "{title}".
Failure Couldn't delete this record: {reason}. Nothing was removed.

Banned phrasing: "and all associated data" — the current copy's core sin. It implies the price guide, artist, and label are destroyed. Always enumerate what goes and what stays.

Accessibility

  • Confirmation page gets <h1>Delete this record?</h1>; focus moves to it on load.
  • The typed-confirm input has a real <label>, not placeholder-only.
  • Destroyed/Kept panels distinguished by heading text and icon, never color alone.
  • Thumbnails carry meaningful alt ("Image 1 of 4 attached to Miles Davis — Kind of Blue").
  • The submit button is disabled until the input matches — but the server enforces it too (see below). Disabled state announced via aria-describedby pointing at the instruction.
  • Audio files listed as a real <ul>, filenames as text, not icons alone.

Implementation notes

Routing

resources :records do          # drop `except: [:destroy]`
  member { get :confirm_delete }
end

Tier A never visits confirm_delete — the show-page button posts DELETE directly with a turbo_confirm. Tier B's button is a link_to to confirm_delete. The branch is decided by record.has_media? at render time.

The tier must be re-derived server-side. A record can gain or lose attachments between page render and submit, and the Tier A path is a plain DELETE that a client could aim at a media-bearing record. So #destroy re-checks:

def destroy
  if @record.has_media? && params[:confirm] != "DELETE"
    return redirect_to confirm_delete_record_path(@record),
      alert: "Type DELETE exactly to confirm."
  end
  ...
end

Without this, the friction is decorative.

Model helpers

def has_media?   = images.attached? || songs.attached?
def media_counts = { images: images.count, songs: songs.count }
def media_bytes  = images.sum { _1.byte_size } + songs.sum { _1.byte_size }

Eager-load with .with_attached_images.with_attached_songs on the confirm page or the byte sums will N+1.

Edge cases

  • Missing S3 blobs (Recovery: ~200 images missing from S3 due to failed Refile migration (Jan 4 2026) #346). ~200 images are known missing from S3. The confirm page must render a placeholder tile for a broken thumbnail rather than 500 — and arguably should say so ("1 image is already missing from storage"), since that's useful information at exactly this moment.
  • Record already deleted (double submit, stale tab) → RecordNotFound → redirect to /records with "That record no longer exists." rather than a 404 page.
  • Zero-attachment record that reports media. images.attached? is true if an attachment row exists even when the blob is gone; count from attachments, size defensively with &.byte_size.to_i.
  • Very large media sets. Max observed is 16 attached images. Cap thumbnails at 12 with a +N chip.
  • Purge is async. purge_later means files disappear shortly after, not instantly. The success copy says "deleted" — accurate from the user's view; don't promise byte-level immediacy.

Delivery: 2 sessions

Both are independently mergeable and leave the app in a working state. Stopping after Session 1 is a valid outcome — the feature is fully usable, just plain on the Tier B path.

#385 must land first. It's a two-line model change plus a migration, and shipping a reachable delete button ahead of it creates orphaned rows on ~54% of deletes.


Session 1 — Delete works end to end

Correct and complete, deliberately plain. Every path functions; none of it is pretty yet.

  • config/routes.rb:59resources :records with a confirm_delete member route
  • Record#has_media?, #media_counts, #media_bytes
  • Server-side tier re-derivation and typed-confirmation check in #destroy
  • Delete button on the record show page, owner-gated, beside Edit (Wireframe 1)
  • Tier A: turbo_confirm dialog with the no-media copy (Wireframe 2)
  • Tier B: confirm_delete page — functional skeleton only. Text counts ("4 images, 2 audio files, 16.6 MB"), the KEPT list, typed DELETE. No thumbnails, no mobile work, no visual design.
  • Success / failure flashes from the copy deck (Wireframe 5)
  • Decide: keep Admin::RecordsController#destroy or consolidate onto the public path
  • Tests: Tier A deletes in one request; Tier B refuses without confirm=DELETE; Tier B succeeds with it; signed-out visitor redirected; attachments purged; deleting a record does not delete its Price

Done when: every record in the collection is deletable through a correct, owner-gated flow, and the friction is real rather than cosmetic.

Risk: medium. The server-side enforcement is the piece that's easy to get subtly wrong; treat those three tests as the acceptance gate.


Session 2 — The confirmation experience

Turns the Session 1 skeleton into Wireframe 3. Pure UI; no behavioral change.

  • Destroyed panel: image thumbnails (cap 12, +N chip), audio list with per-file sizes, byte totals
  • KEPT panel with "N other records" counts for artist and label
  • Escape hatch linking to the existing destroy_attachment flow
  • Mobile layout (Wireframe 4) — +N overflow, Cancel below Delete
  • Broken-thumbnail placeholder for Recovery: ~200 images missing from S3 due to failed Refile migration (Jan 4 2026) #346 blobs, plus the "already missing from storage" note
  • Replace the collapsed admin Danger Zone <details> with the visible panel (Wireframe 6), routed into the same flow
  • Accessibility pass: <h1> focus, real <label>, icon+text not colour alone, meaningful thumbnail alt
  • Test: confirm page renders when a blob is missing from S3

Done when: the confirmation page matches Wireframe 3 on desktop and Wireframe 4 on mobile.

Risk: low. Presentation only, on top of behavior already covered by tests.


Compresses to 1 if Tier B's visual work rides along with Session 1 — viable, but it makes one large session where correctness work and design work compete for attention, and correctness is the part that must not be rushed.


Known gap, not fixed here

User#admin? (app/models/user.rb:27) is hardcoded return true. Every signed-in user is an admin and can delete any record. RecordsController has the right idea with require_collection_owner! (current_user.id == COLLECTION_USER_ID), but Admin::BaseController only checks current_user&.admin? — always true. Mitigated in practice by there being no self-registration route, but delete shouldn't rely on that.

gem "pundit", "~> 2.3" is in the Gemfile and completely unused — no app/policies, no include Pundit, no authorize calls. The tool for doing this properly is already installed. Worth its own issue; it affects far more than delete.

Out of scope

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions