This commit is contained in:
@@ -198,6 +198,7 @@ async function startServer() {
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
imageDerivativeJobRepository,
|
||||
inventoryImageRepository,
|
||||
sellerSettingsRepository,
|
||||
}),
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -70,3 +70,20 @@ test('removes stored bytes when metadata persistence fails', async () => {
|
||||
);
|
||||
assert.deepEqual(deleted, [input.storageKey]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user