From 206bb18da606342380c7ae98706661c9baf116ae Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 19:34:38 -0400 Subject: [PATCH] feat: add public inventory catalog boundary --- apps/api/src/app/build-app.js | 5 ++ apps/api/src/app/server.js | 2 + .../repositories/inventory-item-repository.js | 78 +++++++++++++++++- .../routes/public-inventory-routes.js | 34 ++++++++ .../services/public-inventory-service.js | 22 +++++ .../public-inventory-service.test.js | 31 +++++++ .../http/public-inventory-routes.test.js | 82 +++++++++++++++++++ .../integration/listing-repository.test.js | 25 ++++++ docs/API.md | 1 + docs/IMPLEMENTATION-STATUS.md | 3 +- packages/contracts/package.json | 1 + packages/contracts/public-inventory.js | 19 +++++ 12 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/modules/inventory/routes/public-inventory-routes.js create mode 100644 apps/api/src/modules/inventory/services/public-inventory-service.js create mode 100644 apps/api/tests/application/public-inventory-service.test.js create mode 100644 apps/api/tests/http/public-inventory-routes.test.js create mode 100644 packages/contracts/public-inventory.js diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 65cf3d8..ceb9d00 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -5,6 +5,7 @@ import multipart from '@fastify/multipart'; 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 { 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'; @@ -65,6 +66,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1', }); + app.register(registerPublicInventoryRoutes, { + ...dependencies, + prefix: '/api/v1', + }); return app; } \ No newline at end of file diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 7508b08..b90d22e 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -16,6 +16,7 @@ import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js'; 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 { 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'; @@ -82,6 +83,7 @@ async function startServer() { inventoryItemRepository, productRepository, }), + publicInventoryService: createPublicInventoryService({ inventoryItemRepository }), inventoryImageService: createInventoryImageService({ imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), inventoryImageRepository, diff --git a/apps/api/src/modules/inventory/repositories/inventory-item-repository.js b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js index d4f2e19..1e52256 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-item-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js @@ -1,6 +1,10 @@ -import { asc, eq } from 'drizzle-orm'; +import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'; -import { inventoryItems } from '../../../infrastructure/database/schema.js'; +import { + inventoryItems, + listings, + products, +} from '../../../infrastructure/database/schema.js'; export function createInventoryItemRepository(db) { return { @@ -20,5 +24,75 @@ export function createInventoryItemRepository(db) { .where(eq(inventoryItems.productId, productId)) .orderBy(asc(inventoryItems.createdAt)); }, + + async searchPublic({ page = 1, limit = 20 } = {}) { + const conditions = [ + eq(inventoryItems.status, 'ACTIVE'), + inArray(listings.status, ['AVAILABLE', 'PENDING']), + ]; + const offset = (page - 1) * limit; + + const [countResult, results] = await Promise.all([ + db + .select({ total: count() }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) + .where(and(...conditions)), + db + .select({ + id: inventoryItems.id, + productId: products.id, + productName: products.name, + productSlug: products.slug, + categoryId: products.categoryId, + conditionId: inventoryItems.conditionId, + askingPriceMinor: listings.askingPriceMinor, + listingStatus: listings.status, + listingNotes: listings.notes, + createdAt: inventoryItems.createdAt, + updatedAt: inventoryItems.updatedAt, + }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) + .where(and(...conditions)) + .orderBy(desc(inventoryItems.createdAt)) + .limit(limit) + .offset(offset), + ]); + + return { + items: results, + total: Number(countResult[0]?.total ?? 0), + }; + }, + + async findPublicById(inventoryItemId) { + const [item] = await db + .select({ + id: inventoryItems.id, + productId: products.id, + productName: products.name, + productSlug: products.slug, + categoryId: products.categoryId, + conditionId: inventoryItems.conditionId, + askingPriceMinor: listings.askingPriceMinor, + listingStatus: listings.status, + listingNotes: listings.notes, + createdAt: inventoryItems.createdAt, + updatedAt: inventoryItems.updatedAt, + }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) + .where(and( + eq(inventoryItems.id, inventoryItemId), + eq(inventoryItems.status, 'ACTIVE'), + inArray(listings.status, ['AVAILABLE', 'PENDING']), + )); + + return item ?? null; + }, }; } \ No newline at end of file diff --git a/apps/api/src/modules/inventory/routes/public-inventory-routes.js b/apps/api/src/modules/inventory/routes/public-inventory-routes.js new file mode 100644 index 0000000..76d366f --- /dev/null +++ b/apps/api/src/modules/inventory/routes/public-inventory-routes.js @@ -0,0 +1,34 @@ +import { toPublicInventoryItemDto } from '@pokemon-inventory/contracts/public-inventory'; + +function coerceNumber(value, fallback) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +export async function registerPublicInventoryRoutes(app, options) { + const { publicInventoryService } = options; + + app.get('/public/inventory', async (request) => { + const result = await publicInventoryService.list({ + page: coerceNumber(request.query.page, 1), + limit: coerceNumber(request.query.limit, 20), + }); + + return { + data: { + ...result, + items: result.items.map(toPublicInventoryItemDto), + }, + }; + }); + + app.get('/public/inventory/:id', async (request) => ({ + data: toPublicInventoryItemDto( + await publicInventoryService.getById(request.params.id), + ), + })); +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/public-inventory-service.js b/apps/api/src/modules/inventory/services/public-inventory-service.js new file mode 100644 index 0000000..2112a5f --- /dev/null +++ b/apps/api/src/modules/inventory/services/public-inventory-service.js @@ -0,0 +1,22 @@ +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export function createPublicInventoryService({ inventoryItemRepository }) { + return { + async list(input = {}) { + return inventoryItemRepository.searchPublic({ + page: input.page, + limit: input.limit, + }); + }, + + async getById(inventoryItemId) { + const item = await inventoryItemRepository.findPublicById(inventoryItemId); + + if (!item) { + throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); + } + + return item; + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/public-inventory-service.test.js b/apps/api/tests/application/public-inventory-service.test.js new file mode 100644 index 0000000..b05908d --- /dev/null +++ b/apps/api/tests/application/public-inventory-service.test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPublicInventoryService } from '../../src/modules/inventory/services/public-inventory-service.js'; + +test('lists visible public inventory through the repository boundary', async () => { + const service = createPublicInventoryService({ + inventoryItemRepository: { + async searchPublic(filters) { + assert.deepEqual(filters, { page: 2, limit: 5 }); + return { items: [{ id: 'inventory-1' }], total: 1 }; + }, + }, + }); + + assert.deepEqual(await service.list({ page: 2, limit: 5 }), { + items: [{ id: 'inventory-1' }], + total: 1, + }); +}); + +test('returns a not-found error for hidden public inventory', async () => { + const service = createPublicInventoryService({ + inventoryItemRepository: { async findPublicById() { return null; } }, + }); + + await assert.rejects( + () => service.getById('inventory-1'), + (error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404, + ); +}); diff --git a/apps/api/tests/http/public-inventory-routes.test.js b/apps/api/tests/http/public-inventory-routes.test.js new file mode 100644 index 0000000..05eb613 --- /dev/null +++ b/apps/api/tests/http/public-inventory-routes.test.js @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +function createTestApp() { + return buildApp({ + publicInventoryService: { + async list() { + return { + items: [{ + id: 'inventory-1', + productId: 'product-1', + productName: 'Pikachu Plush', + productSlug: 'pikachu-plush', + categoryId: 'category-1', + conditionId: 'condition-1', + askingPriceMinor: 2499, + listingStatus: 'PENDING', + listingNotes: 'Available soon.', + }], + pagination: { page: 1, limit: 20, total: 1, totalPages: 1 }, + }; + }, + async getById(id) { + if (id !== 'inventory-1') { + const error = new Error('Inventory item was not found.'); + error.code = 'INVENTORY_NOT_FOUND'; + error.statusCode = 404; + throw error; + } + + return { + id, + productId: 'product-1', + productName: 'Pikachu Plush', + productSlug: 'pikachu-plush', + categoryId: 'category-1', + conditionId: 'condition-1', + askingPriceMinor: 2499, + listingStatus: 'PENDING', + listingNotes: 'Available soon.', + notes: 'PRIVATE ITEM NOTE', + }; + }, + }, + }); +} + +test('returns public inventory collection data without private item notes', async (testContext) => { + const app = createTestApp(); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/public/inventory?page=1&limit=20', + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().data.items[0].productSlug, 'pikachu-plush'); + assert.equal('notes' in response.json().data.items[0], false); + assert.equal(response.json().data.pagination.total, 1); +}); + +test('returns public inventory detail or not found', async (testContext) => { + const app = createTestApp(); + testContext.after(() => app.close()); + + const detailResponse = await app.inject({ + method: 'GET', + url: '/api/v1/public/inventory/inventory-1', + }); + const missingResponse = await app.inject({ + method: 'GET', + url: '/api/v1/public/inventory/hidden-inventory', + }); + + assert.equal(detailResponse.statusCode, 200); + assert.equal('notes' in detailResponse.json().data, false); + assert.equal(missingResponse.statusCode, 404); + assert.equal(missingResponse.json().error.code, 'INVENTORY_NOT_FOUND'); +}); diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js index 3d2ee51..8266d12 100644 --- a/apps/api/tests/integration/listing-repository.test.js +++ b/apps/api/tests/integration/listing-repository.test.js @@ -15,6 +15,7 @@ import { users, } from '../../src/infrastructure/database/schema.js'; import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js'; +import { createInventoryItemRepository } from '../../src/modules/inventory/repositories/inventory-item-repository.js'; import { createListingRepository } from '../../src/modules/listings/repositories/listing-repository.js'; const connectionString = process.env.TEST_DATABASE_URL; @@ -29,6 +30,7 @@ if (!new URL(connectionString).pathname.endsWith('_test')) { const database = createDatabase(connectionString); const listingRepository = createListingRepository(database.db); +const inventoryItemRepository = createInventoryItemRepository(database.db); const inventoryImageRepository = createInventoryImageRepository(database.db); async function createInventoryFixture() { @@ -168,6 +170,29 @@ test('searches public listings with pagination and hides sold items by default', assert.equal(included.items[0].status, 'CLOSED'); }); +test('searches public inventory with product and listing fields', async () => { + const { category, condition, inventoryItem, product } = await createInventoryFixture(); + + await listingRepository.create({ + inventoryItemId: inventoryItem.id, + productName: product.name, + categoryId: category.id, + conditionId: condition.id, + askingPriceMinor: 2499, + status: 'AVAILABLE', + }); + + const visible = await inventoryItemRepository.searchPublic({ page: 1, limit: 10 }); + + assert.equal(visible.total, 1); + assert.equal(visible.items[0].productSlug, product.slug); + assert.equal(visible.items[0].askingPriceMinor, 2499); + assert.equal((await inventoryItemRepository.findPublicById(inventoryItem.id)).id, inventoryItem.id); + + await database.db.update(inventoryItems).set({ status: 'SOLD' }).where(sql`id = ${inventoryItem.id}`); + assert.equal(await inventoryItemRepository.findPublicById(inventoryItem.id), null); +}); + test('persists image metadata only for the owning seller and counts item images', async () => { const { seller, inventoryItem } = await createInventoryFixture(); const image = await inventoryImageRepository.createForSeller({ diff --git a/docs/API.md b/docs/API.md index c1906ca..43cbc6c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -18,6 +18,7 @@ Collections include pagination metadata. GET /api/v1/public/search GET /api/v1/public/products/:slug GET /api/v1/public/inventory +GET /api/v1/public/inventory/:id GET /api/v1/public/listings GET /api/v1/public/listings/:id GET /api/v1/public/sellers diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index f9a6ae1..3a3280e 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -94,6 +94,7 @@ docker context use orbstack - Public search responses include `items` plus `pagination` metadata (`page`, `limit`, `total`, and `totalPages`); pending listings are visible by default and sold listings require an explicit filter. - Public product pages are available at `GET /api/v1/public/products/:slug`; responses use a public DTO and require an active physical item with an available or pending listing. - Public listing collection and detail reads are available at `GET /api/v1/public/listings` and `GET /api/v1/public/listings/:id`; collection responses include pagination metadata and detail reads hide internal inventory identity. +- Public inventory collection and detail reads are available at `GET /api/v1/public/inventory` and `GET /api/v1/public/inventory/:id`; responses join only active physical items to available or pending listings and exclude private item notes. - `ImageStorage`, traversal-safe local storage, multipart upload transport, and binary signature validation are implemented; derivative processing remains. - Derivative orchestration now defines thumbnail, medium, and large WebP storage outputs behind an injected renderer; the concrete worker-backed renderer remains. - The worker owns an image derivative job runner with explicit processing, completion, and failure outcomes; queue transport and persistent job records remain. @@ -108,7 +109,7 @@ docker context use orbstack | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | -Latest result: `npm test` passed 91 tests; worker tests passed 2 tests; `npm run test:integration` passed 16 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 95 tests; worker tests passed 2 tests; `npm run test:integration` passed 17 tests against OrbStack PostgreSQL and Redis. ## Next Milestone diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f3f8cfa..9c77738 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -9,6 +9,7 @@ "./listing": "./listing.js", "./product": "./product.js", "./public-listing": "./public-listing.js", + "./public-inventory": "./public-inventory.js", "./public-product": "./public-product.js" } } \ No newline at end of file diff --git a/packages/contracts/public-inventory.js b/packages/contracts/public-inventory.js new file mode 100644 index 0000000..4eb7feb --- /dev/null +++ b/packages/contracts/public-inventory.js @@ -0,0 +1,19 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toPublicInventoryItemDto(item) { + return { + id: item.id, + productId: item.productId, + productName: item.productName, + productSlug: item.productSlug, + categoryId: item.categoryId, + conditionId: item.conditionId, + askingPriceMinor: item.askingPriceMinor, + listingStatus: item.listingStatus, + listingNotes: item.listingNotes ?? null, + createdAt: toIsoString(item.createdAt), + updatedAt: toIsoString(item.updatedAt), + }; +} \ No newline at end of file