From a76d777a6bb86a9dc2e613c09722c3d07c2c125d Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 19:36:13 -0400 Subject: [PATCH] feat: add public seller directory --- apps/api/src/app/build-app.js | 5 +++ apps/api/src/app/server.js | 4 +++ .../sellers/repositories/seller-repository.js | 32 +++++++++++++++++++ .../sellers/routes/public-seller-routes.js | 9 ++++++ .../sellers/services/public-seller-service.js | 7 ++++ .../application/public-seller-service.test.js | 16 ++++++++++ .../tests/http/public-seller-routes.test.js | 32 +++++++++++++++++++ .../integration/catalog-repositories.test.js | 31 ++++++++++++++++++ docs/IMPLEMENTATION-STATUS.md | 3 +- packages/contracts/package.json | 3 +- packages/contracts/public-seller.js | 12 +++++++ 11 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/sellers/repositories/seller-repository.js create mode 100644 apps/api/src/modules/sellers/routes/public-seller-routes.js create mode 100644 apps/api/src/modules/sellers/services/public-seller-service.js create mode 100644 apps/api/tests/application/public-seller-service.test.js create mode 100644 apps/api/tests/http/public-seller-routes.test.js create mode 100644 packages/contracts/public-seller.js diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index ceb9d00..4843f1b 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -11,6 +11,7 @@ import { registerPublicListingRoutes } from '../modules/listings/routes/public-l 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 { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js'; export function buildApp(dependencies) { const app = Fastify(); @@ -70,6 +71,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1', }); + app.register(registerPublicSellerRoutes, { + ...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 b90d22e..92d5049 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -28,6 +28,8 @@ import { createProductRepository } from '../modules/products/repositories/produc 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 { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js'; +import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -46,6 +48,7 @@ async function startServer() { const inventoryItemRepository = createInventoryItemRepository(database.db); const inventoryImageRepository = createInventoryImageRepository(database.db); const listingRepository = createListingRepository(database.db); + const sellerRepository = createSellerRepository(database.db); const conditionRepository = createConditionRepository(database.db); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); @@ -93,6 +96,7 @@ async function startServer() { productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), searchService: createSearchService({ listingRepository }), + publicSellerService: createPublicSellerService({ sellerRepository }), }); app.addHook('onClose', async () => { diff --git a/apps/api/src/modules/sellers/repositories/seller-repository.js b/apps/api/src/modules/sellers/repositories/seller-repository.js new file mode 100644 index 0000000..07b345b --- /dev/null +++ b/apps/api/src/modules/sellers/repositories/seller-repository.js @@ -0,0 +1,32 @@ +import { and, asc, eq, inArray } from 'drizzle-orm'; + +import { + inventoryItems, + listings, + products, + sellers, +} from '../../../infrastructure/database/schema.js'; + +export function createSellerRepository(db) { + return { + async listPublic() { + return db + .select({ + id: sellers.id, + displayName: sellers.displayName, + createdAt: sellers.createdAt, + updatedAt: sellers.updatedAt, + }) + .from(sellers) + .innerJoin(products, eq(products.sellerId, sellers.id)) + .innerJoin(inventoryItems, eq(inventoryItems.productId, products.id)) + .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) + .where(and( + eq(inventoryItems.status, 'ACTIVE'), + inArray(listings.status, ['AVAILABLE', 'PENDING']), + )) + .groupBy(sellers.id, sellers.displayName, sellers.createdAt, sellers.updatedAt) + .orderBy(asc(sellers.displayName)); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/sellers/routes/public-seller-routes.js b/apps/api/src/modules/sellers/routes/public-seller-routes.js new file mode 100644 index 0000000..4658790 --- /dev/null +++ b/apps/api/src/modules/sellers/routes/public-seller-routes.js @@ -0,0 +1,9 @@ +import { toPublicSellerDto } from '@pokemon-inventory/contracts/public-seller'; + +export async function registerPublicSellerRoutes(app, options) { + const { publicSellerService } = options; + + app.get('/public/sellers', async () => ({ + data: (await publicSellerService.list()).map(toPublicSellerDto), + })); +} \ No newline at end of file diff --git a/apps/api/src/modules/sellers/services/public-seller-service.js b/apps/api/src/modules/sellers/services/public-seller-service.js new file mode 100644 index 0000000..f299f36 --- /dev/null +++ b/apps/api/src/modules/sellers/services/public-seller-service.js @@ -0,0 +1,7 @@ +export function createPublicSellerService({ sellerRepository }) { + return { + async list() { + return sellerRepository.listPublic(); + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/public-seller-service.test.js b/apps/api/tests/application/public-seller-service.test.js new file mode 100644 index 0000000..5839708 --- /dev/null +++ b/apps/api/tests/application/public-seller-service.test.js @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPublicSellerService } from '../../src/modules/sellers/services/public-seller-service.js'; + +test('lists sellers associated with publicly visible listings', async () => { + const service = createPublicSellerService({ + sellerRepository: { + async listPublic() { + return [{ id: 'seller-1', displayName: 'Catalog Owner' }]; + }, + }, + }); + + assert.deepEqual(await service.list(), [{ id: 'seller-1', displayName: 'Catalog Owner' }]); +}); diff --git a/apps/api/tests/http/public-seller-routes.test.js b/apps/api/tests/http/public-seller-routes.test.js new file mode 100644 index 0000000..6081a73 --- /dev/null +++ b/apps/api/tests/http/public-seller-routes.test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +test('returns only public seller identity fields', async (testContext) => { + const app = buildApp({ + publicSellerService: { + async list() { + return [{ + id: 'seller-1', + userId: 'private-user-id', + displayName: 'Catalog Owner', + createdAt: new Date('2026-09-07T00:00:00.000Z'), + updatedAt: new Date('2026-09-07T00:00:00.000Z'), + }]; + }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/api/v1/public/sellers' }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data[0], { + id: 'seller-1', + displayName: 'Catalog Owner', + createdAt: '2026-09-07T00:00:00.000Z', + updatedAt: '2026-09-07T00:00:00.000Z', + }); + assert.equal('userId' in response.json().data[0], false); +}); \ 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 9786a52..3ce7f57 100644 --- a/apps/api/tests/integration/catalog-repositories.test.js +++ b/apps/api/tests/integration/catalog-repositories.test.js @@ -8,9 +8,11 @@ import { categories, conditionProfiles, conditions, + inventoryItems, listings, mfaMethods, passwordResetTokens, + products, securityAuditEvents, sellers, sessions, @@ -23,6 +25,7 @@ import { createPasswordResetRepository } from '../../src/modules/auth/repositori import { createMfaMethodRepository } from '../../src/modules/auth/repositories/mfa-method-repository.js'; import { createInventoryItemRepository } from '../../src/modules/inventory/repositories/inventory-item-repository.js'; import { createProductRepository } from '../../src/modules/products/repositories/product-repository.js'; +import { createSellerRepository } from '../../src/modules/sellers/repositories/seller-repository.js'; const connectionString = process.env.TEST_DATABASE_URL; @@ -36,6 +39,7 @@ if (!new URL(connectionString).pathname.endsWith('_test')) { const database = createDatabase(connectionString); const productRepository = createProductRepository(database.db); +const sellerRepository = createSellerRepository(database.db); const inventoryItemRepository = createInventoryItemRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -190,6 +194,33 @@ test('finds only products with active inventory and a public listing', async () assert.equal(await productRepository.findPublicBySlug(product.slug), null); }); +test('lists only sellers with publicly visible inventory', async () => { + const { seller, category, condition } = await createCatalogPrerequisites(); + const [product] = await database.db.insert(products).values({ + sellerId: seller.id, + categoryId: category.id, + name: 'Public Seller Product', + slug: 'public-seller-product', + }).returning(); + const [inventoryItem] = await database.db.insert(inventoryItems).values({ + productId: product.id, + conditionId: condition.id, + }).returning(); + await database.db.insert(listings).values({ + inventoryItemId: inventoryItem.id, + productName: product.name, + categoryId: category.id, + conditionId: condition.id, + askingPriceMinor: 1999, + status: 'AVAILABLE', + }); + + assert.deepEqual((await sellerRepository.listPublic()).map(({ id }) => id), [seller.id]); + + await database.db.update(listings).set({ status: 'CLOSED' }); + assert.deepEqual(await sellerRepository.listPublic(), []); +}); + 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 3a3280e..d5a53fc 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -95,6 +95,7 @@ docker context use orbstack - 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. +- The public seller directory is available at `GET /api/v1/public/sellers`; it returns only sellers with visible inventory and exposes display identity without user or contact fields. - `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. @@ -109,7 +110,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 95 tests; worker tests passed 2 tests; `npm run test:integration` passed 17 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 97 tests; worker tests passed 2 tests; `npm run test:integration` passed 18 tests against OrbStack PostgreSQL and Redis. ## Next Milestone diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9c77738..c6f6243 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,7 @@ "./product": "./product.js", "./public-listing": "./public-listing.js", "./public-inventory": "./public-inventory.js", - "./public-product": "./public-product.js" + "./public-product": "./public-product.js", + "./public-seller": "./public-seller.js" } } \ No newline at end of file diff --git a/packages/contracts/public-seller.js b/packages/contracts/public-seller.js new file mode 100644 index 0000000..09a63b0 --- /dev/null +++ b/packages/contracts/public-seller.js @@ -0,0 +1,12 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toPublicSellerDto(seller) { + return { + id: seller.id, + displayName: seller.displayName, + createdAt: toIsoString(seller.createdAt), + updatedAt: toIsoString(seller.updatedAt), + }; +} \ No newline at end of file