feat: add admin image management
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 22:12:30 -04:00
parent 9704c2db02
commit efeca42d0f
5 changed files with 221 additions and 0 deletions
@@ -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));
},
};
}
@@ -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({
@@ -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();
});
}
@@ -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;
}
@@ -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'));
});