feat(admin): remove or restore every background on an item (#293)
Two routes on the item, and a small admin config route so the inventory screen can know whether to offer them. Both answer 200 once the id is valid, even when the sidecar fails, and that is a deliberate departure from the per-photo endpoints in #281. Those act on one image, so the request either worked or it did not and 502 says which. These act on several, so "did it work" has no single answer — two of four is the normal shape of a bad day here, not an exception — and a 502 would throw away the count that is the only thing making the outcome actionable. Non-200 is reserved for not being able to try at all, which here means an unreadable or absent id. No status check on either. A sold item's photos are still the shop's photos and improving them changes nothing about the sale; the guards on unpublish protect a checkout in progress and a completed sale, neither of which is at stake in a photograph's background. The config route follows adminVersion's precedent rather than extending the public /api/config: admin-only, one purpose, and the reason written down. The inventory screen had no other way to learn the feature exists, because GET /api/admin/items answers a bare array with several consumers and reshaping it for one boolean is the worse trade. Also extends the admin item select to carry original_image_path on each image, behind a new ADMIN_IMAGES_SUBQUERY kept separate from the shared IMAGES_SUBQUERY the public select uses. Task 3 needs to derive its restore-button label from that field, and the server never sent it for items before this — only the drafts endpoint carried it, added by #281 for the review queue. It stays admin-only for the same reason itemSelect.ts already names PUBLIC_ITEM_SELECT's columns explicitly: an internal original filename is nobody's business on the storefront, and sharing one subquery would put it in every public item response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { asyncRoute } from '../asyncRoute';
|
||||
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||
import { readId, tagColorFor } from '../utils';
|
||||
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
|
||||
import { removeBackgroundsForItem, restoreOriginalsForItem } from '../intake/backgroundRemoval';
|
||||
// The upload pipeline moved to src/imageUpload.ts when #222's public intake
|
||||
// endpoint became a second caller. Mounting uploadImages gets the type
|
||||
// allowlist, the magic-byte check, and the EXIF-stripping re-encode together —
|
||||
@@ -350,4 +351,59 @@ router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Re
|
||||
res.json(available);
|
||||
}));
|
||||
|
||||
/**
|
||||
* One item's images, or null when the item does not exist.
|
||||
*
|
||||
* Checked before acting so an absent item is a 404 rather than a cheerful
|
||||
* summary of nothing. `removeBackgroundsForItem` would happily report
|
||||
* `total: 0` for an id that was never an item, which is true and useless.
|
||||
*/
|
||||
async function itemExists(itemId: number): Promise<boolean> {
|
||||
const { rows } = await pool.query(`SELECT 1 FROM items WHERE id = $1`, [itemId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the background from every photo of one item.
|
||||
*
|
||||
* Per item rather than per photo because an upload is one item: the front, the
|
||||
* back and the chipped base are three views of one thing, not three things to
|
||||
* cut out separately (#293).
|
||||
*
|
||||
* Answers 200 once the id is valid, even when the sidecar fails. Unlike the
|
||||
* per-photo endpoints in #281, this acts on several images, so "did it work"
|
||||
* has no single answer — two of four is the normal shape of a bad day here.
|
||||
* A 502 would throw away the count, which is the only thing that makes the
|
||||
* outcome actionable. Non-200 is reserved for not being able to try at all.
|
||||
*
|
||||
* No status check. A sold item's photos are still the shop's photos, and
|
||||
* improving them changes nothing about the sale — the guards on `unpublish`
|
||||
* protect a checkout in progress and a completed sale, neither of which is at
|
||||
* stake in a photograph's background.
|
||||
*/
|
||||
router.post('/items/:id/remove-backgrounds', asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null || !(await itemExists(itemId))) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
res.json(await removeBackgroundsForItem(itemId));
|
||||
}));
|
||||
|
||||
/**
|
||||
* Put every original back.
|
||||
*
|
||||
* The reason removing is safe to try. Photos that were never cut out are
|
||||
* skipped rather than refused, so a half-done item — what a partial failure
|
||||
* leaves behind — is restorable too.
|
||||
*/
|
||||
router.post('/items/:id/restore-originals', asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null || !(await itemExists(itemId))) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
res.json(await restoreOriginalsForItem(itemId));
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user