This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
<span v-if="valuation.observations?.some(({ isOutlier }) => isOutlier)"> · OUTLIER FLAGGED</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="pricingJobs.length" aria-label="Pricing jobs">
|
||||
<li v-for="job in pricingJobs" :key="job.id">
|
||||
{{ job.id }} · {{ job.providerId }} · {{ job.status }}<span v-if="job.error"> · {{ job.error }}</span>
|
||||
<button v-if="job.status === 'FAILED'" class="button button--secondary" type="button" :disabled="isSaving" @click="retryPricingJob(job.id)">Retry job</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="admin-list" aria-labelledby="listings-title">
|
||||
<h2 id="listings-title">Listings</h2>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user