feat: add public image read boundary
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 21:58:30 -04:00
parent 5452bd5589
commit b00bd388a9
15 changed files with 223 additions and 9 deletions
+5
View File
@@ -6,6 +6,7 @@ import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js';
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js';
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js';
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
@@ -71,6 +72,10 @@ export function buildApp(dependencies) {
...dependencies,
prefix: '/api/v1',
});
app.register(registerPublicImageRoutes, {
...dependencies,
prefix: '/api/v1',
});
app.register(registerPublicSellerRoutes, {
...dependencies,
prefix: '/api/v1',
+8 -2
View File
@@ -20,6 +20,7 @@ import { createPasswordRecoveryService } from '../modules/auth/services/password
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js';
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js';
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js';
@@ -65,6 +66,10 @@ async function startServer() {
securityAuditRepository,
});
const passwordResetNotifier = createPasswordResetNotifier();
const publicImageService = createPublicImageService({
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
inventoryImageRepository,
});
const authenticationService = createAuthenticationService({
mfaService,
sessionRepository,
@@ -95,13 +100,14 @@ async function startServer() {
inventoryItemRepository,
productRepository,
}),
publicInventoryService: createPublicInventoryService({ inventoryItemRepository }),
publicImageService,
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
inventoryImageService: createInventoryImageService({
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
inventoryImageRepository,
}),
listingService: createListingService({ listingRepository, inventoryImageRepository }),
publicListingService: createPublicListingService({ listingRepository }),
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }),
searchService: createSearchService({ listingRepository }),
@@ -1,8 +1,9 @@
import { and, eq } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import {
inventoryImages,
inventoryItems,
listings,
products,
} from '../../../infrastructure/database/schema.js';
@@ -29,5 +30,51 @@ export function createInventoryImageRepository(db) {
return rows.length;
},
async listPublicByInventoryItemId(inventoryItemId) {
return db
.select({
id: inventoryImages.id,
mimeType: inventoryImages.mimeType,
width: inventoryImages.width,
height: inventoryImages.height,
altText: inventoryImages.altText,
sortOrder: inventoryImages.sortOrder,
isPrimary: inventoryImages.isPrimary,
processingStatus: inventoryImages.processingStatus,
})
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.where(and(
eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'),
))
.orderBy(inventoryImages.sortOrder);
},
async findPublicById(imageId) {
const [image] = await db
.select({
id: inventoryImages.id,
storageKey: inventoryImages.storageKey,
mimeType: inventoryImages.mimeType,
})
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.where(and(
eq(inventoryImages.id, imageId),
eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'),
));
return image ?? null;
},
};
}
@@ -0,0 +1,8 @@
export async function registerPublicImageRoutes(app, options) {
const { publicImageService } = options;
app.get('/public/images/:id', async (request, reply) => {
const image = await publicImageService.getContent(request.params.id);
return reply.type(image.mimeType).send(image.data);
});
}
@@ -0,0 +1,23 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPublicImageService({ inventoryImageRepository, imageStorage }) {
return {
async listForInventoryItem(inventoryItemId) {
const images = await inventoryImageRepository.listPublicByInventoryItemId(inventoryItemId);
return images.map((image) => ({
...image,
url: `/api/v1/public/images/${image.id}`,
}));
},
async getContent(imageId) {
const image = await inventoryImageRepository.findPublicById(imageId);
if (!image) {
throw createApplicationError('IMAGE_NOT_FOUND', 'Image was not found.', 404);
}
return { ...image, data: await imageStorage.get(image.storageKey) };
},
};
}
@@ -1,6 +1,8 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPublicInventoryService({ inventoryItemRepository }) {
const noOpPublicImageService = Object.freeze({ async listForInventoryItem() { return []; } });
export function createPublicInventoryService({ inventoryItemRepository, publicImageService = noOpPublicImageService }) {
return {
async list(input = {}) {
return inventoryItemRepository.searchPublic({
@@ -16,7 +18,10 @@ export function createPublicInventoryService({ inventoryItemRepository }) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
}
return item;
return {
...item,
images: await publicImageService.listForInventoryItem(item.id),
};
},
};
}
@@ -1,6 +1,8 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPublicListingService({ listingRepository }) {
const noOpPublicImageService = Object.freeze({ async listForInventoryItem() { return []; } });
export function createPublicListingService({ listingRepository, publicImageService = noOpPublicImageService }) {
return {
async list(input = {}) {
return listingRepository.searchPublic({
@@ -17,7 +19,10 @@ export function createPublicListingService({ listingRepository }) {
throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404);
}
return listing;
return {
...listing,
images: await publicImageService.listForInventoryItem(listing.inventoryItemId),
};
},
};
}