feat: enqueue pricing refresh jobs
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-09 12:35:17 -04:00
parent 409d2cd0ca
commit 72346130ed
8 changed files with 68 additions and 14 deletions
+3 -1
View File
@@ -44,6 +44,7 @@ import { createAcquisitionService } from '../modules/acquisitions/services/acqui
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js'; import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js';
import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js'; import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js';
import { createPricingService } from '../modules/pricing/services/pricing-service.js'; import { createPricingService } from '../modules/pricing/services/pricing-service.js';
import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js';
function parsePort(value) { function parsePort(value) {
const port = Number.parseInt(value ?? '3000', 10); const port = Number.parseInt(value ?? '3000', 10);
@@ -70,6 +71,7 @@ async function startServer() {
const saleRepository = createSaleRepository(database.db); const saleRepository = createSaleRepository(database.db);
const acquisitionRepository = createAcquisitionRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db);
const pricingRepository = createPricingRepository(database.db); const pricingRepository = createPricingRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
const userRepository = createUserRepository(database.db); const userRepository = createUserRepository(database.db);
const sessionRepository = createSessionRepository(database.db); const sessionRepository = createSessionRepository(database.db);
const passwordResetRepository = createPasswordResetRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db);
@@ -134,7 +136,7 @@ async function startServer() {
saleService: createSaleService({ saleRepository }), saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }),
}), }),
pricingService: createPricingService({ pricingRepository }), pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository }),
publicListingService: createPublicListingService({ listingRepository, publicImageService }), publicListingService: createPublicListingService({ listingRepository, publicImageService }),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }),
@@ -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) { async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs) const [completed] = await db.update(pricingRefreshJobs)
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() }) .set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
@@ -57,13 +57,26 @@ export async function registerAdminPricingRoutes(app, options) {
app.post('/inventory/:id/pricing/refresh', async (request, reply) => { app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const { providerId, productId, conditionId } = request.body ?? {}; const { providerId, productId, conditionId } = request.body ?? {};
const result = await pricingService.refresh({ const job = await pricingService.enqueueRefresh({
sellerId: request.admin.sellerId, sellerId: request.admin.sellerId,
inventoryItemId: request.params.id, inventoryItemId: request.params.id,
providerId, providerId,
productId, productId,
conditionId, 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,
},
};
}); });
} }
@@ -1,7 +1,7 @@
import { createPricingObservation } from '../domain/pricing-observation.js'; import { createPricingObservation } from '../domain/pricing-observation.js';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPricingService({ pricingRepository, pricingProviderRegistry }) { export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository }) {
return { return {
async addObservation({ sellerId, ...input }) { async addObservation({ sellerId, ...input }) {
const observation = createPricingObservation(input); const observation = createPricingObservation(input);
@@ -52,5 +52,17 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) }; 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;
},
}; };
} }
@@ -47,3 +47,17 @@ test('fails clearly when a requested pricing provider is unavailable', async ()
(error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503, (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');
});
+10 -10
View File
@@ -46,17 +46,13 @@ test('accepts a CSRF-protected market-value override', async (testContext) => {
assert.equal(response.json().data.valueMinor, 2600); 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({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { pricingService: {
async refresh() { async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; },
const error = new Error('Pricing provider is not configured.'); async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; },
error.code = 'PRICING_PROVIDER_UNAVAILABLE';
error.statusCode = 503;
throw error;
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
@@ -68,6 +64,10 @@ test('returns a clear unavailable response when no provider is configured', asyn
payload: { providerId: 'missing' }, payload: { providerId: 'missing' },
}); });
assert.equal(response.statusCode, 503); assert.equal(response.statusCode, 202);
assert.equal(response.json().error.code, 'PRICING_PROVIDER_UNAVAILABLE'); 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');
}); });
+6
View File
@@ -64,6 +64,7 @@ POST /api/v1/admin/inventory/:id/list
POST /api/v1/admin/inventory/:id/archive POST /api/v1/admin/inventory/:id/archive
POST /api/v1/admin/inventory/:id/relist POST /api/v1/admin/inventory/:id/relist
POST /api/v1/admin/inventory/:id/pricing/refresh POST /api/v1/admin/inventory/:id/pricing/refresh
GET /api/v1/admin/jobs/:id
GET /api/v1/admin/listings GET /api/v1/admin/listings
GET /api/v1/admin/listings/:id 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, detail exposes only the latest calculated or manually overridden market value,
condition label, and public image URLs. 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 ## Provider matching
The API must expose Product-level operations for: The API must expose Product-level operations for:
+1
View File
@@ -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. - 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. - 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. - 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. - `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