From 72346130edc3d1f99e3e5337cd4dbd9b50bf093b Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 12:35:17 -0400 Subject: [PATCH] feat: enqueue pricing refresh jobs --- apps/api/src/app/server.js | 4 +++- .../pricing-refresh-job-repository.js | 6 ++++++ .../pricing/routes/admin-pricing-routes.js | 17 ++++++++++++++-- .../pricing/services/pricing-service.js | 14 ++++++++++++- .../tests/application/pricing-service.test.js | 14 +++++++++++++ apps/api/tests/http/pricing-routes.test.js | 20 +++++++++---------- docs/API.md | 6 ++++++ docs/IMPLEMENTATION-STATUS.md | 1 + 8 files changed, 68 insertions(+), 14 deletions(-) diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index de07999..9244581 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -44,6 +44,7 @@ import { createAcquisitionService } from '../modules/acquisitions/services/acqui import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js'; 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'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -70,6 +71,7 @@ async function startServer() { const saleRepository = createSaleRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db); const pricingRepository = createPricingRepository(database.db); + const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -134,7 +136,7 @@ async function startServer() { saleService: createSaleService({ saleRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }), }), - pricingService: createPricingService({ pricingRepository }), + pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository }), publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), diff --git a/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js b/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js index 5f2c268..d8e7628 100644 --- a/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js +++ b/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js @@ -25,6 +25,12 @@ export function createPricingRefreshJobRepository(db) { }); }, + async findByIdForSeller(id, sellerId) { + const [job] = await db.select().from(pricingRefreshJobs) + .where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId))); + return job ?? null; + }, + async complete(id, observations, valuation) { const [completed] = await db.update(pricingRefreshJobs) .set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() }) diff --git a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js index 993a2c1..13642b0 100644 --- a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -57,13 +57,26 @@ export async function registerAdminPricingRoutes(app, options) { app.post('/inventory/:id/pricing/refresh', async (request, reply) => { await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); const { providerId, productId, conditionId } = request.body ?? {}; - const result = await pricingService.refresh({ + const job = await pricingService.enqueueRefresh({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id, providerId, productId, conditionId, }); - return reply.code(200).send({ data: result }); + return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } }); + }); + + app.get('/jobs/:id', async (request) => { + const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id }); + return { + data: { + id: job.id, + status: job.status, + error: job.error ?? null, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }, + }; }); } \ No newline at end of file diff --git a/apps/api/src/modules/pricing/services/pricing-service.js b/apps/api/src/modules/pricing/services/pricing-service.js index 0bd6186..eb73885 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 }) { +export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository }) { return { async addObservation({ sellerId, ...input }) { const observation = createPricingObservation(input); @@ -52,5 +52,17 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) }; }, + + 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 }); + return job; + }, + + async getJob({ sellerId, jobId }) { + const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId); + if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404); + return job; + }, }; } \ No newline at end of file diff --git a/apps/api/tests/application/pricing-service.test.js b/apps/api/tests/application/pricing-service.test.js index 924e473..5e79ae7 100644 --- a/apps/api/tests/application/pricing-service.test.js +++ b/apps/api/tests/application/pricing-service.test.js @@ -46,4 +46,18 @@ test('fails clearly when a requested pricing provider is unavailable', async () () => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }), (error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503, ); +}); + +test('enqueues a seller-scoped pricing refresh and reads its status', async () => { + const service = createPricingService({ + pricingRepository: {}, + pricingRefreshJobRepository: { + async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; }, + async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; }, + }, + }); + + 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((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 459a3b8..1380454 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -46,17 +46,13 @@ test('accepts a CSRF-protected market-value override', async (testContext) => { assert.equal(response.json().data.valueMinor, 2600); }); -test('returns a clear unavailable response when no provider is configured', async (testContext) => { +test('enqueues a pricing refresh and exposes seller-scoped job status', async (testContext) => { const app = buildApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authorizeUnsafeRequest: async () => {}, pricingService: { - async refresh() { - const error = new Error('Pricing provider is not configured.'); - error.code = 'PRICING_PROVIDER_UNAVAILABLE'; - error.statusCode = 503; - throw error; - }, + async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; }, + async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; }, }, }); testContext.after(() => app.close()); @@ -68,6 +64,10 @@ test('returns a clear unavailable response when no provider is configured', asyn payload: { providerId: 'missing' }, }); - assert.equal(response.statusCode, 503); - assert.equal(response.json().error.code, 'PRICING_PROVIDER_UNAVAILABLE'); + assert.equal(response.statusCode, 202); + assert.equal(response.json().data.status, 'PENDING'); + + 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'); }); \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 3b0a2da..4286376 100644 --- a/docs/API.md +++ b/docs/API.md @@ -64,6 +64,7 @@ POST /api/v1/admin/inventory/:id/list 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/listings GET /api/v1/admin/listings/:id @@ -123,6 +124,11 @@ records an audit event. Pricing observations remain private; public listing detail exposes only the latest calculated or manually overridden market value, 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. + ## Provider matching The API must expose Product-level operations for: diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index cf04434..f138190 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -150,6 +150,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema - 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. - 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 has durable PostgreSQL refresh jobs with row-locked claim, completed/failed persistence, and provider observations routed 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. - `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