feat(admin): endpoints to rotate one photo of an item (#301)

Two POST routes and the module behind them. They live on the item rather than on the draft, and that is the decision that makes the inventory editor free when it follows: an image belongs to an item whether or not a draft row exists, so the second screen to want this is the same call from a different place with no new backend at all.

A cut-out and its pristine original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then quietly un-rotate the photo, so the undo of one feature becomes a regression of another.

204 rather than 200. Rotation changes no column: the paths are identical afterwards and only the bytes differ, so there is no row worth returning, which is the same reason deleting an image is already a 204.

Only "not on this item" is a 404, and it is indistinguishable from an absent id on purpose, because an image id is a serial and this endpoint should not confirm which ones exist. Everything else stays loud as a 500, and the file is untouched in every one of those cases — rotateInPlace renames over the original only once the new file has been written.

One asymmetry is recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a failure between the two files turns the displayed one twice. That needs the disk to break between two writes, and the remedy is one press in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 13:46:29 -05:00
co-authored by Claude Opus 5
parent 3659608abe
commit 3891f4fd75
3 changed files with 281 additions and 0 deletions
+59
View File
@@ -8,6 +8,8 @@ import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilter
import { readId, tagColorFor } from '../utils';
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
import { removeBackgroundsForItem, restoreOriginalsForItem } from '../intake/backgroundRemoval';
import { rotateItemImage, ImageNotOnItemError } from '../imageRotation';
import { RotateDirection } from '../imageProcessing';
// 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 —
@@ -411,4 +413,61 @@ router.post('/items/:id/restore-originals', asyncRoute(async (req: Request, res:
res.json(await restoreOriginalsForItem(itemId));
}));
/**
* Turn one photo a quarter turn.
*
* On the item rather than on the draft, which is the decision that makes the
* inventory editor free later: an image belongs to an item whether or not a
* draft row exists, so the second screen to want this is the same call from a
* different place, with no new backend at all.
*
* Unlike the per-item background endpoints, this acts on exactly one file, so
* it can honestly answer whether it worked. 204 rather than 200 because
* rotation changes no column — the paths are identical afterwards and only the
* bytes differ, so there is no row worth returning, which is also why
* DELETE /items/:id/images/:imageId is a 204.
*
* A factory rather than two copied handlers: the direction is the only thing
* that differs. Two paths rather than one endpoint taking a direction in the
* body matches how remove-background and restore-original are already spelled.
*/
function rotationRoute(direction: RotateDirection) {
return asyncRoute(async (req: Request, res: Response) => {
// Both ids, not just the first. A route carrying two of them can guard one
// and forget the other, and the forgotten one fails as a 500 rather than
// the 404 that "no such photo" actually means (#207).
const itemId = readId(req.params.id);
const imageId = readId(req.params.imageId);
if (itemId === null || imageId === null) {
return res.status(404).json({ error: 'no such photo on this item' });
}
try {
await rotateItemImage(itemId, imageId, direction);
} catch (err) {
// Only "not on this item" is a 404, and it is indistinguishable from an
// absent one on purpose: an image id is a serial, and confirming which
// ids exist is not something this endpoint should do. Everything else is
// a real fault and stays loud — the file is untouched in every one of
// those cases, because rotateInPlace renames over the original only once
// the new file has been written successfully.
if (err instanceof ImageNotOnItemError) {
return res.status(404).json({ error: 'no such photo on this item' });
}
console.error(`[rotation] item ${itemId}, image ${imageId}:`, err);
return res.status(500).json({
error:
err instanceof Error
? `this photo could not be rotated: ${err.message}`
: 'this photo could not be rotated'
});
}
res.status(204).end();
});
}
router.post('/items/:id/images/:imageId/rotate-left', rotationRoute('left'));
router.post('/items/:id/images/:imageId/rotate-right', rotationRoute('right'));
export default router;