diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index a1d26b1..e04c365 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -19,6 +19,7 @@ import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-rout import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js'; import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js'; +import { registerAdminProviderMappingRoutes } from '../modules/pricing/routes/admin-provider-mapping-routes.js'; export function buildApp(dependencies) { const app = Fastify(); @@ -82,6 +83,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1/admin', }); + app.register(registerAdminProviderMappingRoutes, { + ...dependencies, + prefix: '/api/v1/admin', + }); app.register(registerPublicSearchRoutes, { ...dependencies, prefix: '/api/v1', diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 9244581..ada484b 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -45,6 +45,8 @@ import { createDashboardService } from '../modules/dashboard/services/dashboard- import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js'; import { createPricingService } from '../modules/pricing/services/pricing-service.js'; import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js'; +import { createProviderMappingRepository } from '../modules/pricing/repositories/provider-mapping-repository.js'; +import { createProviderMappingService } from '../modules/pricing/services/provider-mapping-service.js'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -72,6 +74,7 @@ async function startServer() { const acquisitionRepository = createAcquisitionRepository(database.db); const pricingRepository = createPricingRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); + const providerMappingRepository = createProviderMappingRepository(database.db); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -137,6 +140,7 @@ async function startServer() { acquisitionService: createAcquisitionService({ acquisitionRepository }), }), pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository }), + providerMappingService: createProviderMappingService({ providerMappingRepository }), publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index b90327c..282631a 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -299,6 +299,20 @@ export const marketValueOverrides = pgTable('market_value_overrides', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); +export const pricingProviderMappings = pgTable('pricing_provider_mappings', { + id: uuid('id').defaultRandom().primaryKey(), + productId: uuid('product_id').notNull().references(() => products.id), + providerId: text('provider_id').notNull(), + mappingType: text('mapping_type').notNull(), + externalIdentifier: text('external_identifier'), + searchQuery: text('search_query'), + sourceUrl: text('source_url'), + status: text('status').default('ACTIVE').notNull(), + metadata: jsonb('metadata').default({}).notNull(), + disabledAt: timestamp('disabled_at', { withTimezone: true }), + ...timestamps, +}); + export const pricingRefreshJobs = pgTable('pricing_refresh_jobs', { id: uuid('id').defaultRandom().primaryKey(), sellerId: uuid('seller_id').notNull().references(() => sellers.id), diff --git a/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js b/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js new file mode 100644 index 0000000..7afe221 --- /dev/null +++ b/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js @@ -0,0 +1,42 @@ +import { and, eq } from 'drizzle-orm'; + +import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js'; + +export function createProviderMappingRepository(db) { + return { + async createForSeller(mapping, sellerId) { + const [ownedProduct] = await db.select({ id: products.id }).from(products) + .where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId))); + if (!ownedProduct) return { status: 'NOT_FOUND' }; + const [created] = await db.insert(pricingProviderMappings).values(mapping).returning(); + return { status: 'CREATED', mapping: created }; + }, + + async listForSeller(productId, sellerId) { + return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings) + .innerJoin(products, eq(products.id, pricingProviderMappings.productId)) + .where(and( + eq(pricingProviderMappings.productId, productId), + eq(products.sellerId, sellerId), + eq(pricingProviderMappings.status, 'ACTIVE'), + )) + .then((rows) => rows.map(({ mapping }) => mapping)); + }, + + async disableForSeller(id, sellerId) { + const [mapping] = await db.select().from(pricingProviderMappings) + .innerJoin(products, eq(products.id, pricingProviderMappings.productId)) + .where(and( + eq(pricingProviderMappings.id, id), + eq(products.sellerId, sellerId), + eq(pricingProviderMappings.status, 'ACTIVE'), + )); + if (!mapping) return { status: 'NOT_FOUND' }; + const [updated] = await db.update(pricingProviderMappings) + .set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() }) + .where(eq(pricingProviderMappings.id, id)) + .returning(); + return { status: 'DISABLED', mapping: updated }; + }, + }; +} diff --git a/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js b/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js new file mode 100644 index 0000000..97b52c5 --- /dev/null +++ b/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js @@ -0,0 +1,47 @@ +import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping'; +import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping'; + +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +function validationError(reply, result) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed.', + details: result.error.issues.map(({ message, path }) => ({ message, path })), + }, + }); +} + +export async function registerAdminProviderMappingRoutes(app, options) { + const { + authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, + authorizeUnsafeRequest, + sessionCookieName = 'pokemon_inventory_session', + providerMappingService, + } = options; + + app.addHook('preHandler', async (request) => { + const admin = await authenticateAdmin(request); + if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + request.admin = admin; + }); + + app.get('/products/:id/provider-mappings', async (request) => ({ + data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto), + })); + + app.post('/products/:id/provider-mappings', async (request, reply) => { + await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); + const result = createProviderMappingSchema.safeParse(request.body); + if (!result.success) return validationError(reply, result); + const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId }); + return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) }); + }); + + app.post('/provider-mappings/:id/disable', async (request, reply) => { + await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); + const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId }); + return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) }); + }); +} diff --git a/apps/api/src/modules/pricing/services/provider-mapping-service.js b/apps/api/src/modules/pricing/services/provider-mapping-service.js new file mode 100644 index 0000000..7fa115e --- /dev/null +++ b/apps/api/src/modules/pricing/services/provider-mapping-service.js @@ -0,0 +1,19 @@ +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export function createProviderMappingService({ providerMappingRepository }) { + return { + async create({ sellerId, ...input }) { + const result = await providerMappingRepository.createForSeller(input, sellerId); + if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404); + return result.mapping; + }, + async list({ productId, sellerId }) { + return providerMappingRepository.listForSeller(productId, sellerId); + }, + async disable({ id, sellerId }) { + const result = await providerMappingRepository.disableForSeller(id, sellerId); + if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404); + return result.mapping; + }, + }; +} diff --git a/apps/api/tests/application/provider-mapping-service.test.js b/apps/api/tests/application/provider-mapping-service.test.js new file mode 100644 index 0000000..8d905bc --- /dev/null +++ b/apps/api/tests/application/provider-mapping-service.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createProviderMappingService } from '../../src/modules/pricing/services/provider-mapping-service.js'; + +test('creates and disables seller-owned provider mappings', async () => { + const calls = []; + const service = createProviderMappingService({ + providerMappingRepository: { + async createForSeller(input, sellerId) { calls.push(['create', input, sellerId]); return { status: 'CREATED', mapping: { id: 'mapping-1', ...input } }; }, + async listForSeller() { return [{ id: 'mapping-1', status: 'ACTIVE' }]; }, + async disableForSeller() { return { status: 'DISABLED', mapping: { id: 'mapping-1', status: 'DISABLED' } }; }, + }, + }); + + const created = await service.create({ sellerId: 'seller-1', productId: 'product-1', providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }); + assert.equal(created.id, 'mapping-1'); + assert.equal(calls[0][2], 'seller-1'); + assert.equal((await service.list({ sellerId: 'seller-1', productId: 'product-1' })).length, 1); + assert.equal((await service.disable({ sellerId: 'seller-1', id: 'mapping-1' })).status, 'DISABLED'); +}); diff --git a/apps/api/tests/http/provider-mapping-routes.test.js b/apps/api/tests/http/provider-mapping-routes.test.js new file mode 100644 index 0000000..86e9ca1 --- /dev/null +++ b/apps/api/tests/http/provider-mapping-routes.test.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +test('protects provider mapping writes and scopes mappings to the seller', async (testContext) => { + let received; + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authorizeUnsafeRequest: async () => {}, + providerMappingService: { + async create(input) { received = input; return { id: 'mapping-1', ...input, status: 'ACTIVE' }; }, + async list() { return []; }, + async disable() { return { id: 'mapping-1', status: 'DISABLED' }; }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/products/product-1/provider-mappings', + headers: { 'x-csrf-token': 'csrf' }, + payload: { providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }, + }); + + assert.equal(response.statusCode, 201); + assert.equal(received.sellerId, 'seller-1'); + assert.equal(response.json().data.providerId, 'fixture'); +}); diff --git a/apps/api/tests/integration/transactions-pricing-sales.test.js b/apps/api/tests/integration/transactions-pricing-sales.test.js index a95c03d..c34d7f4 100644 --- a/apps/api/tests/integration/transactions-pricing-sales.test.js +++ b/apps/api/tests/integration/transactions-pricing-sales.test.js @@ -17,6 +17,7 @@ import { import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js'; import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js'; import { createPricingRefreshJobRepository } from '../../src/modules/pricing/repositories/pricing-refresh-job-repository.js'; +import { createProviderMappingRepository } from '../../src/modules/pricing/repositories/provider-mapping-repository.js'; import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js'; const connectionString = process.env.TEST_DATABASE_URL; @@ -33,6 +34,7 @@ const database = createDatabase(connectionString); const acquisitionRepository = createAcquisitionRepository(database.db); const pricingRepository = createPricingRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); +const providerMappingRepository = createProviderMappingRepository(database.db); const saleRepository = createSaleRepository(database.db); async function createFixture() { @@ -73,7 +75,7 @@ async function createFixture() { } beforeEach(async () => { - await database.db.execute(sql`TRUNCATE pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`); + await database.db.execute(sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`); }); after(async () => { @@ -176,3 +178,18 @@ test('claims and completes a durable pricing refresh job', async () => { assert.deepEqual(completed.valuation, { valueMinor: 2500 }); assert.equal(user.email, 'transactions@example.test'); }); + +test('persists and disables seller-owned provider mappings', async () => { + const { seller, product } = await createFixture(); + const created = await providerMappingRepository.createForSeller({ + productId: product.id, + providerId: 'integration', + mappingType: 'PRODUCT', + externalIdentifier: 'catalog-1', + }, seller.id); + assert.equal(created.status, 'CREATED'); + assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 1); + const disabled = await providerMappingRepository.disableForSeller(created.mapping.id, seller.id); + assert.equal(disabled.status, 'DISABLED'); + assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 0); +}); diff --git a/database/migrations/0018_provider_mappings.sql b/database/migrations/0018_provider_mappings.sql new file mode 100644 index 0000000..1f5dec2 --- /dev/null +++ b/database/migrations/0018_provider_mappings.sql @@ -0,0 +1,14 @@ +CREATE TABLE "pricing_provider_mappings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL REFERENCES "products"("id"), + "provider_id" text NOT NULL, + "mapping_type" text NOT NULL, + "external_identifier" text, + "search_query" text, + "source_url" text, + "status" text DEFAULT 'ACTIVE' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "disabled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index dae838e..d7fb717 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1790064000000, "tag": "0017_pricing_refresh_jobs", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1790150400000, + "tag": "0018_provider_mappings", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 4286376..16b2b90 100644 --- a/docs/API.md +++ b/docs/API.md @@ -65,6 +65,9 @@ POST /api/v1/admin/inventory/:id/archive POST /api/v1/admin/inventory/:id/relist POST /api/v1/admin/inventory/:id/pricing/refresh GET /api/v1/admin/jobs/:id +GET /api/v1/admin/products/:id/provider-mappings +POST /api/v1/admin/products/:id/provider-mappings +POST /api/v1/admin/provider-mappings/:id/disable GET /api/v1/admin/listings GET /api/v1/admin/listings/:id diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 8dbb14c..106228d 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -151,6 +151,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema - Concrete provider adapters, asynchronous pricing refresh transport, richer exports/settings, full integration CI services, and deployment/backup automation remain incomplete to varying degrees. - The pricing refresh worker now claims durable PostgreSQL jobs with row locking, persists completed/failed outcomes, and routes provider observations through the pricing service; configured external providers remain deployment work. - Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline. +- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling. - `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 81759d2..9cffcd0 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -15,6 +15,7 @@ "./public-inventory": "./public-inventory.js", "./public-product": "./public-product.js", "./public-seller": "./public-seller.js" - ,"./pricing": "./pricing.js" + ,"./pricing": "./pricing.js", + "./provider-mapping": "./provider-mapping.js" } } \ No newline at end of file diff --git a/packages/contracts/provider-mapping.js b/packages/contracts/provider-mapping.js new file mode 100644 index 0000000..5e735aa --- /dev/null +++ b/packages/contracts/provider-mapping.js @@ -0,0 +1,15 @@ +export function toAdminProviderMappingDto(mapping) { + return { + id: mapping.id, + productId: mapping.productId, + providerId: mapping.providerId, + mappingType: mapping.mappingType, + externalIdentifier: mapping.externalIdentifier ?? null, + searchQuery: mapping.searchQuery ?? null, + sourceUrl: mapping.sourceUrl ?? null, + status: mapping.status, + metadata: mapping.metadata ?? {}, + createdAt: mapping.createdAt instanceof Date ? mapping.createdAt.toISOString() : mapping.createdAt, + updatedAt: mapping.updatedAt instanceof Date ? mapping.updatedAt.toISOString() : mapping.updatedAt, + }; +} diff --git a/packages/validation/package.json b/packages/validation/package.json index 0e30a2e..9b107cd 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -10,7 +10,8 @@ "./listing": "./listing.js", "./sale": "./sale.js", "./product": "./product.js" - ,"./pricing": "./pricing.js" + ,"./pricing": "./pricing.js", + "./provider-mapping": "./provider-mapping.js" }, "dependencies": { "zod": "^4.1.8" diff --git a/packages/validation/provider-mapping.js b/packages/validation/provider-mapping.js new file mode 100644 index 0000000..8566814 --- /dev/null +++ b/packages/validation/provider-mapping.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const createProviderMappingSchema = z.object({ + providerId: z.string().trim().min(1).max(100), + mappingType: z.enum(['PRODUCT', 'SEARCH']), + externalIdentifier: z.string().trim().max(255).optional(), + searchQuery: z.string().trim().max(1000).optional(), + sourceUrl: z.string().trim().url().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}).strict();