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:
2026-09-04 10:26:19 -05:00
co-authored by Claude Opus 5
parent 6f3e77fa88
commit 8f35204995
5 changed files with 312 additions and 2 deletions
+2
View File
@@ -14,6 +14,7 @@ import adminItemDraftsRouter from './routes/adminItemDrafts';
import intakeActionsRouter from './routes/intakeActions';
import intakeRouter from './routes/intake';
import adminVersionRouter from './routes/adminVersion';
import adminConfigRouter from './routes/adminConfig';
import filtersRouter from './routes/filters';
import customersRouter from './routes/customers';
import publicRouter from './routes/public';
@@ -84,6 +85,7 @@ app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter);
app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter);
app.use('/api/admin/version', requireAdminGate, adminVersionRouter);
app.use('/api/admin/config', requireAdminGate, adminConfigRouter);
app.use('/api/admin', requireAdminGate, adminRouter);
app.use('/api/customers/me/addresses', shippingAddressesRouter);
app.use('/api/customers', customersRouter);
+34 -2
View File
@@ -30,6 +30,26 @@ const IMAGES_SUBQUERY = `
WHERE img.item_id = i.id
), '[]') AS images`;
/**
* Admin-only images, carrying `original_image_path` alongside the public
* fields — the field the inventory screen needs to know whether a photo has a
* cut-out to restore (#293).
*
* A separate subquery rather than adding the column to `IMAGES_SUBQUERY`
* itself, for the same reason `PUBLIC_ITEM_SELECT` names its columns instead
* of using `i.*`: an original filename is internal — nobody's business on the
* storefront — and folding it into the one subquery both selects share would
* put it in every public item response too.
*/
const ADMIN_IMAGES_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order,
'original_image_path', img.original_image_path)
ORDER BY img.sort_order)
FROM item_images img
WHERE img.item_id = i.id
), '[]') AS images`;
const TAGS_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name)
@@ -54,7 +74,7 @@ export const PUBLIC_ITEM_SELECT = `
export const ADMIN_ITEM_SELECT = `
SELECT i.*,
c.name AS category_name,
${IMAGES_SUBQUERY},
${ADMIN_IMAGES_SUBQUERY},
${TAGS_SUBQUERY}
${FROM_CLAUSE}`;
@@ -89,16 +109,28 @@ interface ItemRowBase {
/** What PUBLIC_ITEM_SELECT returns. Deliberately no payment or reservation columns. */
export type PublicItemRow = ItemRowBase;
/**
* An admin item's image: everything `ItemImage` has, plus where the
* background-removed photo's original went. `null` for a photo that was never
* cut out.
*/
export interface AdminItemImage extends ItemImage {
original_image_path: string | null;
}
/**
* What ADMIN_ITEM_SELECT returns: `i.*`, so every column on the table.
*
* The extra fields are the ones the storefront is not allowed to see, which is
* the whole reason the two selects differ.
* the whole reason the two selects differ. `images` is narrowed rather than
* inherited as-is, to match `ADMIN_IMAGES_SUBQUERY` carrying
* `original_image_path` where the public select's images do not.
*/
export interface AdminItemRow extends ItemRowBase {
reserved_until: Date | null;
sold_at: Date | null;
paypal_order_id: string | null;
images: AdminItemImage[];
}
/**
+56
View File
@@ -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;
+27
View File
@@ -0,0 +1,27 @@
import { Router, Request, Response } from 'express';
import { isRembgConfigured } from '../intake/rembgClient';
const router = Router();
/**
* What the admin screens can offer in this environment.
*
* Behind `requireAdminGate` like every other admin router, and deliberately not
* folded into `/api/config` — the same reasoning `adminVersion.ts` records.
* That endpoint is public and the storefront fetches it on every load; nothing
* here is any of a customer's business.
*
* It exists because the inventory screen has no other way to learn this.
* `GET /api/admin/item-drafts` carries the flag for the review queue, but
* `GET /api/admin/items` answers a bare array with several consumers, and
* changing its shape for one boolean would be a worse trade than one small
* route.
*
* Not wrapped in `asyncRoute` because the handler is synchronous: it reads an
* environment variable, so there is no promise to reject.
*/
router.get('/', (_req: Request, res: Response) => {
res.json({ backgroundRemoval: isRembgConfigured() });
});
export default router;