Status (2026-10-13): ALL BUILT AND LIVE — phases A–H of the approved plan, shipped phase by phase (§7 has versions, rollbacks and what changed from this design). Founder asked for (1) the Library's feature gaps, thought through as a product designer, and (2) whether uploading a file that's already in Media can warn "this file already exists" — in every service, with the other cases that come with it. Owner: founder decided §5 on 2026-10-12 (scope: everything incl. "later"; storage: meter only; duplicate rules as recommended; ship each phase); build session implemented. Facts as of 2026-10-12, read from
apps/media-service,packages/core-media,apps/seller-dashboard/src/modules/media,components/media/*and every upload surface in the dashboard.
1. Short answer: can we warn "this file already exists"?
Yes. Same mechanism Google Drive, Dropbox and iCloud use: a fingerprint of the file's contents (SHA-256). Two files with the same fingerprint are the same file, byte for byte, whatever they're called.
- The browser computes the fingerprint of the picked file before uploading anything (Web Crypto, about 50 ms for a 10 MB file; the file never leaves the device for the check).
- It asks media "do I already have any of these?" and media answers only with files the person could already open — so the check can't be used to discover a colleague's Only-me file.
- If there's a match, a small dialog shows the existing file (preview, name, who added it, when) and lets the person use it, upload anyway, or cancel.
What it can catch reliably: the exact same file again — same photo, same PDF, same video, under any name, from any service. What it can't catch with a fingerprint: the same picture saved differently (resized, re-compressed, a screenshot of it, a cropped version). That needs a "visual fingerprint" (perceptual hash) and gives "looks similar" hints with false positives. Possible later for images only (§4, later).
Why it isn't there today: the browser uploads straight to storage (presigned PUT), so media never sees the bytes, and no fingerprint column exists (media_files has no hash/checksum). Both are additive to fix.
2. How duplicate detection works
2.1 Fingerprints
| Column (new, nullable) | What | Who computes it | Trusted for |
|---|---|---|---|
content_sha256 |
fingerprint of the stored bytes | media, server-side: at /upload/complete it reads the object back (≤ 10 MB) and hashes it; ingest-url and /internal/upload already hold the bytes |
matching anyone's file |
source_sha256 |
fingerprint of the original file the person picked, before the image editor re-encoded it | the browser, sent with presign | matching the uploader's own files only |
Why two: the image editor re-encodes images (image-editor/lib/canvas.ts:283), so the stored bytes of a cropped/compressed photo never equal the original. Without source_sha256, re-picking the same photo in the Library would never match.
Why source_sha256 only matches your own files: media can't verify it (it never sees the original). If it matched everyone's files, a member could upload an altered image (a swapped payment QR code, an edited invoice scan) while claiming the fingerprint of the real one; a colleague uploading the real one would be offered the altered one. Trusting your own claim about your own files is harmless.
Partial unique index is not wanted (duplicates must stay allowed — "Upload anyway"). Index: (tenant_id, content_sha256) and (tenant_id, uploaded_by, source_sha256), both WHERE status IN ('uploaded','archived').
Existing files get content_sha256 from a one-off janitor job (read bytes → hash → store), batched; today that's ~26 rows.
2.2 The check
POST /files/match { fingerprints: [sha256, …] } (≤ 50) → for each fingerprint, the best match the caller can read:
- same workspace;
statusuploaded(orarchived, reported as such);listable; neverdelegated(form and mail files are service-managed) and neverpending_upload; - passes
canReadMedia(so a colleague's Only-me file is never reported); - matches
content_sha256, orsource_sha256whenuploaded_by= caller.
Returns { id, fileName, mimeType, fileSize, visibility, serviceCode, uploadedBy (you / a member), createdAt, status, previewUrl }.
The check is a hint, never a gate: if it fails, times out (2 s) or the server is old, the upload just proceeds.
2.3 When it runs, per surface
| Surface | Check on | "Use this one" means | Notes |
|---|---|---|---|
| Library upload (free-mode editor) | the original, before the editor opens | open the existing file's detail panel (and highlight it) | warn before anyone spends time cropping |
| Overview Quick Upload (no editor) | the file | same | |
Blog cover / author avatar / newsletter images (preset crop via MediaDrawer) |
the final bytes, after the editor | return the existing file's URL instead of uploading | a crop to a preset is a new image; only an identical result is a duplicate |
| Blog body paste/drop, carousel | the file | return the existing URL | |
Social composer (use-media-upload.ts) |
the file | attach the existing mediaId | composer stores mediaIds, so reuse is clean |
Blog markdown import (ingest-url) |
server-side, after fetch | silently reuse an identical public blog file | no dialog in a bulk import |
| Mailbox attachments, form uploads | never | — | per-message / per-submission lifecycle; mailbox releases its copy after send |
| Workspace logo, profile avatar | never | — | single slot, replaced often |
| Collections entries | not in v1 | — | separate service path (/internal/upload) |
Services that need a public link (blog, newsletter, social, brand logo) only consider public matches. A Workspace or Only-me match there is ignored and the upload proceeds — no dialog that ends in "you can't use it".
Choke points: browser uploads all pass through presignUpload (modules/media/api/media.api.ts:223) — via uploadFile and the composer's own hook. Put the check in one helper (findExisting(files)) called by uploadFile's callers' shared hooks (useMediaUploader, useMediaLibrary, use-media-upload, upload-image.ts), not inside uploadFile itself, because the dialog and "use this one" differ per surface.
2.4 The dialog (minimal UI)
One file:
Already in your library
[preview] logo-final.png · 240 KB
Added by you · 3 Oct · Workspace
[Upload anyway] [Use this one]- In the Library, the primary button reads Show it.
- Added by someone else: "Added by a member" (same wording as the detail panel's owner field).
- Esc / ✕ cancels the upload.
Several files (one dialog, not one per file):
3 of 8 files are already in your library
[thumb] logo-final.png Skip ▾
[thumb] team.jpg Skip ▾
[thumb] brochure.pdf Skip ▾ (Skip / Upload anyway)
[Upload 5 new files]2.5 Other cases and what happens
| Case | Behaviour |
|---|---|
| Same file twice in one selection | uploaded once, silently |
| Same file dropped again right after uploading it | matched (it's in the library now) → dialog |
| Match is in the archive | "It's in your archive" → Restore it (if you can manage it) / Upload anyway |
| Match is a colleague's Only me file | not reported; upload proceeds as normal (no leak) |
| Match is from another service (a Blog image found from the Library) | shown with "Added by Blog"; Show it opens it |
| Match has a different visibility than you picked (you chose Public, it's Workspace) | dialog shows its visibility; Show it lets you switch it (G24 rules) — never changed automatically |
| Same name, different content | no warning — names aren't unique (image.png, Screenshot…); a prompt here would be noise. Replacing a file is an explicit action (§4 Replace) |
| Two people upload the same file at the same moment | both succeed; rare, harmless |
| Check fails / slow / offline | upload proceeds; no dialog |
| File larger than the limit | rejected before fingerprinting, with the limit stated (see B3) |
Abandoned upload (pending_upload) |
ignored |
| File permanently deleted, then re-uploaded | no match; normal upload |
2.6 Not proposed: storage-level dedupe
Making two rows share one stored object would save storage silently, but every delete, visibility move and archive would have to reference-count shared bytes. R2 storage is cheap and the cap is 10 MB per file; the person-facing prompt is the value. Revisit only if storage cost becomes real (and quota G2 is enforced).
3. Gaps that are broken today (fix first)
| ID | What's wrong | Where | Fix |
|---|---|---|---|
| B1 | Type filter only filters the page already loaded. "Images" on a page of 20 mixed files shows whatever images happen to be in those 20; "Load more" can add nothing. | useMediaLibrary.ts:108-117; server has no type filter (files.ts:103-187) |
server-side `type=image |
| B2 | Grid/List toggle does nothing — viewMode never reaches MediaGrid. |
MediaFiles.tsx:206,213 |
build the list view (name, type, size, visibility, added, by) or remove the toggle |
| B3 | Size limit mismatch: dashboard accepts up to 50 MB, server caps at 10 MB. Images are compressed by the editor, but videos, PDFs and the Overview Quick Upload (no editor) fail after the upload starts with a server error. | MediaUploadZone.tsx:27; upload.ts:51 |
reject over 10 MB up front: "Max 10 MB" |
| B4 | Search treats % and _ as wildcards ("50%" matches everything). |
files.ts:149 |
escape them |
| B5 | "Docs" means PDF only, in the filter and in stats. | useMediaLibrary.ts:115; stats files.ts:251-311 |
define documents once (pdf today; more when more types are allowed) |
| B6 | Permanent delete doesn't know where a file is used: deleting a blog post's image breaks the published post (404) with no warning. The API also allows permanent delete of a file that isn't archived (the UI only offers it in Archive). | files.ts:469-487, :405 |
Where used (§4); API: permanent delete only from archive |
| B7 | Upload progress is fake (fixed 10 → 30 → 80 → 100); a slow 10 MB upload sits at 30 %. | media.api.ts:262-323 |
real progress via XHR (mailbox's AttachmentTray already does it) |
| B8 | Editors' media pickers show only that service's own public files, first 20, no search, no paging; Library files never appear (= G26). | MediaPicker.tsx:34 |
search + paging + "All public files" |
| B9 | MediaDrawer says "post" even in Newsletter. |
MediaDrawer.tsx:29 |
neutral copy |
| B10 | Dead code: FeaturedImagePicker.tsx, slot-builder-modal.tsx (never imported). |
delete | |
| B11 | Stale comment says fileSize is never reconciled; /complete now overwrites it with the real size. |
content.ts:96-98 |
fix comment |
4. Missing features, prioritised
P1 — core library hygiene
| Feature | Design notes |
|---|---|
| Rename | Detail panel: click the name to edit. Only the name before the extension is editable; the extension is shown, locked (the server derives the served type from it — renaming photo.png to photo.html must be impossible). PATCH /files/:id { fileName }, 1–200 chars, no / \ or control characters, same extension or 400. Who: same as archive (uploader, or media:files.admin); never delegated files. The link doesn't change (the stored key has no name in it); downloads use the new name. Fixes imported.png names from the blog importer. |
| Multi-file upload + queue | multiple on the input; drop many; a small queue under the upload zone (per-file progress, cancel, retry, done ✓). Files that need the editor go through it one at a time; "Skip editing" for the rest. Mailbox's AttachmentTray is the pattern. |
| Duplicate check | §2. |
| Sort | Newest (default), Oldest, Name A–Z, Largest. Server-side; cursor pagination must follow the sort. |
| Where used | Detail panel: "Used in: Blog post 'Launch notes' · Newsletter 'October'". Warns on archive, permanent delete and Public → Workspace. Needs each service to report its references to media (blog stores URLs inside post HTML; content-engine, collections, forms and mailbox store mediaIds). Biggest item; it also makes "Use this one" safe at scale, because shared files become visible. |
P2
| Feature | Design notes |
|---|---|
| Expiring share link | "Share a link…" on Workspace/Only-me files: a link that works for 1 day / 7 days, no sign-in. Covers most "make it public to send it to a client" moments without making the file public forever. Uses the existing presigned-link machinery (7-day maximum). |
| Replace file | Detail panel: upload a new version under the same file. References by id (social, collections) follow automatically. Public links are cached as immutable for a year, so the public link changes on replace; with Where used we can say which posts still point at the old one. |
| Alt text / description | One field in the detail panel; prefilled into blog/newsletter/social when the file is picked (social composer already keeps alt text per slot). |
| Dimensions | Store width × height at upload (browser already decodes images); show in card tooltip/detail; pickers can say "too small for a banner". |
| Paste to upload | ⌘V an image anywhere on the Library page. |
| Better preview | PDF preview (first page / browser viewer), zoom on images. |
| Storage meter + quota | "1.2 GB of 5 GB" on Overview; enforce the plan's storage_mb (G2). |
Later
| Feature | Design notes |
|---|---|
| Folders or tags | With search, sort and filters, a library of hundreds works without them. When needed, prefer tags (a file in two places, fits "minimal UI") over nested folders. |
| Favorites | Probably not needed once search + sort exist. |
| Bulk visibility, bulk download (zip) | G25. |
| "Looks similar" | Visual fingerprint for images; hint only. |
| Activity record ("Priya made this public") | G27, with the gateway audit log. |
| Keyboard shortcuts | / search, u upload, arrows to move, Enter to open. |
5. Decisions for the founder
- Same name, different file → no warning (recommended), or warn "a file with this name exists"?
source_sha256(pre-editor fingerprint) matches only your own uploads (recommended, safe), or everyone's (catches more, but a member could plant an altered image under a real one's fingerprint)?- In editors' pickers, ignore non-public matches (recommended) — or offer "Make it public and use it" (changes a Library file for everyone)?
- Order: B1–B11 fixes → Rename + multi-upload + sort → duplicate check (Library first, then every uploader) → Where used → P2. Recommended; Where used could move earlier if "Use this one" in editors ships first.
6. Build sketch for the duplicate check (when approved)
- Core migration:
content_sha256,source_sha256+ two partial indexes (additive;manual-migrations/+packages/core-database/scripts/like the storage migration). Run before the media deploy. - Media: hash at
/complete(read back),ingest-url,/internal/upload; presign acceptssourceSha256(64 hex chars or ignored);POST /files/match; janitor job backfillscontent_sha256. - Dashboard:
lib/fingerprint.ts(hook-free, Web Crypto),findExisting(),DuplicateDialog(single + batch), wired into Library, Overview,useMediaUploader,upload-image.ts,use-media-upload.ts. - Tests: match only readable files (Only-me not reported, other workspace not reported, delegated never),
source_sha256only for own files, archived reported as archived, check failure never blocks upload, batch with internal duplicates.
7. Rollout record (2026-10-12/13)
Plan: ~/.claude/plans/can-we-start-planning-misty-kernighan.md. Branch media/library-v2, fast-forwarded to main phase by phase. Founder decisions: everything including "later"; storage meter only (no enforcement); duplicate rules as recommended; ship each phase when verified.
| Phase | What | Commits | Deployed (rollback) |
|---|---|---|---|
| A | B1–B11 fixes, sort + keyset paging, list view, real upload progress, named downloads | dc8ec625, 9cc4e76d, 7a21898c (review fixes) | media fc648dda (5b619234) |
| B | One migration for every phase; rename, alt text, dimensions | 93d783e9, a0d550a3 | 2026-10-13-media-library-v2.sql applied 2026-10-12; media fc648dda |
| C | Upload queue (many files), paste, / and u, PDF + zoom previews |
b84141f2 | dashboard only |
| D | "Already in your library" everywhere; fingerprints; picker upgrade | f182b0e9, 6bc8a3f9 | media 5b8c7e14 (fc648dda) |
| E | Expiring share link; replace with version kept | f0fc94a0, 8f65a89a | media 0343d3e4 (5b8c7e14) |
| F | Where used (gateway fan-out + a read-only route per service) | 6f242634, 037d8a61 | media 7132e8e5 (0343d3e4), blog e643c290 (b255f862), newsletter 95f1ab31 (14a39a56), content-engine ead45cc8 (01f4e334), collections 1ac1e435 (7a8cdb49), gateway aa6692e2 (4b1338a4) |
| G | Storage meter (manager current from media_files) |
a6ac8b97 | manager ff2fa244 (b187f760) |
| H | Bulk visibility, zip download, tags, stars, "looks similar" | ce709c9a, 24273db6 | media 648b98f5 (7132e8e5) |
| Review | Independent review of B–H, all findings fixed (below) | 056ef513, b14b0d93 | media f3674b40 (648b98f5), collections b023fc8c (1ac1e435), gateway de977c03 (aa6692e2) |
Dashboard: Vercel logicspike-office redeploys on each push (all READY). Every media rollback above is schema-safe: the migration is additive.
In-app checks by the founder, 2026-10-14: passed — upload several files at once, "Already in your library", rename + download under the new name, 7-day share link in a private window, "Used in" on a blog cover, Replace with the previous version in the Archive, tags and stars filters, storage meter, bulk Visibility and zip Download. All checks passed.
Where the build differs from this design
- Blog paste/drop/carousel and the social composer reuse an identical public file silently (own service only) instead of showing a dialog — a dialog mid-typing was noise, and only the same service's files are reused so their visibility can't later change under the post. The Library and the editors' picker show the dialog.
- Replace keeps the previous version (keys swapped, old version archived as "
(previous).ext") instead of deleting the old bytes: old public links keep working and restoring undoes it. - Communication was not deployed:
maincarries its undeployed "DEPLOY-AFTER-SOAK" fail-closed guard. The brand logo for "Used in" is read through communication's existingGET /v1/sender-settings. - The zip download uses a small store-only zip writer (
lib/zip.ts) rather than a dependency. - Existing files get
content_sha256from the janitor's 6-hourly run (backfillFingerprints, 200 per run);/internal/janitor/runneeds the internal token, which this session doesn't have.
Review of B–H (2026-10-13), all fixed
- A visibility move racing a Replace could leave the file pointing at bytes in the wrong bucket and delete the previous version's bytes: the move now also guards on the key, so whichever lands second is refused.
- "Used in" said "Not used anywhere" for a file made private after being public (its key and old embeds stay): usage-terms now returns the key for every non-delegated file; the gateway answers 404 to browsers on that route, so the key stays server-side.
- An admin's Replace made the admin own the "(previous)" version: it now stays the file owner's.
- Collections "Used in" counted deleted entries and collections.
- A second duplicate prompt overwrote the first, leaving its batch stuck in "checking": prompts now queue. Closing a "looks similar" prompt now uploads (it's a different file); an exact duplicate is still skipped.
- "Used in" was cached for the whole session, so a use added since could be missed: it's kept 30 s, and archive/delete forever/making private always ask fresh.
- Leaving the page didn't stop queued uploads: nothing new starts after unmount.
- A failed Replace left its uploaded version as a stray file (now removed); a refused unstar in the Starred view lost the file from the list (now put back); the storage meter misread unlimited plans (-1); zip names could collide with a file already named "a (2).png".
Gap log (found, not fixed in this round)
| ID | Gap | Why not now | Owner |
|---|---|---|---|
| L1 | "Used in" is single-file; bulk archive/delete keep the generic warning | N fan-outs per bulk action | build |
| L2 | Usage searches are strpos scans over a workspace's posts/campaigns/entries (no trigram index) — fine at today's sizes |
Add pg_trgm indexes if a workspace grows large |
build |
| L3 | Collection entries have no page of their own; "Used in" links to the collection | Collections UI | build |
| L4 | Share links can't be revoked before they expire (presigned R2 links; = access-audit G10) | Inherent; rotate the R2 key to kill all | founder (accepted) |
| L5 | A presigned upload link stays valid for its hour after /complete; re-PUTting other bytes of the same size would leave content_sha256 stale. Only misleads duplicate hints (the dialog always shows the existing file's preview) |
Fix: sign x-amz-checksum-sha256 at presign (needs bucket CORS for that header) |
build |
| L6 | Files uploaded before 2026-10-13 have no dHash ("looks similar" can't match them); the worker can't decode images to backfill | Only new uploads | build (accepted) |
| L7 | Storage is one workspace total; blog.storage_mb isn't split out |
Product decision | founder |
| L8 | No storage enforcement (= access-audit G2) | Founder's choice 2026-10-12 | founder |
| L9 | No in-app activity record ("who made this public / renamed / replaced") — only log lines (= access-audit G27) | Waits on the gateway audit log | build |
| L10 | Dashboard tests notification-bell useAttention and FormShell read-only save are timing-flaky under the full suite (pass alone) |
Not media | build |
| L11 | ingest-url reuse doesn't exclude service = "media" (hardening only; only the blog importer calls it, always with blog) |
No caller passes media |
build |
| L12 | The picker now lists the workspace's public Library images; their uploader can later make one non-public and break the post that picked it | "Used in" names the post before the change is confirmed | founder: keep (decided 2026-10-14) |