This commit is contained in:
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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' }),
|
() => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }),
|
||||||
(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');
|
||||||
});
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user