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 { and, eq, inArray } from 'drizzle-orm';
import { import {
@@ -53,10 +54,42 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.inventoryItemId, inventoryItemId), eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(products.sellerId, sellerId), eq(products.sellerId, sellerId),
)) ))
.orderBy(inventoryImages.sortOrder) .orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.then((rows) => rows.map(({ image }) => image)); .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) { async updateOrderForSeller(imageId, sortOrder, sellerId) {
const owned = await this.findByIdForSeller(imageId, sellerId); const owned = await this.findByIdForSeller(imageId, sellerId);
if (!owned) return null; if (!owned) return null;
@@ -135,7 +168,7 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.processingStatus, 'COMPLETED'), eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'), eq(imageDerivativeJobs.status, 'COMPLETED'),
)) ))
.orderBy(inventoryImages.sortOrder); .orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
}, },
async findPublicById(imageId) { async findPublicById(imageId) {
@@ -1,5 +1,5 @@
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image'; 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 { randomUUID } from 'node:crypto';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -98,6 +98,18 @@ export async function registerAdminImageRoutes(app, options) {
return admin; 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) => { app.patch('/images/:id/order', async (request) => {
const admin = await authorizeAdminMutation(request); const admin = await authorizeAdminMutation(request);
const image = await inventoryImageService.updateOrder({ 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 { createApplicationError } from '../../../shared/errors/application-error.js';
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.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 }) { async updateOrder({ imageId, sortOrder, sellerId }) {
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId)); return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
}, },
@@ -35,3 +35,16 @@ test('manages owned image order, primary state, alt text, retry, and deletion',
assert.ok(calls.some(([action]) => action === 'delete-job')); assert.ok(calls.some(([action]) => action === 'delete-job'));
assert.ok(calls.some(([action]) => action === 'storage-delete')); assert.ok(calls.some(([action]) => action === 'storage-delete'));
}); });
test('moves an image through the owned atomic repository operation', async () => {
const service = createInventoryImageService({ inventoryImageRepository: {
async moveForSeller(id, direction, sellerId) {
assert.equal(direction, 'down');
assert.equal(sellerId, 'seller-1');
return id === 'owned' ? { id, sortOrder: 1 } : null;
},
} });
assert.deepEqual(await service.move({ imageId: 'owned', direction: 'down', sellerId: 'seller-1' }), { id: 'owned', sortOrder: 1 });
await assert.rejects(() => service.move({ imageId: 'missing', direction: 'down', sellerId: 'seller-1' }), { code: 'IMAGE_NOT_FOUND' });
await assert.rejects(() => service.move({ imageId: 'owned', direction: 'left', sellerId: 'seller-1' }), { code: 'IMAGE_MOVE_INVALID' });
});
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { moveImageInOrder } from '../../src/modules/inventory/domain/image-order.js';
test('moves one image next to its neighbor without changing the input order', () => {
const images = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(moveImageInOrder(images, 'b', 'up').map(({ id }) => id), ['b', 'a', 'c']);
assert.deepEqual(moveImageInOrder(images, 'b', 'down').map(({ id }) => id), ['a', 'c', 'b']);
assert.deepEqual(images.map(({ id }) => id), ['a', 'b', 'c']);
assert.equal(moveImageInOrder(images, 'a', 'up'), images);
assert.equal(moveImageInOrder(images, 'c', 'down'), images);
assert.throws(() => moveImageInOrder(images, 'a', 'left'), { code: 'IMAGE_MOVE_INVALID' });
});
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
test('validates and authorizes image moves and exposes only the image DTO', async (context) => {
const calls = [];
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest: async () => { calls.push('csrf'); },
inventoryImageService: { async move(input) { calls.push(input); return { id: input.imageId, sortOrder: 1, privateData: 'hidden' }; } },
});
context.after(() => app.close());
const response = await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload: { direction: 'down' } });
assert.equal(response.statusCode, 200);
assert.deepEqual(calls, ['csrf', { imageId: 'image-1', direction: 'down', sellerId: 'seller-1' }]);
assert.equal(response.json().data.sortOrder, 1);
assert.equal(response.json().data.privateData, undefined);
for (const payload of [{}, { direction: 'left' }, { direction: 'up', sellerId: 'other' }]) {
assert.equal((await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload })).statusCode, 400);
}
});
test('rejects unauthenticated and CSRF-blocked moves without calling the service', async (context) => {
for (const csrf of [false, true]) {
const app = buildApp({
authenticateAdmin: async () => csrf ? { sellerId: 'seller-1' } : null,
authorizeUnsafeRequest: async () => { throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); },
inventoryImageService: { async move() { assert.fail('unauthorized move'); } },
});
context.after(() => app.close());
assert.equal((await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload: { direction: 'up' } })).statusCode, csrf ? 403 : 401);
}
});
@@ -273,3 +273,65 @@ test('reads and updates listings only through the owning seller scope', async ()
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED'); const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
assert.equal(archivedListing.status, 'ARCHIVED'); assert.equal(archivedListing.status, 'ARCHIVED');
}); });
async function createImageOrderFixture() {
const { seller, inventoryItem } = await createInventoryFixture();
const images = [];
for (let index = 0; index < 3; index += 1) {
images.push(await inventoryImageRepository.createForSeller({
inventoryItemId: inventoryItem.id, storageKey: `move-${index}.jpg`, originalFilename: `move-${index}.jpg`,
mimeType: 'image/jpeg', fileSize: 100, width: 10, height: 10, sortOrder: 0,
isPrimary: index === 0,
}, seller.id));
}
return { seller, inventoryItem, images: await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id) };
}
test('moves images atomically with tied positions, ownership checks and boundary no-ops', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
const read = () => inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
assert.equal(await inventoryImageRepository.moveForSeller(images[0].id, 'down', '00000000-0000-0000-0000-000000000000'), null);
assert.equal(await inventoryImageRepository.moveForSeller('00000000-0000-0000-0000-000000000000', 'down', seller.id), null);
assert.deepEqual(await read(), images);
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
assert.deepEqual(await read(), images);
await inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id);
const moved = await read();
assert.deepEqual(moved.map(({ id }) => id), [images[1].id, images[0].id, images[2].id]);
assert.deepEqual(moved.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
assert.equal(moved.find(({ id }) => id === images[0].id).isPrimary, images[0].isPrimary);
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
assert.deepEqual((await read()).map(({ id }) => id), images.map(({ id }) => id));
});
test('rolls back every position if an image move fails after its first write', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
let writes = 0;
const failingRepository = createInventoryImageRepository({
transaction: (action) => database.db.transaction((tx) => action(new Proxy(tx, {
get(target, property) {
if (property === 'update') return (...args) => {
writes += 1;
if (writes === 2) throw new Error('Injected second write failure');
return target.update(...args);
};
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
},
}))),
});
await assert.rejects(() => failingRepository.moveForSeller(images[0].id, 'down', seller.id), /Injected second write failure/);
assert.equal(writes, 2);
assert.deepEqual(await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id), images);
});
test('serializes concurrent moves within the same gallery', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
await Promise.all([
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
]);
const result = await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
assert.deepEqual(result.map(({ id }) => id), [images[1].id, images[2].id, images[0].id]);
assert.deepEqual(result.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
});
+2 -14
View File
@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { import {
deleteImage, deleteImage,
listAdminImages, listAdminImages,
reorderImage, moveImage,
retryImageProcessing, retryImageProcessing,
setPrimaryImage, setPrimaryImage,
updateImageAltText, updateImageAltText,
@@ -76,18 +76,6 @@ export function useAdminImages(inventoryItemId) {
}); });
} }
async function moveImage(imageId, direction) {
const ordered = [...images.value].sort((left, right) => left.sortOrder - right.sortOrder);
const index = ordered.findIndex((image) => image.id === imageId);
const neighborIndex = direction === 'up' ? index - 1 : index + 1;
if (index === -1 || neighborIndex < 0 || neighborIndex >= ordered.length) return;
const current = ordered[index];
const neighbor = ordered[neighborIndex];
await apply(async (token) => {
await reorderImage(current.id, neighbor.sortOrder, token);
await reorderImage(neighbor.id, current.sortOrder, token);
});
}
return { return {
images, images,
@@ -98,7 +86,7 @@ export function useAdminImages(inventoryItemId) {
hasLoaded, hasLoaded,
load, load,
upload, upload,
move: moveImage, move: (imageId, direction) => apply((token) => moveImage(imageId, direction, token)),
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)), setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)), updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)), retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)),
@@ -10,6 +10,7 @@ export default {
let reads = 0; let reads = 0;
let writes = 0; let writes = 0;
let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }]; let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }];
if (scenario === 'move' || scenario === 'move-error') images.push({ id: 'image-2', altText: 'Back of card', sortOrder: 1, isPrimary: false, processingStatus: 'COMPLETED' });
const reply = (data) => new Response(JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } }); const reply = (data) => new Response(JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } });
const fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 }); const fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 });
globalThis.fetch = async (url, options = {}) => { globalThis.fetch = async (url, options = {}) => {
@@ -21,6 +22,15 @@ export default {
return reply(images); return reply(images);
} }
writes += 1; writes += 1;
if (scenario === 'move' || scenario === 'move-error') {
await expect(String(url)).toMatch(/\/images\/image-[12]\/move$/);
await expect(options.method).toBe('POST');
const { direction } = JSON.parse(options.body);
await expect(['up', 'down']).toContain(direction);
if (scenario === 'move-error' && writes === 1) return fail('Move failed.');
images = [...images].reverse().map((image, sortOrder) => ({ ...image, sortOrder }));
return reply(images[0]);
}
if (scenario === 'saving') return new Promise(() => {}); if (scenario === 'saving') return new Promise(() => {});
if (scenario === 'partial-upload' && writes === 2) return fail('Second upload failed.'); if (scenario === 'partial-upload' && writes === 2) return fail('Second upload failed.');
if (options.body instanceof FormData) images = [...images, { id: `uploaded-${writes}`, altText: options.body.get('altText'), sortOrder: images.length, isPrimary: false, processingStatus: 'COMPLETED' }]; if (options.body instanceof FormData) images = [...images, { id: `uploaded-${writes}`, altText: options.body.get('altText'), sortOrder: images.length, isPrimary: false, processingStatus: 'COMPLETED' }];
@@ -137,3 +147,29 @@ export const PartialUploadFailure = {
await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible(); await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible();
}, },
}; };
export const AtomicMove = {
parameters: { imageScenario: 'move' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const down = await canvas.findAllByRole('button', { name: 'Move down' });
await userEvent.click(down[0]);
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Back of card'));
await userEvent.click(canvas.getAllByRole('button', { name: 'Move up' })[1]);
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Front of card'));
await expect(canvas.getAllByRole('button', { name: 'Move up' })[0]).toBeDisabled();
await expect(canvas.getAllByRole('button', { name: 'Move down' })[1]).toBeDisabled();
},
};
export const FailedAtomicMove = {
parameters: { imageScenario: 'move-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click((await canvas.findAllByRole('button', { name: 'Move down' }))[0]);
await expect(await canvas.findByRole('alert')).toHaveTextContent('Move failed.');
await waitFor(() => expect(canvas.getAllByRole('button', { name: 'Move down' })[0]).toBeEnabled());
await expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Front of card');
await userEvent.click(canvas.getAllByRole('button', { name: 'Move down' })[0]);
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Back of card'));
},
};
@@ -80,3 +80,11 @@ export async function listAdminImages(inventoryItemId) {
headers: {}, headers: {},
}); });
} }
export function moveImage(imageId, direction, csrfToken) {
return request(`/admin/images/${encodeURIComponent(imageId)}/move`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify({ direction }),
});
}
+10
View File
@@ -197,3 +197,13 @@ Seller contact removal uses the same explicit-empty-string convention:
whitespace-only strings clear the contact. Omitted contact fields retain their whitespace-only strings clear the contact. Omitted contact fields retain their
saved values. `null` is not a settings input. Public listing/product detail DTOs saved values. `null` is not a settings input. Public listing/product detail DTOs
return `null` for empty or absent contacts, so no contact link is rendered. return `null` for empty or absent contacts, so no contact link is rendered.
`POST /api/v1/admin/images/:id/move` accepts exactly `{ "direction": "up" }`
or `{ "direction": "down" }`. Authentication and session CSRF validation are
required; ownership comes from the authenticated Seller. The server selects the
adjacent image within the same physical Inventory Item and commits all resulting
positions atomically. At the first/last position the corresponding move is a
successful no-op. Missing/unowned images return `404 IMAGE_NOT_FOUND`; invalid
bodies return `400 VALIDATION_ERROR`. The response uses the existing admin image
DTO. The legacy absolute `/order` endpoint remains available, but UI moves use
this new operation exclusively.
+10
View File
@@ -119,3 +119,13 @@ retain the last loaded gallery but disable mutations until an explicit reload
succeeds. Failed initial loads show a reload action instead of an empty manager. succeeds. Failed initial loads show a reload action instead of an empty manager.
Reload restores keyboard focus to the manager or the retry control. Pending Reload restores keyboard focus to the manager or the retry control. Pending
operations block conflicting writes and uploads, including drag-and-drop. operations block conflicting writes and uploads, including drag-and-drop.
## Atomic adjacent moves
Move up/down uses one server operation. The repository locks the owning Inventory
Item and its images, selects current order by `sortOrder`, `createdAt`, then `id`,
and uses the domain move rule to choose the new order. An actual move assigns
contiguous zero-based positions, resolving tied legacy/upload positions, within
one PostgreSQL transaction. Any failure rolls back all changes. Concurrent moves
within one item serialize; primary selection and image metadata are unchanged.
Boundary moves are no-ops. No database migration is required.
+11 -4
View File
@@ -1,6 +1,6 @@
# Implementation Status # Implementation Status
Last updated: 2026-09-10 Last updated: 2026-09-11
## Resume Here ## Resume Here
@@ -120,7 +120,7 @@ docker context use orbstack
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 application, and 65 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available. Verified locally: `npm test` passes 171 tests with no failures (24 domain, 80 application, and 67 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
### Public Seller Description ### Public Seller Description
@@ -169,7 +169,7 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
- Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery. - Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged. - Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged.
### Latest Vertical Slice: Image Manager Error Recovery ### Image Manager Error Recovery
- Loaded images and alt-text drafts survive action failures and refreshes. Initial-load errors offer Reload; refresh errors retain the gallery with writes disabled until reload succeeds. - Loaded images and alt-text drafts survive action failures and refreshes. Initial-load errors offer Reload; refresh errors retain the gallery with writes disabled until reload succeeds.
- Every action reconciles metadata even on failure, exposing partial multi-upload/reorder success without replaying mutations. Pending actions block overlapping writes and file drops. - Every action reconciles metadata even on failure, exposing partial multi-upload/reorder success without replaying mutations. Pending actions block overlapping writes and file drops.
@@ -177,7 +177,14 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
- Dark-theme coverage also fixed destructive-button contrast by separating its solid background token from error-text color. - Dark-theme coverage also fixed destructive-button contrast by separating its solid background token from error-text color.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 124 tests. No API or persistence changes. - Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 124 tests. No API or persistence changes.
Next bounded slice: atomic image reordering. Move up/down currently sends two separate order updates; failure between them can leave a partial reorder. Add a seller-scoped API operation that updates both positions transactionally, connect the controls to it, and verify rollback and ownership with integration tests. ### Latest Vertical Slice: Atomic Image Reordering
- Move up/down now calls a single authenticated, CSRF-protected `POST /api/v1/admin/images/:id/move` operation. The server derives the adjacent image from current persisted order.
- The repository locks the owning Inventory Item and its images, applies the domain move rule, and commits contiguous positions in one transaction. Ties are resolved deterministically; boundary moves are no-ops; primary state is preserved.
- Tests cover invalid requests, ownership, CSRF, missing images, boundary moves, tied positions, rollback after the first write, concurrent moves, and UI success/failure recovery.
- Validation: 171 API tests, 26 isolated PostgreSQL/Redis integration tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 126 tests. No migration required.
Next bounded slice: image mutation payload validation. The legacy absolute-order and alt-text endpoints currently pass unchecked request fields into the service. Add strict schemas and domain validation for these updates, provide clear UI validation feedback, and test malformed/oversized requests without changing valid image workflows.
## Next Milestone ## Next Milestone
+3
View File
@@ -11,3 +11,6 @@ export const createInventoryImageSchema = z.object({
sortOrder: z.int().nonnegative().optional(), sortOrder: z.int().nonnegative().optional(),
isPrimary: z.boolean().optional(), isPrimary: z.boolean().optional(),
}).strict(); }).strict();
export const moveInventoryImageSchema = z.object({
direction: z.enum(['up', 'down']),
}).strict();