feat: add public product catalog boundary

This commit is contained in:
2026-09-08 18:20:04 -04:00
parent f29629c712
commit 43f4f69894
11 changed files with 233 additions and 9 deletions
+5
View File
@@ -7,6 +7,7 @@ import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-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'; import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
export function buildApp(dependencies) { export function buildApp(dependencies) {
@@ -55,6 +56,10 @@ export function buildApp(dependencies) {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: '/api/v1',
}); });
app.register(registerPublicProductRoutes, {
...dependencies,
prefix: '/api/v1',
});
return app; return app;
} }
+2
View File
@@ -24,6 +24,7 @@ import { createListingRepository } from '../modules/listings/repositories/listin
import { createListingService } from '../modules/listings/services/listing-service.js'; import { createListingService } from '../modules/listings/services/listing-service.js';
import { createProductRepository } from '../modules/products/repositories/product-repository.js'; import { createProductRepository } from '../modules/products/repositories/product-repository.js';
import { createProductService } from '../modules/products/services/product-service.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'; import { createSearchService } from '../modules/search/services/search-service.js';
function parsePort(value) { function parsePort(value) {
@@ -86,6 +87,7 @@ async function startServer() {
}), }),
listingService: createListingService({ listingRepository, inventoryImageRepository }), listingService: createListingService({ listingRepository, inventoryImageRepository }),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }),
searchService: createSearchService({ listingRepository }), searchService: createSearchService({ listingRepository }),
}); });
@@ -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) { export function createProductRepository(db) {
return { return {
@@ -18,6 +22,22 @@ export function createProductRepository(db) {
return product ?? null; 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) { async findBySellerIdAndSlug(sellerId, slug) {
const [product] = await db const [product] = await db
.select() .select()
@@ -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) };
});
}
@@ -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;
},
};
}
@@ -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,
);
});
@@ -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');
});
@@ -8,6 +8,7 @@ import {
categories, categories,
conditionProfiles, conditionProfiles,
conditions, conditions,
listings,
mfaMethods, mfaMethods,
passwordResetTokens, passwordResetTokens,
securityAuditEvents, 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 () => { test('retrieves only non-revoked, non-expired sessions', async () => {
const { user, seller } = await createCatalogPrerequisites(); const { user, seller } = await createCatalogPrerequisites();
const tokenHash = 'a hashed session token'; const tokenHash = 'a hashed session token';
+9 -6
View File
@@ -90,6 +90,8 @@ docker context use orbstack
- Listing publication rejects physical inventory in `SOLD` or `ARCHIVED` status with a conflict response. - 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. - 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. - 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. - `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. - 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. - 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 | | 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 | | 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 ## 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. 1. Add public inventory/listing detail and collection endpoints around the existing SearchService.
2. Begin acquisition and pricing foundations after the listing/image boundary is complete. 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 ## 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. - 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. - 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. - `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 ## Local Commands
+2 -1
View File
@@ -7,6 +7,7 @@
"./inventory": "./inventory.js", "./inventory": "./inventory.js",
"./image": "./image.js", "./image": "./image.js",
"./listing": "./listing.js", "./listing": "./listing.js",
"./product": "./product.js" "./product": "./product.js",
"./public-product": "./public-product.js"
} }
} }
+20
View File
@@ -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),
};
}