diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index ada484b..4e63cef 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -139,7 +139,7 @@ async function startServer() { saleService: createSaleService({ saleRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }), }), - pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository }), + pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository }), providerMappingService: createProviderMappingService({ providerMappingRepository }), publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), diff --git a/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js b/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js index 7afe221..692ac74 100644 --- a/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js +++ b/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js @@ -23,6 +23,19 @@ export function createProviderMappingRepository(db) { .then((rows) => rows.map(({ mapping }) => mapping)); }, + async findActiveForSeller(productId, sellerId, providerId) { + const conditions = [ + eq(pricingProviderMappings.productId, productId), + eq(products.sellerId, sellerId), + eq(pricingProviderMappings.status, 'ACTIVE'), + ]; + if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId)); + const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings) + .innerJoin(products, eq(products.id, pricingProviderMappings.productId)) + .where(and(...conditions)); + return row?.mapping ?? null; + }, + async disableForSeller(id, sellerId) { const [mapping] = await db.select().from(pricingProviderMappings) .innerJoin(products, eq(products.id, pricingProviderMappings.productId)) diff --git a/apps/api/src/modules/pricing/services/pricing-service.js b/apps/api/src/modules/pricing/services/pricing-service.js index eb73885..1fa0785 100644 --- a/apps/api/src/modules/pricing/services/pricing-service.js +++ b/apps/api/src/modules/pricing/services/pricing-service.js @@ -1,7 +1,7 @@ import { createPricingObservation } from '../domain/pricing-observation.js'; import { createApplicationError } from '../../../shared/errors/application-error.js'; -export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository }) { +export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) { return { async addObservation({ sellerId, ...input }) { const observation = createPricingObservation(input); @@ -55,7 +55,15 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) { if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503); - const job = await pricingRefreshJobRepository.create({ sellerId, inventoryItemId, providerId, productId, conditionId }); + const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId); + if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409); + const job = await pricingRefreshJobRepository.create({ + sellerId, + inventoryItemId, + providerId: mapping.providerId, + productId, + conditionId, + }); return job; }, diff --git a/apps/api/tests/application/pricing-service.test.js b/apps/api/tests/application/pricing-service.test.js index 5e79ae7..937a2dd 100644 --- a/apps/api/tests/application/pricing-service.test.js +++ b/apps/api/tests/application/pricing-service.test.js @@ -55,9 +55,11 @@ test('enqueues a seller-scoped pricing refresh and reads its status', async () = async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; }, async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; }, }, + providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } }, }); const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' }); assert.equal(job.status, 'PENDING'); + assert.equal(job.providerId, 'mapped-provider'); assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED'); }); \ No newline at end of file diff --git a/apps/api/tests/http/pricing-routes.test.js b/apps/api/tests/http/pricing-routes.test.js index 1380454..81cf488 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -70,4 +70,30 @@ test('enqueues a pricing refresh and exposes seller-scoped job status', async (t const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' }); assert.equal(jobResponse.statusCode, 200); assert.equal(jobResponse.json().data.status, 'COMPLETED'); +}); + +test('returns a conflict when refresh has no active product mapping', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authorizeUnsafeRequest: async () => {}, + pricingService: { + async enqueueRefresh() { + const error = new Error('No active pricing provider mapping exists for this product.'); + error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND'; + error.statusCode = 409; + throw error; + }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/inventory/item-1/pricing/refresh', + headers: { 'x-csrf-token': 'csrf' }, + payload: { productId: 'product-1', conditionId: 'condition-1' }, + }); + + assert.equal(response.statusCode, 409); + assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND'); }); \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 16b2b90..aa3d8b4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -130,7 +130,8 @@ condition label, and public image URLs. Pricing refresh returns `202 Accepted` with a durable job identifier and `PENDING` status. Poll the job endpoint for `PROCESSING`, `COMPLETED`, or `FAILED`; job status reads are seller-scoped and expose only safe status/error -metadata. +metadata. Refresh enqueue resolves the provider from an active Product-level +mapping and returns `409` when no mapping exists. ## Provider matching diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 106228d..95b9127 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -152,6 +152,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema - 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. +- Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly. - `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