diff --git a/apps/api/src/modules/inventory/repositories/image-derivative-job-repository.js b/apps/api/src/modules/inventory/repositories/image-derivative-job-repository.js index 5b4a664..d17f6f7 100644 --- a/apps/api/src/modules/inventory/repositories/image-derivative-job-repository.js +++ b/apps/api/src/modules/inventory/repositories/image-derivative-job-repository.js @@ -85,5 +85,29 @@ export function createImageDerivativeJobRepository(db) { return failed ?? null; }, + + async retryForImage(imageId) { + const [retried] = await db + .update(imageDerivativeJobs) + .set({ status: 'PENDING', error: null, derivatives: [], updatedAt: new Date() }) + .where(and( + eq(imageDerivativeJobs.inventoryImageId, imageId), + eq(imageDerivativeJobs.status, 'FAILED'), + )) + .returning(); + + if (retried) { + await db + .update(inventoryImages) + .set({ processingStatus: 'PENDING', updatedAt: new Date() }) + .where(eq(inventoryImages.id, imageId)); + } + + return retried ?? null; + }, + + async deleteForImage(imageId) { + await db.delete(imageDerivativeJobs).where(eq(imageDerivativeJobs.inventoryImageId, imageId)); + }, }; } \ No newline at end of file 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 ad0efcb..53b3e2b 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -32,6 +32,70 @@ export function createInventoryImageRepository(db) { return rows.length; }, + async findByIdForSeller(imageId, sellerId) { + const [image] = await db + .select({ image: inventoryImages }) + .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))); + + return image?.image ?? null; + }, + + async updateOrderForSeller(imageId, sortOrder, sellerId) { + const owned = await this.findByIdForSeller(imageId, sellerId); + if (!owned) return null; + + const [updated] = await db + .update(inventoryImages) + .set({ sortOrder, updatedAt: new Date() }) + .where(eq(inventoryImages.id, imageId)) + .returning(); + return updated ?? null; + }, + + async setPrimaryForSeller(imageId, sellerId) { + const owned = await this.findByIdForSeller(imageId, sellerId); + if (!owned) return null; + + return db.transaction(async (tx) => { + await tx + .update(inventoryImages) + .set({ isPrimary: false, updatedAt: new Date() }) + .where(eq(inventoryImages.inventoryItemId, owned.inventoryItemId)); + const [updated] = await tx + .update(inventoryImages) + .set({ isPrimary: true, updatedAt: new Date() }) + .where(eq(inventoryImages.id, imageId)) + .returning(); + return updated ?? null; + }); + }, + + async updateAltTextForSeller(imageId, altText, sellerId) { + const owned = await this.findByIdForSeller(imageId, sellerId); + if (!owned) return null; + + const [updated] = await db + .update(inventoryImages) + .set({ altText, updatedAt: new Date() }) + .where(eq(inventoryImages.id, imageId)) + .returning(); + return updated ?? null; + }, + + async deleteForSeller(imageId, sellerId) { + const owned = await this.findByIdForSeller(imageId, sellerId); + if (!owned) return null; + + const [deleted] = await db + .delete(inventoryImages) + .where(eq(inventoryImages.id, imageId)) + .returning(); + return deleted ?? null; + }, + async listPublicByInventoryItemId(inventoryItemId) { return db .select({ 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 76ce20d..db7ca60 100644 --- a/apps/api/src/modules/inventory/routes/admin-image-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-image-routes.js @@ -77,4 +77,61 @@ export async function registerAdminImageRoutes(app, options) { return reply.code(201).send({ data: toAdminInventoryImageDto(image) }); }); + + async function authorizeAdminMutation(request) { + const admin = await authenticateAdmin(request); + if (!admin?.sellerId) { + throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + } + + await authorizeUnsafeRequest?.( + request.cookies[sessionCookieName], + request.headers['x-csrf-token'], + ); + return admin; + } + + app.patch('/images/:id/order', async (request) => { + const admin = await authorizeAdminMutation(request); + const image = await inventoryImageService.updateOrder({ + imageId: request.params.id, + sortOrder: request.body?.sortOrder, + sellerId: admin.sellerId, + }); + return { data: toAdminInventoryImageDto(image) }; + }); + + app.post('/images/:id/primary', async (request) => { + const admin = await authorizeAdminMutation(request); + const image = await inventoryImageService.setPrimary({ + imageId: request.params.id, + sellerId: admin.sellerId, + }); + return { data: toAdminInventoryImageDto(image) }; + }); + + app.patch('/images/:id/alt-text', async (request) => { + const admin = await authorizeAdminMutation(request); + const image = await inventoryImageService.updateAltText({ + imageId: request.params.id, + altText: request.body?.altText ?? null, + sellerId: admin.sellerId, + }); + return { data: toAdminInventoryImageDto(image) }; + }); + + app.post('/images/:id/retry', async (request) => { + const admin = await authorizeAdminMutation(request); + const image = await inventoryImageService.retry({ + imageId: request.params.id, + sellerId: admin.sellerId, + }); + return { data: toAdminInventoryImageDto(image) }; + }); + + app.delete('/images/:id', async (request, reply) => { + const admin = await authorizeAdminMutation(request); + await inventoryImageService.delete({ imageId: request.params.id, sellerId: admin.sellerId }); + return reply.code(204).send(); + }); } \ No newline at end of file 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 ca75443..efb2830 100644 --- a/apps/api/src/modules/inventory/services/inventory-image-service.js +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -37,5 +37,44 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer throw error; } }, + + async updateOrder({ imageId, sortOrder, sellerId }) { + return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId)); + }, + + async setPrimary({ imageId, sellerId }) { + return requireOwnedImage(inventoryImageRepository.setPrimaryForSeller(imageId, sellerId)); + }, + + async updateAltText({ imageId, altText, sellerId }) { + return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, altText, sellerId)); + }, + + async delete({ imageId, sellerId }) { + const image = await inventoryImageRepository.findByIdForSeller(imageId, sellerId); + if (!image) throw imageNotFoundError(); + await imageDerivativeJobRepository?.deleteForImage(imageId); + await inventoryImageRepository.deleteForSeller(imageId, sellerId); + await imageStorage.delete(image.storageKey); + return image; + }, + + async retry({ imageId, sellerId }) { + const image = await inventoryImageRepository.findByIdForSeller(imageId, sellerId); + if (!image) throw imageNotFoundError(); + const job = await imageDerivativeJobRepository?.retryForImage(imageId); + if (!job) throw createApplicationError('IMAGE_RETRY_NOT_AVAILABLE', 'Image retry is not available.', 409); + return image; + }, }; +} + +function imageNotFoundError() { + return createApplicationError('IMAGE_NOT_FOUND', 'Image was not found.', 404); +} + +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-management.test.js b/apps/api/tests/application/inventory-image-management.test.js new file mode 100644 index 0000000..cc3cfbb --- /dev/null +++ b/apps/api/tests/application/inventory-image-management.test.js @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js'; + +function createService() { + const calls = []; + const service = createInventoryImageService({ + inventoryImageRepository: { + async findByIdForSeller(id, sellerId) { calls.push(['find', id, sellerId]); return { id, inventoryItemId: 'item-1', storageKey: 'image.jpg' }; }, + async updateOrderForSeller(id, sortOrder, sellerId) { calls.push(['order', id, sortOrder, sellerId]); return { id, sortOrder }; }, + async setPrimaryForSeller(id, sellerId) { calls.push(['primary', id, sellerId]); return { id, isPrimary: true }; }, + async updateAltTextForSeller(id, altText, sellerId) { calls.push(['alt', id, altText, sellerId]); return { id, altText }; }, + async deleteForSeller(id, sellerId) { calls.push(['delete', id, sellerId]); return { id, storageKey: 'image.jpg' }; }, + }, + imageDerivativeJobRepository: { + async retryForImage(id) { calls.push(['retry', id]); return { id: 'job-1' }; }, + async deleteForImage(id) { calls.push(['delete-job', id]); }, + }, + imageStorage: { async delete(key) { calls.push(['storage-delete', key]); } }, + }); + return { service, calls }; +} + +test('manages owned image order, primary state, alt text, retry, and deletion', async () => { + const { service, calls } = createService(); + + assert.deepEqual(await service.updateOrder({ imageId: 'image-1', sortOrder: 2, sellerId: 'seller-1' }), { id: 'image-1', sortOrder: 2 }); + assert.deepEqual(await service.setPrimary({ imageId: 'image-1', sellerId: 'seller-1' }), { id: 'image-1', isPrimary: true }); + assert.deepEqual(await service.updateAltText({ imageId: 'image-1', altText: 'Front', sellerId: 'seller-1' }), { id: 'image-1', altText: 'Front' }); + assert.deepEqual(await service.retry({ imageId: 'image-1', sellerId: 'seller-1' }), { id: 'image-1', inventoryItemId: 'item-1', storageKey: 'image.jpg' }); + await service.delete({ imageId: 'image-1', sellerId: 'seller-1' }); + + assert.ok(calls.some(([action]) => action === 'retry')); + assert.ok(calls.some(([action]) => action === 'delete-job')); + assert.ok(calls.some(([action]) => action === 'storage-delete')); +});