diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index a20f3dc..4b3c417 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -7,6 +7,7 @@ import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin- import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js'; +import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js'; import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js'; export function buildApp(dependencies) { @@ -55,6 +56,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1', }); + app.register(registerPublicProductRoutes, { + ...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 56dab36..930a5c6 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -24,6 +24,7 @@ import { createListingRepository } from '../modules/listings/repositories/listin import { createListingService } from '../modules/listings/services/listing-service.js'; import { createProductRepository } from '../modules/products/repositories/product-repository.js'; import { createProductService } from '../modules/products/services/product-service.js'; +import { createPublicProductService } from '../modules/products/services/public-product-service.js'; import { createSearchService } from '../modules/search/services/search-service.js'; function parsePort(value) { @@ -86,6 +87,7 @@ async function startServer() { }), listingService: createListingService({ listingRepository, inventoryImageRepository }), productService: createProductService({ productRepository }), + publicProductService: createPublicProductService({ productRepository }), searchService: createSearchService({ listingRepository }), }); diff --git a/apps/api/src/modules/products/repositories/product-repository.js b/apps/api/src/modules/products/repositories/product-repository.js index f08cd37..e0d2cb7 100644 --- a/apps/api/src/modules/products/repositories/product-repository.js +++ b/apps/api/src/modules/products/repositories/product-repository.js @@ -1,6 +1,10 @@ -import { and, asc, eq } from 'drizzle-orm'; +import { and, asc, eq, inArray } from 'drizzle-orm'; -import { products } from '../../../infrastructure/database/schema.js'; +import { + inventoryItems, + listings, + products, +} from '../../../infrastructure/database/schema.js'; export function createProductRepository(db) { return { @@ -18,6 +22,22 @@ export function createProductRepository(db) { return product ?? null; }, + async findPublicBySlug(slug) { + const [result] = await db + .select({ product: products }) + .from(products) + .innerJoin(inventoryItems, eq(inventoryItems.productId, products.id)) + .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) + .where(and( + eq(products.slug, slug), + eq(inventoryItems.status, 'ACTIVE'), + inArray(listings.status, ['AVAILABLE', 'PENDING']), + )) + .limit(1); + + return result?.product ?? null; + }, + async findBySellerIdAndSlug(sellerId, slug) { const [product] = await db .select() diff --git a/apps/api/src/modules/products/routes/public-product-routes.js b/apps/api/src/modules/products/routes/public-product-routes.js new file mode 100644 index 0000000..c70e83a --- /dev/null +++ b/apps/api/src/modules/products/routes/public-product-routes.js @@ -0,0 +1,10 @@ +import { toPublicProductDto } from '@pokemon-inventory/contracts/public-product'; + +export async function registerPublicProductRoutes(app, options) { + const { publicProductService } = options; + + app.get('/public/products/:slug', async (request) => { + const product = await publicProductService.getBySlug(request.params.slug); + return { data: toPublicProductDto(product) }; + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/products/services/public-product-service.js b/apps/api/src/modules/products/services/public-product-service.js new file mode 100644 index 0000000..fdfc506 --- /dev/null +++ b/apps/api/src/modules/products/services/public-product-service.js @@ -0,0 +1,15 @@ +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export function createPublicProductService({ productRepository }) { + return { + async getBySlug(slug) { + const product = await productRepository.findPublicBySlug(slug); + + if (!product) { + throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404); + } + + return product; + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/public-product-service.test.js b/apps/api/tests/application/public-product-service.test.js new file mode 100644 index 0000000..5bf078b --- /dev/null +++ b/apps/api/tests/application/public-product-service.test.js @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPublicProductService } from '../../src/modules/products/services/public-product-service.js'; + +test('returns only a publicly visible product', async () => { + const service = createPublicProductService({ + productRepository: { + async findPublicBySlug(slug) { + assert.equal(slug, 'pikachu-plush'); + return { + id: 'product-1', + sellerId: 'private-seller-id', + categoryId: 'category-1', + name: 'Pikachu Plush', + slug, + description: 'A friendly plush.', + }; + }, + }, + }); + + assert.deepEqual(await service.getBySlug('pikachu-plush'), { + id: 'product-1', + sellerId: 'private-seller-id', + categoryId: 'category-1', + name: 'Pikachu Plush', + slug: 'pikachu-plush', + description: 'A friendly plush.', + }); +}); + +test('returns a not-found application error for non-public products', async () => { + const service = createPublicProductService({ + productRepository: { async findPublicBySlug() { return null; } }, + }); + + await assert.rejects( + () => service.getBySlug('hidden-product'), + (error) => error.code === 'PRODUCT_NOT_FOUND' && error.statusCode === 404, + ); +}); \ No newline at end of file diff --git a/apps/api/tests/http/public-product-routes.test.js b/apps/api/tests/http/public-product-routes.test.js new file mode 100644 index 0000000..848fc50 --- /dev/null +++ b/apps/api/tests/http/public-product-routes.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +function createTestApp() { + return buildApp({ + publicProductService: { + async getBySlug(slug) { + if (slug !== 'pikachu-plush') { + const error = new Error('Product was not found.'); + error.code = 'PRODUCT_NOT_FOUND'; + error.statusCode = 404; + throw error; + } + + return { + id: 'product-1', + sellerId: 'private-seller-id', + categoryId: 'category-1', + name: 'Pikachu Plush', + slug, + description: 'A friendly plush.', + createdAt: new Date('2026-09-07T00:00:00.000Z'), + updatedAt: new Date('2026-09-07T00:00:00.000Z'), + }; + }, + }, + }); +} + +test('returns a public product without private seller identity', async (testContext) => { + const app = createTestApp(); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/public/products/pikachu-plush', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data, { + id: 'product-1', + categoryId: 'category-1', + name: 'Pikachu Plush', + slug: 'pikachu-plush', + description: 'A friendly plush.', + brand: null, + setName: null, + cardNumber: null, + eventName: null, + releaseYear: null, + createdAt: '2026-09-07T00:00:00.000Z', + updatedAt: '2026-09-07T00:00:00.000Z', + }); + assert.equal('sellerId' in response.json().data, false); +}); + +test('returns not found when a product is not publicly visible', async (testContext) => { + const app = createTestApp(); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/public/products/hidden-product', + }); + + assert.equal(response.statusCode, 404); + assert.equal(response.json().error.code, 'PRODUCT_NOT_FOUND'); +}); \ No newline at end of file diff --git a/apps/api/tests/integration/catalog-repositories.test.js b/apps/api/tests/integration/catalog-repositories.test.js index 3955eb7..9786a52 100644 --- a/apps/api/tests/integration/catalog-repositories.test.js +++ b/apps/api/tests/integration/catalog-repositories.test.js @@ -8,6 +8,7 @@ import { categories, conditionProfiles, conditions, + listings, mfaMethods, passwordResetTokens, securityAuditEvents, @@ -154,6 +155,41 @@ test('finds a product by both its seller and slug', async () => { ); }); +test('finds only products with active inventory and a public listing', async () => { + const { seller, category, condition } = await createCatalogPrerequisites(); + const product = await productRepository.create({ + sellerId: seller.id, + categoryId: category.id, + name: 'Public Pikachu Plush', + slug: 'public-pikachu-plush', + }); + const inventoryItem = await inventoryItemRepository.create({ + productId: product.id, + conditionId: condition.id, + }); + + await database.db.insert(listings).values({ + inventoryItemId: inventoryItem.id, + productName: product.name, + categoryId: category.id, + conditionId: condition.id, + askingPriceMinor: 2499, + status: 'PENDING', + }); + + assert.equal( + (await productRepository.findPublicBySlug(product.slug))?.id, + product.id, + ); + + await database.db + .update(listings) + .set({ status: 'CLOSED' }) + .where(sql`inventory_item_id = ${inventoryItem.id}`); + + assert.equal(await productRepository.findPublicBySlug(product.slug), null); +}); + test('retrieves only non-revoked, non-expired sessions', async () => { const { user, seller } = await createCatalogPrerequisites(); const tokenHash = 'a hashed session token'; diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 08b9295..99c48b7 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -90,6 +90,8 @@ docker context use orbstack - Listing publication rejects physical inventory in `SOLD` or `ARCHIVED` status with a conflict response. - Listing reads are seller-scoped and return only admin listing DTO fields. - Item-owned image metadata can be added through a seller-scoped, CSRF-protected admin route; publication uses the persisted image count. +- Public search is available at `GET /api/v1/public/search` through a normalized SearchService boundary with pagination, price filters, text matching, and sold-item visibility controls. +- 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. - `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. @@ -104,16 +106,17 @@ 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 70 tests; worker tests passed 2 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 86 tests; worker tests passed 2 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. ## Next Milestone -Complete the inventory/listing integrity boundary before starting pricing and acquisition. +Continue the public catalog boundary before starting the web UI. -1. Add a portable decoder/encoder and worker-backed derivative processing lifecycle. -2. Begin acquisition and pricing foundations after the listing/image boundary is complete. +1. Add public inventory/listing detail and collection endpoints around the existing SearchService. +2. Add public catalog response pagination metadata and stable product URL handling. +3. Begin Vue public catalog and admin workflow foundations after the API read boundary is complete. -Acceptance criteria: listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites. +Acceptance criteria: public reads expose only visible inventory and product fields while admin listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites. ## Overall Roadmap @@ -133,7 +136,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema - No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables. - Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing remains. -- Sales, acquisitions, pricing, concrete image rendering, search, queue transport, persistent worker jobs, web UI, CI, and deployment services remain unimplemented. +- Sales and acquisition domain foundations, concrete image rendering, normalized search, public search/product reads, queue transport, persistent worker jobs, web UI, CI, and deployment services remain incomplete to varying degrees. - `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes. ## Local Commands diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 42a7245..d6dce0c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -7,6 +7,7 @@ "./inventory": "./inventory.js", "./image": "./image.js", "./listing": "./listing.js", - "./product": "./product.js" + "./product": "./product.js", + "./public-product": "./public-product.js" } } \ No newline at end of file diff --git a/packages/contracts/public-product.js b/packages/contracts/public-product.js new file mode 100644 index 0000000..f3a1f3e --- /dev/null +++ b/packages/contracts/public-product.js @@ -0,0 +1,20 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toPublicProductDto(product) { + return { + id: product.id, + categoryId: product.categoryId, + name: product.name, + slug: product.slug, + description: product.description ?? null, + brand: product.brand ?? null, + setName: product.setName ?? null, + cardNumber: product.cardNumber ?? null, + eventName: product.eventName ?? null, + releaseYear: product.releaseYear ?? null, + createdAt: toIsoString(product.createdAt), + updatedAt: toIsoString(product.updatedAt), + }; +} \ No newline at end of file