diff --git a/apps/api/src/modules/inventory/domain/image-order.js b/apps/api/src/modules/inventory/domain/image-order.js new file mode 100644 index 0000000..f27adad --- /dev/null +++ b/apps/api/src/modules/inventory/domain/image-order.js @@ -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; +} diff --git a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js index 4e4ddf7..db95e80 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -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) { diff --git a/apps/api/src/modules/inventory/routes/admin-image-routes.js b/apps/api/src/modules/inventory/routes/admin-image-routes.js index 4721adf..f1c63a1 100644 --- a/apps/api/src/modules/inventory/routes/admin-image-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-image-routes.js @@ -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({ diff --git a/apps/api/src/modules/inventory/services/inventory-image-service.js b/apps/api/src/modules/inventory/services/inventory-image-service.js index 7275db3..c3807aa 100644 --- a/apps/api/src/modules/inventory/services/inventory-image-service.js +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -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)); }, diff --git a/apps/api/tests/application/inventory-image-management.test.js b/apps/api/tests/application/inventory-image-management.test.js index cc3cfbb..e5f54e8 100644 --- a/apps/api/tests/application/inventory-image-management.test.js +++ b/apps/api/tests/application/inventory-image-management.test.js @@ -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 === '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' }); +}); diff --git a/apps/api/tests/domain/image-order.test.js b/apps/api/tests/domain/image-order.test.js new file mode 100644 index 0000000..caf92e7 --- /dev/null +++ b/apps/api/tests/domain/image-order.test.js @@ -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' }); +}); diff --git a/apps/api/tests/http/image-move-routes.test.js b/apps/api/tests/http/image-move-routes.test.js new file mode 100644 index 0000000..d3baca7 --- /dev/null +++ b/apps/api/tests/http/image-move-routes.test.js @@ -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); + } +}); diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js index ad49b8b..c9ba75b 100644 --- a/apps/api/tests/integration/listing-repository.test.js +++ b/apps/api/tests/integration/listing-repository.test.js @@ -273,3 +273,65 @@ test('reads and updates listings only through the owning seller scope', async () const archivedListing = await listingRepository.updateStatus(createdListing.id, '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]); +}); diff --git a/apps/web/src/composables/useAdminImages.js b/apps/web/src/composables/useAdminImages.js index e470737..7679582 100644 --- a/apps/web/src/composables/useAdminImages.js +++ b/apps/web/src/composables/useAdminImages.js @@ -3,7 +3,7 @@ import { ref } from 'vue'; import { deleteImage, listAdminImages, - reorderImage, + moveImage, retryImageProcessing, setPrimaryImage, 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 { images, @@ -98,7 +86,7 @@ export function useAdminImages(inventoryItemId) { hasLoaded, load, upload, - move: moveImage, + move: (imageId, direction) => apply((token) => moveImage(imageId, direction, token)), setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)), updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)), retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)), diff --git a/apps/web/src/pages/AdminImagePage.stories.js b/apps/web/src/pages/AdminImagePage.stories.js index b7ffc47..7d0b260 100644 --- a/apps/web/src/pages/AdminImagePage.stories.js +++ b/apps/web/src/pages/AdminImagePage.stories.js @@ -10,6 +10,7 @@ export default { let reads = 0; let writes = 0; 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 fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 }); globalThis.fetch = async (url, options = {}) => { @@ -21,6 +22,15 @@ export default { return reply(images); } 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 === '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' }]; @@ -137,3 +147,29 @@ export const PartialUploadFailure = { 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')); + }, +}; diff --git a/apps/web/src/services/admin-image-service.js b/apps/web/src/services/admin-image-service.js index b96d7e1..493116b 100644 --- a/apps/web/src/services/admin-image-service.js +++ b/apps/web/src/services/admin-image-service.js @@ -80,3 +80,11 @@ export async function listAdminImages(inventoryItemId) { 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 }), + }); +} diff --git a/docs/API.md b/docs/API.md index f5c426c..daee988 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 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. + +`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. diff --git a/docs/IMAGES.md b/docs/IMAGES.md index 4c4b734..ac15d26 100644 --- a/docs/IMAGES.md +++ b/docs/IMAGES.md @@ -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. Reload restores keyboard focus to the manager or the retry control. Pending 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. diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 7adf667..e30954c 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -1,6 +1,6 @@ # Implementation Status -Last updated: 2026-09-10 +Last updated: 2026-09-11 ## Resume Here @@ -120,7 +120,7 @@ docker context use orbstack | 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 | -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 @@ -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. - 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. - 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. - 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 diff --git a/packages/validation/image.js b/packages/validation/image.js index cbecde8..7f29edf 100644 --- a/packages/validation/image.js +++ b/packages/validation/image.js @@ -10,4 +10,7 @@ export const createInventoryImageSchema = z.object({ altText: z.string().trim().max(1000).optional(), sortOrder: z.int().nonnegative().optional(), isPrimary: z.boolean().optional(), -}).strict(); \ No newline at end of file +}).strict(); +export const moveInventoryImageSchema = z.object({ + direction: z.enum(['up', 'down']), +}).strict();