feat: reorder inventory images atomically
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-11 00:13:44 -04:00
parent 72e9a79b67
commit 0e81fc4022
15 changed files with 275 additions and 22 deletions
@@ -0,0 +1,19 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function validateImageMove(direction) {
if (direction !== 'up' && direction !== 'down') {
throw createApplicationError('IMAGE_MOVE_INVALID', 'Image move must be up or down.', 400);
}
}
// Input is the persisted display order. Return a reordered copy; boundary moves
// are no-ops. Persistence assigns contiguous positions after an actual move.
export function moveImageInOrder(images, imageId, direction) {
validateImageMove(direction);
const index = images.findIndex((image) => image.id === imageId);
const neighbor = index + (direction === 'up' ? -1 : 1);
if (index < 0 || neighbor < 0 || neighbor >= images.length) return images;
const reordered = [...images];
[reordered[index], reordered[neighbor]] = [reordered[neighbor], reordered[index]];
return reordered;
}
@@ -1,3 +1,4 @@
import { moveImageInOrder } from '../domain/image-order.js';
import { and, eq, inArray } from 'drizzle-orm';
import {
@@ -53,10 +54,42 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(products.sellerId, sellerId),
))
.orderBy(inventoryImages.sortOrder)
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.then((rows) => rows.map(({ image }) => image));
},
async moveForSeller(imageId, direction, sellerId) {
return db.transaction(async (tx) => {
// Lock the owning physical item first, serializing moves within a gallery.
const [owned] = await tx
.select({ inventoryItemId: inventoryItems.id })
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryImages.id, imageId), eq(products.sellerId, sellerId)))
.for('update', { of: inventoryItems });
if (!owned) return null;
const images = await tx.select().from(inventoryImages)
.where(eq(inventoryImages.inventoryItemId, owned.inventoryItemId))
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.for('update');
const target = images.find((image) => image.id === imageId);
if (!target) return null;
const reordered = moveImageInOrder(images, imageId, direction);
if (reordered === images) return target;
const updatedAt = new Date();
for (const [sortOrder, image] of reordered.entries()) {
if (image.sortOrder !== sortOrder) {
await tx.update(inventoryImages).set({ sortOrder, updatedAt })
.where(eq(inventoryImages.id, image.id));
}
}
const [moved] = await tx.select().from(inventoryImages).where(eq(inventoryImages.id, imageId));
return moved;
});
},
async updateOrderForSeller(imageId, sortOrder, sellerId) {
const owned = await this.findByIdForSeller(imageId, sellerId);
if (!owned) return null;
@@ -135,7 +168,7 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'),
))
.orderBy(inventoryImages.sortOrder);
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
},
async findPublicById(imageId) {
@@ -1,5 +1,5 @@
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image';
import { createInventoryImageSchema, moveInventoryImageSchema } from '@pokemon-inventory/validation/image';
import { randomUUID } from 'node:crypto';
import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -98,6 +98,18 @@ export async function registerAdminImageRoutes(app, options) {
return admin;
}
app.post('/images/:id/move', async (request, reply) => {
const admin = await authorizeAdminMutation(request);
const result = moveInventoryImageSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.move({
imageId: request.params.id,
direction: result.data.direction,
sellerId: admin.sellerId,
});
return { data: toAdminInventoryImageDto(image) };
});
app.patch('/images/:id/order', async (request) => {
const admin = await authorizeAdminMutation(request);
const image = await inventoryImageService.updateOrder({
@@ -1,3 +1,4 @@
import { validateImageMove } from '../domain/image-order.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
@@ -42,6 +43,11 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
}
},
async move({ imageId, direction, sellerId }) {
validateImageMove(direction);
return requireOwnedImage(inventoryImageRepository.moveForSeller(imageId, direction, sellerId));
},
async updateOrder({ imageId, sortOrder, sellerId }) {
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
},