feat(admin): remove and restore a photo's background per image (#281)

Adds two admin-gated endpoints on the review queue router: POST /:itemId/images/:imageId/remove-background and POST /:itemId/images/:imageId/restore-original. Both run synchronously and reuse the same backgroundRemoval module the drafting worker uses, so a cut-out obtained either way is identical and either can be undone by Restore.

Ownership is scoped by item as well as by image (imageOfItem selects on id AND item_id), because the image id is a serial and guessing one is easy — a photo belonging to a different submission must not be reachable through another item's URL. A sidecar failure returns 502, not 500, and leaves the row untouched, since removeImageBackground only writes the row after the cut-out file already exists on disk.

GET /api/admin/item-drafts now returns { drafts, backgroundRemoval } instead of { drafts }, and each image in the payload gains original_image_path, which is what the review queue UI will use to decide between "Remove background" and "Restore original". DRAFT_SELECT's images aggregate is extended accordingly, keeping the deliberate column spelling that guards against the upload_links token digest leaking into the response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:49:38 -05:00
co-authored by Claude Opus 5
parent 885a78c572
commit 9ced34ad19
2 changed files with 233 additions and 2 deletions
+87 -2
View File
@@ -3,6 +3,8 @@ import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { draftQueued } from '../intake/draftingWorker';
import { nextPriceSource, PriceSource } from '../intake/priceSource';
import { removeImageBackground, restoreImageOriginal } from '../intake/backgroundRemoval';
import { isRembgConfigured } from '../intake/rembgClient';
const router = Router();
@@ -27,7 +29,10 @@ const DRAFT_SELECT = `
i.price_cents, i.status,
l.label AS upload_link_label,
COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path)
SELECT json_agg(json_build_object(
'id', img.id,
'image_path', img.image_path,
'original_image_path', img.original_image_path)
ORDER BY img.sort_order)
FROM item_images img WHERE img.item_id = d.item_id
), '[]'::json) AS images
@@ -52,7 +57,9 @@ router.get(
? await pool.query(`${DRAFT_SELECT} WHERE d.state = $1 ORDER BY d.created_at DESC`, [state])
: await pool.query(`${DRAFT_SELECT} WHERE d.state <> 'discarded' ORDER BY d.created_at DESC`);
res.json({ drafts: rows });
// Whether the control has anything behind it, alongside the rows. A second
// endpoint for one boolean would be a round trip the queue already makes.
res.json({ drafts: rows, backgroundRemoval: isRembgConfigured() });
})
);
@@ -220,4 +227,82 @@ router.post(
})
);
/**
* One photo's current paths, if it belongs to this item.
*
* Scoped by item as well as by image so an image id from a different
* submission cannot be acted on through this item's URL — the id is a serial,
* so guessing one is not hard.
*/
async function imageOfItem(
itemId: string,
imageId: string
): Promise<{ image_path: string; original_image_path: string | null } | null> {
const { rows } = await pool.query<{ image_path: string; original_image_path: string | null }>(
`SELECT image_path, original_image_path
FROM item_images WHERE id = $1 AND item_id = $2`,
[imageId, itemId]
);
return rows[0] ?? null;
}
/**
* Remove the background from one photo.
*
* The other half of the submitter's checkbox: for the photos nobody ticked it
* for, and for the ones where the worker could not reach the sidecar. Both go
* through the same module, so a cut-out obtained either way is identical and
* either can be undone by Restore.
*
* Synchronous, unlike the worker's path. A warm request measures 1.12.3 s and
* this is an admin who just clicked a button and is watching for the result.
* The reason drafting was moved off the request path — that a stranger can
* trigger it and must never wait — does not apply behind the admin gate.
*/
router.post(
'/:itemId/images/:imageId/remove-background',
asyncRoute(async (req: Request, res: Response) => {
const { itemId = '', imageId = '' } = req.params;
if ((await imageOfItem(itemId, imageId)) === null) {
return res.status(404).json({ error: 'no such photo on this item' });
}
try {
await removeImageBackground(Number(imageId));
} catch (err) {
// 502, not 500. The request was fine and so is this app — the service it
// depends on did not answer. The message says the photo is unchanged,
// because that is the thing the admin actually needs to know.
console.error(`[drafts] background removal for image ${imageId}:`, err);
return res
.status(502)
.json({ error: 'the background-removal service did not answer — the photo is unchanged' });
}
res.json(await imageOfItem(itemId, imageId));
})
);
/**
* Put the original photo back.
*
* The reason a cut-out is safe to try at all. Background removal produces the
* occasional poor result on an unusual object, and this makes that survivable
* rather than something to prevent. Nothing is deleted: the cut-out file stays
* on disk, because somebody restoring one is quite likely to try again.
*/
router.post(
'/:itemId/images/:imageId/restore-original',
asyncRoute(async (req: Request, res: Response) => {
const { itemId = '', imageId = '' } = req.params;
const existing = await imageOfItem(itemId, imageId);
if (existing === null || existing.original_image_path === null) {
return res.status(404).json({ error: 'this photo has no original to restore' });
}
await restoreImageOriginal(Number(imageId));
res.json(await imageOfItem(itemId, imageId));
})
);
export default router;