feat(admin): remove an image's background from the inventory item editor #293

Closed
opened 2026-09-04 09:11:23 -05:00 by bermudalamb · 2 comments
Owner

Background removal exists in exactly two places today, both delivered by #281:

  • The submission page (/submit/:token) — a checkbox, ticked by default, recording the submitter's intent for the drafting worker to act on.
  • The review queue (Admin | Review queue) — a per-photo Remove background / Restore original button on each draft card.

It does not exist where an admin actually edits an item's photos. Admin.tsx:314-326 renders "Existing Images (front / back / etc.)" for the item being edited, and each thumbnail carries one control: a red delete button. There is no way to remove a background there, and no way to restore one.

That gap is not an oversight in the implementation — #281's design put it out of scope in as many words: "The control lives on the review queue only. Applying it to already-published items is a separate piece of work and a separate decision about what happens to a live product image." This issue is that separate piece of work.

Why it matters

Not every item arrives through an upload link. An admin adding stock themselves goes straight to Admin | Inventory, uploads photos, and never touches the review queue — so for those items the feature effectively does not exist. It is also the only route back for an item whose draft was published before anybody looked at the photos, because publishing does not remove it from the queue but nobody revisits it once it is live.

What already exists and should be reused

Nearly all of it. The work is mostly wiring:

  • backend/src/intake/backgroundRemoval.tsremoveImageBackground(imageId) and restoreImageOriginal(imageId), already shared by the worker and the review queue, already idempotent, already leaving the original restorable.
  • item_images.original_image_path — already the single source of truth for "can this be restored?", so the button's label is derived, not stored twice.
  • frontend/src/admin/DraftQueue.tsx — the DraftPhoto component is exactly this control. It should be lifted somewhere shared rather than copied, or the second copy will drift from the first.

Decisions worth making before writing anything

Does this apply to a live product image? The review queue only ever touches items nobody has published yet. Doing it on an available item changes what a customer sees, mid-browse, with a cached image possibly still in flight. That may be perfectly fine — the original is restorable and the change is one click back — but it is the question #281 deliberately declined to answer, and it should be answered on purpose.

Should it be offered for every status, or only some? A sold item's photos are a record of something that happened; a reserved item is in somebody's cart right now. unpublish already refuses both by name, and that precedent may or may not apply here.

Is a bulk "remove backgrounds on this item" wanted, or strictly per photo? Per photo is what exists and is what makes a poor cut survivable. Bulk over the whole catalogue remains out of scope.

Not in scope

Bulk application across the existing catalogue, and any change to how the submission page or the review queue already behave.

Refs #281

Background removal exists in exactly two places today, both delivered by #281: - **The submission page** (`/submit/:token`) — a checkbox, ticked by default, recording the submitter's intent for the drafting worker to act on. - **The review queue** (`Admin | Review queue`) — a per-photo **Remove background** / **Restore original** button on each draft card. It does **not** exist where an admin actually edits an item's photos. `Admin.tsx:314-326` renders "Existing Images (front / back / etc.)" for the item being edited, and each thumbnail carries one control: a red delete button. There is no way to remove a background there, and no way to restore one. That gap is not an oversight in the implementation — #281's design put it out of scope in as many words: *"The control lives on the review queue only. Applying it to already-published items is a separate piece of work and a separate decision about what happens to a live product image."* This issue is that separate piece of work. ## Why it matters Not every item arrives through an upload link. An admin adding stock themselves goes straight to `Admin | Inventory`, uploads photos, and never touches the review queue — so for those items the feature effectively does not exist. It is also the only route back for an item whose draft was published before anybody looked at the photos, because publishing does not remove it from the queue but nobody revisits it once it is live. ## What already exists and should be reused Nearly all of it. The work is mostly wiring: - `backend/src/intake/backgroundRemoval.ts` — `removeImageBackground(imageId)` and `restoreImageOriginal(imageId)`, already shared by the worker and the review queue, already idempotent, already leaving the original restorable. - `item_images.original_image_path` — already the single source of truth for "can this be restored?", so the button's label is derived, not stored twice. - `frontend/src/admin/DraftQueue.tsx` — the `DraftPhoto` component is exactly this control. It should be lifted somewhere shared rather than copied, or the second copy will drift from the first. ## Decisions worth making before writing anything **Does this apply to a live product image?** The review queue only ever touches items nobody has published yet. Doing it on an `available` item changes what a customer sees, mid-browse, with a cached image possibly still in flight. That may be perfectly fine — the original is restorable and the change is one click back — but it is the question #281 deliberately declined to answer, and it should be answered on purpose. **Should it be offered for every status, or only some?** A `sold` item's photos are a record of something that happened; a `reserved` item is in somebody's cart right now. `unpublish` already refuses both by name, and that precedent may or may not apply here. **Is a bulk "remove backgrounds on this item" wanted, or strictly per photo?** Per photo is what exists and is what makes a poor cut survivable. Bulk over the whole catalogue remains out of scope. ## Not in scope Bulk application across the existing catalogue, and any change to how the submission page or the review queue already behave. Refs #281
Author
Owner

Decisions, answering the three questions above.

Yes, it applies to a live product image. Removing a background on an available item changes what a customer sees while they are browsing. That is accepted: the original is kept and restoring it is one click, so the worst case is a photo that looks wrong for as long as it takes somebody to notice and undo it. Weighed against the alternative — a shop where published items can never be tidied up — that is the right way round.

Every status. No exceptions for sold or reserved. The precedent from unpublish does not carry: refusing there protects a customer mid-checkout from having an item vanish, and protects a completed sale from being quietly rewritten. Neither applies to a photograph's background. A sold item's photos are still the shop's photos, and improving them changes nothing about the sale.

Per upload, not per photo. This is a change from what the review queue does and it is the more consequential answer of the three.

An upload is one item. Somebody photographing a vase sends the front, the back and the chipped base, and those are three views of one thing — they should not be split into separate items, and they should not be cut out one at a time. So the control acts on every image in the upload, as a single action on the item.

That has two consequences worth writing down now rather than discovering later.

The button is per item, not per thumbnail. The DraftPhoto component in the review queue is per photo, so it is no longer the thing to lift wholesale — what is shared is removeImageBackground and restoreImageOriginal underneath, which already loop cleanly (removeBackgroundsForItem in backgroundRemoval.ts does exactly this for the worker).

Partial failure needs an answer. Four photos, the sidecar dies after two. removeBackgroundsForItem stops at the first failure, so the item is left half cut out — which is a real state a customer could see on a live item. The options are to report "2 of 4 done" and let the admin retry, or to restore the ones that succeeded so the item stays consistent. Retry is cheaper and the operation is already idempotent, so the second pass would skip the two that worked; that is the direction to take unless there is a reason not to.

Still out of scope: bulk application across the whole catalogue.

Decisions, answering the three questions above. **Yes, it applies to a live product image.** Removing a background on an `available` item changes what a customer sees while they are browsing. That is accepted: the original is kept and restoring it is one click, so the worst case is a photo that looks wrong for as long as it takes somebody to notice and undo it. Weighed against the alternative — a shop where published items can never be tidied up — that is the right way round. **Every status.** No exceptions for `sold` or `reserved`. The precedent from `unpublish` does not carry: refusing there protects a customer mid-checkout from having an item vanish, and protects a completed sale from being quietly rewritten. Neither applies to a photograph's background. A sold item's photos are still the shop's photos, and improving them changes nothing about the sale. **Per upload, not per photo.** This is a change from what the review queue does and it is the more consequential answer of the three. An upload is one item. Somebody photographing a vase sends the front, the back and the chipped base, and those are three views of one thing — they should not be split into separate items, and they should not be cut out one at a time. So the control acts on **every image in the upload**, as a single action on the item. That has two consequences worth writing down now rather than discovering later. *The button is per item, not per thumbnail.* The `DraftPhoto` component in the review queue is per photo, so it is no longer the thing to lift wholesale — what is shared is `removeImageBackground` and `restoreImageOriginal` underneath, which already loop cleanly (`removeBackgroundsForItem` in `backgroundRemoval.ts` does exactly this for the worker). *Partial failure needs an answer.* Four photos, the sidecar dies after two. `removeBackgroundsForItem` stops at the first failure, so the item is left half cut out — which is a real state a customer could see on a live item. The options are to report "2 of 4 done" and let the admin retry, or to restore the ones that succeeded so the item stays consistent. Retry is cheaper and the operation is already idempotent, so the second pass would skip the two that worked; that is the direction to take unless there is a reason not to. Still out of scope: bulk application across the whole catalogue.
Author
Owner

Done. Merged as PR #296, nine commits.

All three decisions from the comment above shipped as agreed: it applies to live product images, to every status including sold and reserved, and it acts on every photo in the upload rather than one at a time.

What the final review caught that the per-task reviews could not.

The button's gate and label both used images.every(img => img.original_image_path != null), so a partly cut-out item only ever offered Remove backgrounds — and with REMBG_URL unset it offered nothing at all. One photo whose file is missing from the volume would fail on every attempt, stranding the others permanently with no way back. DraftQueue avoids this by evaluating the flag per photo; whole-item every does not reproduce that property. It is now two independent buttons, and Restore originals appears whenever any photo is cut out, regardless of whether a sidecar is configured.

restore-originals also broke its own always-200 contract: it rethrew a genuine failure, which became a 500 that discarded the partial count and left the modal showing stale thumbnails — while photos genuinely had been restored. RestoreSummary now carries failed, matching RemovalSummary.

One thing worth recording for the next person who writes an integration test here. The first attempt at those fixes added fault-injection tests that did jest.spyOn(pool, 'connect') on the pool exported from src/db — the single pool every integration suite shares, and the one afterAll calls pool.end() on. The result was a suite reporting "Test suite failed to run" against its own teardown, filtered runs that never exited, and three failing tests. Mocking that pool is not a way to inject a fault; it is a way to make the suite unrunnable. The partial-restore path is covered by a unit test that mocks src/db wholesale instead, where jest's per-file module registry means no real pool exists at all.

Also in this branch: ADMIN_IMAGES_SUBQUERY, so original_image_path reaches admin item images and provably not the storefront — itemSelect.ts exists to keep columns that are none of a customer's business out of the public select, and that property is asserted both ways against a real row.

Still out of scope, unchanged: bulk application across the catalogue.

Verified: backend 460 unit, 435 integration, frontend 53 unit, both builds and lints clean. The end-to-end spec is the one thing not yet exercised — it runs for the first time in CI on 1683fbb.

Done. Merged as PR #296, nine commits. All three decisions from the comment above shipped as agreed: it applies to live product images, to every status including `sold` and `reserved`, and it acts on every photo in the upload rather than one at a time. **What the final review caught that the per-task reviews could not.** The button's gate and label both used `images.every(img => img.original_image_path != null)`, so a partly cut-out item only ever offered **Remove backgrounds** — and with `REMBG_URL` unset it offered nothing at all. One photo whose file is missing from the volume would fail on every attempt, stranding the others permanently with no way back. `DraftQueue` avoids this by evaluating the flag *per photo*; whole-item `every` does not reproduce that property. It is now two independent buttons, and **Restore originals** appears whenever any photo is cut out, regardless of whether a sidecar is configured. `restore-originals` also broke its own always-200 contract: it rethrew a genuine failure, which became a 500 that discarded the partial count and left the modal showing stale thumbnails — while photos genuinely had been restored. `RestoreSummary` now carries `failed`, matching `RemovalSummary`. **One thing worth recording for the next person who writes an integration test here.** The first attempt at those fixes added fault-injection tests that did `jest.spyOn(pool, 'connect')` on the pool exported from `src/db` — the single pool every integration suite shares, and the one `afterAll` calls `pool.end()` on. The result was a suite reporting "Test suite failed to run" against its own teardown, filtered runs that never exited, and three failing tests. Mocking that pool is not a way to inject a fault; it is a way to make the suite unrunnable. The partial-restore path is covered by a unit test that mocks `src/db` wholesale instead, where jest's per-file module registry means no real pool exists at all. Also in this branch: `ADMIN_IMAGES_SUBQUERY`, so `original_image_path` reaches admin item images and provably not the storefront — `itemSelect.ts` exists to keep columns that are none of a customer's business out of the public select, and that property is asserted both ways against a real row. Still out of scope, unchanged: bulk application across the catalogue. Verified: backend 460 unit, 435 integration, frontend 53 unit, both builds and lints clean. The end-to-end spec is the one thing not yet exercised — it runs for the first time in CI on `1683fbb`.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#293