From 22d13af604d9825264e921b24b76ae21d0fed1bf Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 17:39:08 -0400 Subject: [PATCH] feat: add pricing job activity view --- .../pricing-refresh-job-repository.js | 16 ++++++++++++- .../pricing/routes/admin-pricing-routes.js | 13 +++++++++++ .../pricing/services/pricing-service.js | 10 ++++++++ .../tests/application/pricing-service.test.js | 12 ++++++++++ apps/api/tests/http/pricing-routes.test.js | 18 +++++++++++++++ apps/web/src/pages/AdminHomePage.vue | 19 ++++++++++++++- apps/web/src/services/admin-jobs-service.js | 23 +++++++++++++++++++ 7 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/services/admin-jobs-service.js 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 f3cb607..4f33d2b 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 @@ -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() }) 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 1732e8a..36effa2 100644 --- a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -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 } }); + }); } \ 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 1fa0785..5c4c1cb 100644 --- a/apps/api/src/modules/pricing/services/pricing-service.js +++ b/apps/api/src/modules/pricing/services/pricing-service.js @@ -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; + }, }; } \ 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 937a2dd..76702fe 100644 --- a/apps/api/tests/application/pricing-service.test.js +++ b/apps/api/tests/application/pricing-service.test.js @@ -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'); }); \ 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 81cf488..6c0f8da 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -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); }); \ No newline at end of file diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index 3e91e16..9f3d0d7 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -10,6 +10,7 @@ import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js'; import { getAdminDashboard } from '@/services/admin-dashboard-service.js'; import { addAdminPricingObservation, calculateAdminValuation, enqueueAdminPricingRefresh, getAdminPricingJob, overrideAdminValuation } from '@/services/admin-pricing-service.js'; +import { listAdminPricingJobs, retryAdminPricingJob } from '@/services/admin-jobs-service.js'; import { createAdminProviderMapping, listAdminProviderMappings } from '@/services/admin-provider-mapping-service.js'; const { principal } = useAuth(); @@ -56,6 +57,7 @@ const mappingType = ref('PRODUCT'); const mappingExternalIdentifier = ref(''); const mappingSearchQuery = ref(''); const providerMappings = ref([]); +const pricingJobs = ref([]); function csrfToken() { return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? ''; @@ -64,7 +66,7 @@ function csrfToken() { async function load() { isLoading.value = true; try { - [products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value, dashboard.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions(), getAdminDashboard()]); + [products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value, dashboard.value, pricingJobs.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions(), getAdminDashboard(), listAdminPricingJobs()]); } catch (requestError) { error.value = requestError; } finally { isLoading.value = false; } } @@ -207,6 +209,15 @@ async function refreshPricing() { } finally { isSaving.value = false; } } +async function retryPricingJob(jobId) { + isSaving.value = true; + try { + await retryAdminPricingJob(jobId, csrfToken()); + pricingJobs.value = await listAdminPricingJobs(); + } catch (requestError) { error.value = requestError; } + finally { isSaving.value = false; } +} + async function overridePricing() { isSaving.value = true; try { @@ -394,6 +405,12 @@ onMounted(load); · OUTLIER FLAGGED +

Listings

diff --git a/apps/web/src/services/admin-jobs-service.js b/apps/web/src/services/admin-jobs-service.js new file mode 100644 index 0000000..93047af --- /dev/null +++ b/apps/web/src/services/admin-jobs-service.js @@ -0,0 +1,23 @@ +const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; + +async function request(path, options = {}) { + const response = await fetch(`${apiBaseUrl}${path}`, { + credentials: 'include', + ...options, + headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error?.message ?? 'Job request failed.'); + return payload?.data; +} + +export function listAdminPricingJobs() { + return request('/admin/jobs'); +} + +export function retryAdminPricingJob(jobId, csrfToken) { + return request(`/admin/jobs/${encodeURIComponent(jobId)}/retry`, { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + }); +}