This commit is contained in:
@@ -85,5 +85,29 @@ export function createImageDerivativeJobRepository(db) {
|
|||||||
|
|
||||||
return failed ?? null;
|
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;
|
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) {
|
async listPublicByInventoryItemId(inventoryItemId) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -77,4 +77,61 @@ export async function registerAdminImageRoutes(app, options) {
|
|||||||
|
|
||||||
return reply.code(201).send({ data: toAdminInventoryImageDto(image) });
|
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;
|
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'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user