diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 5bc630e..9e234fa 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -198,6 +198,7 @@ async function startServer() { imageStorage: createLocalImageStorage(imageStorageRoot), imageDerivativeJobRepository, inventoryImageRepository, + sellerSettingsRepository, }), listingService: createListingService({ listingRepository, 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 db95e80..1ba0c62 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -1,5 +1,6 @@ import { moveImageInOrder } from '../domain/image-order.js'; import { and, eq, inArray } from 'drizzle-orm'; +import { createApplicationError } from '../../../shared/errors/application-error.js'; import { imageDerivativeJobs, @@ -24,6 +25,24 @@ export function createInventoryImageRepository(db) { return createdImage; }, + async createForSellerWithLimit(image, sellerId, maxImages) { + return db.transaction(async (tx) => { + const [ownedItem] = await tx.select({ id: inventoryItems.id }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId))) + .for('update', { of: inventoryItems }); + if (!ownedItem) return null; + const existing = await tx.select({ id: inventoryImages.id }).from(inventoryImages) + .where(eq(inventoryImages.inventoryItemId, image.inventoryItemId)); + if (existing.length >= maxImages) { + throw createApplicationError('IMAGE_COUNT_LIMIT_REACHED', 'Maximum images per item reached.', 409); + } + const [createdImage] = await tx.insert(inventoryImages).values(image).returning(); + return createdImage; + }); + }, + async countByInventoryItemId(inventoryItemId) { const rows = await db .select({ id: inventoryImages.id }) 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 c6b6fa2..e8e45ab 100644 --- a/apps/api/src/modules/inventory/services/inventory-image-service.js +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -2,13 +2,20 @@ import { validateImageMove } from '../domain/image-order.js'; import { createApplicationError } from '../../../shared/errors/application-error.js'; import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js'; -export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector }) { +export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector, sellerSettingsRepository }) { return { async create(input) { validateInventoryImageMetadata(input); - const image = await inventoryImageRepository.createForSeller( + const settingsRecord = await sellerSettingsRepository?.findBySellerId(input.sellerId); + const maxImages = settingsRecord?.settings?.inventory?.maxImagesPerItem ?? 10; + if (!Number.isInteger(maxImages) || maxImages < 1 || maxImages > 100) { + throw createApplicationError('IMAGE_COUNT_LIMIT_INVALID', 'Image count limit is invalid.', 500); + } + const createImage = inventoryImageRepository.createForSellerWithLimit ?? inventoryImageRepository.createForSeller; + const image = await createImage.call(inventoryImageRepository, { ...input, processingStatus: 'PENDING' }, input.sellerId, + maxImages, ); if (!image) { @@ -99,4 +106,4 @@ async function requireOwnedImage(imagePromise) { const image = await imagePromise; if (!image) throw imageNotFoundError(); return image; -} \ No newline at end of file +} diff --git a/apps/api/tests/application/inventory-image-service.test.js b/apps/api/tests/application/inventory-image-service.test.js index 64f3741..ccd628c 100644 --- a/apps/api/tests/application/inventory-image-service.test.js +++ b/apps/api/tests/application/inventory-image-service.test.js @@ -69,4 +69,21 @@ test('removes stored bytes when metadata persistence fails', async () => { (error) => error.code === 'INVENTORY_ITEM_NOT_FOUND', ); assert.deepEqual(deleted, [input.storageKey]); -}); \ No newline at end of file +}); + +test('passes the seller image limit to the atomic repository operation', async () => { + let limit; + const service = createInventoryImageService({ + sellerSettingsRepository: { async findBySellerId() { return { settings: { inventory: { maxImagesPerItem: 2 } } }; } }, + inventoryImageRepository: { + async createForSellerWithLimit(image, sellerId, maxImages) { + assert.equal(sellerId, 'seller-id'); + limit = maxImages; + return { id: 'image-1', ...image }; + }, + }, + }); + const image = await service.create({ ...input, data: undefined }); + assert.equal(image.id, 'image-1'); + assert.equal(limit, 2); +}); diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js index c9ba75b..f94a4d6 100644 --- a/apps/api/tests/integration/listing-repository.test.js +++ b/apps/api/tests/integration/listing-repository.test.js @@ -244,6 +244,17 @@ test('persists image metadata only for the owning seller and counts item images' ); }); +test('enforces per-item image limits across concurrent inserts', async () => { + const { seller, inventoryItem } = await createInventoryFixture(); + const image = (suffix) => ({ inventoryItemId: inventoryItem.id, storageKey: `inventory/${suffix}.jpg`, originalFilename: `${suffix}.jpg`, mimeType: 'image/jpeg', fileSize: 4, width: 2, height: 2, sortOrder: 0, isPrimary: false, processingStatus: 'PENDING' }); + const results = await Promise.allSettled([ + inventoryImageRepository.createForSellerWithLimit(image('a'), seller.id, 1), + inventoryImageRepository.createForSellerWithLimit(image('b'), seller.id, 1), + ]); + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + assert.equal(results.filter((result) => result.status === 'rejected' && result.reason.code === 'IMAGE_COUNT_LIMIT_REACHED').length, 1); +}); + test('reads and updates listings only through the owning seller scope', async () => { const { seller, category, condition, inventoryItem } = await createInventoryFixture(); diff --git a/docs/IMAGES.md b/docs/IMAGES.md index 42247dc..6ac3f72 100644 --- a/docs/IMAGES.md +++ b/docs/IMAGES.md @@ -25,7 +25,7 @@ Support: - delete - add images later -Image count is configurable. +Image count is configurable per seller through `inventory.maxImagesPerItem` (default 10). The API enforces this limit atomically while locking the physical inventory item, so concurrent uploads cannot exceed the configured cap. ## Storage diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 13fd183..bb162b9 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -266,6 +266,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro - Storybook now installs a lightweight router mirroring the app's named routes plus a mobile-viewport-forcing test-runner hook, enabling isolated page stories (including the public catalog) that use `useRoute`/`useRouter` or narrow-viewport CSS breakpoints. - The public catalog now has its own Storybook coverage, including an interaction test proving the mobile filter disclosure is keyboard-operable and correctly hides/reveals filter controls via `aria-expanded`. This review also found and fixed a real color-contrast failure in the catalog error-state text. - `ListingDetailPage`, `ProductDetailPage`, and `AdminImagePage` now have Storybook coverage. Adding it surfaced and fixed a color-contrast bug shared by all three: the "Back to catalog" link, pagination controls, and the selected image-gallery thumbnail were using the non-text-safe `--color-interactive` token instead of `--color-interactive-text`. +- Per-item image limits are now enforced atomically for seller image creation and uploads, using `inventory.maxImagesPerItem` (default 10); concurrent inserts are serialized on the physical inventory item and return `IMAGE_COUNT_LIMIT_REACHED` at the configured cap. ## Local Commands @@ -287,4 +288,4 @@ Then migrate it using its `TEST_DATABASE_URL` value as `DATABASE_URL`, and run i ## Suggested Resume Prompt -`Read docs/IMPLEMENTATION-STATUS.md and continue the next bounded milestone. Verify the existing baseline first, follow AGENTS.md, and keep the change focused with tests.` \ No newline at end of file +`Read docs/IMPLEMENTATION-STATUS.md and continue the next bounded milestone. Verify the existing baseline first, follow AGENTS.md, and keep the change focused with tests.`