feat: add pricing job activity view
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-09 17:39:08 -04:00
parent af563b691d
commit 22d13af604
7 changed files with 109 additions and 2 deletions
@@ -1,4 +1,4 @@
import { and, asc, eq } from 'drizzle-orm';
import { and, asc, desc, eq } from 'drizzle-orm';
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js';
@@ -38,6 +38,20 @@ export function createPricingRefreshJobRepository(db) {
return rows.length;
},
async listForSeller(sellerId) {
return db.select().from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.sellerId, sellerId))
.orderBy(desc(pricingRefreshJobs.createdAt));
},
async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs)
.set({ status: 'PENDING', error: null, observations: [], valuation: null, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED')))
.returning();
return job ?? null;
},
async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs)
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
@@ -80,4 +80,17 @@ export async function registerAdminPricingRoutes(app, options) {
},
};
});
app.get('/jobs', async (request) => ({
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId,
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt,
})),
}));
app.post('/jobs/:id/retry', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
});
}
@@ -72,5 +72,15 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404);
return job;
},
async listJobs({ sellerId }) {
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
},
async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId);
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409);
return job;
},
};
}
@@ -62,4 +62,16 @@ test('enqueues a seller-scoped pricing refresh and reads its status', async () =
assert.equal(job.status, 'PENDING');
assert.equal(job.providerId, 'mapped-provider');
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED');
});
test('lists and retries seller-scoped failed pricing jobs', async () => {
const service = createPricingService({
pricingRepository: {},
pricingRefreshJobRepository: {
async listForSeller() { return [{ id: 'job-1', status: 'FAILED' }]; },
async retryForSeller() { return { id: 'job-1', status: 'PENDING' }; },
},
});
assert.equal((await service.listJobs({ sellerId: 'seller-1' })).length, 1);
assert.equal((await service.retryJob({ sellerId: 'seller-1', jobId: 'job-1' })).status, 'PENDING');
});
@@ -96,4 +96,22 @@ test('returns a conflict when refresh has no active product mapping', async (tes
assert.equal(response.statusCode, 409);
assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND');
});
test('lists jobs and retries a failed job with CSRF protection', async (testContext) => {
let csrfChecks = 0;
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
pricingService: {
async listJobs() { return [{ id: 'job-1', status: 'FAILED', providerId: 'fixture', inventoryItemId: 'item-1' }]; },
async retryJob() { return { id: 'job-1', status: 'PENDING' }; },
},
});
testContext.after(() => app.close());
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs' });
const retryResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/jobs/job-1/retry', headers: { 'x-csrf-token': 'csrf' } });
assert.equal(listResponse.statusCode, 200);
assert.equal(retryResponse.statusCode, 202);
assert.equal(csrfChecks, 1);
});