From 7c4885cf65fbd118d398e9b45941500371fd4a52 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 10 Sep 2026 01:25:11 -0400 Subject: [PATCH] feat: add asynchronous inventory exports --- apps/api/src/app/server.js | 2 + .../inventory-export-job-repository.js | 51 +++++++++++++++++++ .../exports/routes/admin-export-routes.js | 30 +++++++++++ .../exports/services/admin-export-service.js | 10 +++- .../tests/http/admin-export-routes.test.js | 26 ++++++++++ apps/web/src/pages/AdminHomePage.stories.js | 10 ++++ apps/web/src/pages/AdminHomePage.vue | 27 ++++++++++ .../web/src/services/admin-catalog-service.js | 11 ++++ apps/web/tests/catalog-service.test.js | 15 ++++++ apps/worker/src/inventory-export-worker.js | 14 +++++ apps/worker/src/worker-runtime.js | 19 +++++++ .../tests/inventory-export-worker.test.js | 32 ++++++++++++ .../migrations/0024_inventory_export_jobs.sql | 13 +++++ database/migrations/meta/_journal.json | 7 +++ docs/API.md | 2 + docs/IMPLEMENTATION-STATUS.md | 1 + 16 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/exports/repositories/inventory-export-job-repository.js create mode 100644 apps/worker/src/inventory-export-worker.js create mode 100644 apps/worker/tests/inventory-export-worker.test.js create mode 100644 database/migrations/0024_inventory_export_jobs.sql diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 60b3c38..be1780b 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -53,6 +53,7 @@ import { createPricingProviderRegistry } from "../modules/pricing/providers/pric import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js"; import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js"; import { createAdminExportService } from "../modules/exports/services/admin-export-service.js"; +import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js"; import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js"; import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js"; @@ -166,6 +167,7 @@ async function startServer() { }), inventoryItemService, adminExportService: createAdminExportService({ + inventoryExportJobRepository: createInventoryExportJobRepository(database.db), inventoryItemService, }), sellerSettingsService, diff --git a/apps/api/src/modules/exports/repositories/inventory-export-job-repository.js b/apps/api/src/modules/exports/repositories/inventory-export-job-repository.js new file mode 100644 index 0000000..9ebd922 --- /dev/null +++ b/apps/api/src/modules/exports/repositories/inventory-export-job-repository.js @@ -0,0 +1,51 @@ +import { and, asc, eq } from 'drizzle-orm'; + +import { inventoryExportJobs } from '../../../infrastructure/database/schema.js'; + +export function createInventoryExportJobRepository(db) { + return { + async create(job) { + const [created] = await db.insert(inventoryExportJobs).values(job).returning(); + return created; + }, + + async findByIdForSeller(id, sellerId) { + const [job] = await db.select().from(inventoryExportJobs).where(and( + eq(inventoryExportJobs.id, id), + eq(inventoryExportJobs.sellerId, sellerId), + )); + return job ?? null; + }, + + async claimNext() { + return db.transaction(async (transaction) => { + const [job] = await transaction.select().from(inventoryExportJobs) + .where(eq(inventoryExportJobs.status, 'PENDING')) + .orderBy(asc(inventoryExportJobs.createdAt)) + .limit(1).for('update', { skipLocked: true }); + if (!job) return null; + const [claimed] = await transaction.update(inventoryExportJobs) + .set({ status: 'PROCESSING', updatedAt: new Date(), error: null }) + .where(and(eq(inventoryExportJobs.id, job.id), eq(inventoryExportJobs.status, 'PENDING'))) + .returning(); + return claimed ?? null; + }); + }, + + async complete(id, output) { + const [completed] = await db.update(inventoryExportJobs) + .set({ status: 'COMPLETED', output, updatedAt: new Date(), error: null }) + .where(and(eq(inventoryExportJobs.id, id), eq(inventoryExportJobs.status, 'PROCESSING'))) + .returning(); + return completed ?? null; + }, + + async fail(id, error) { + const [failed] = await db.update(inventoryExportJobs) + .set({ status: 'FAILED', error, updatedAt: new Date() }) + .where(and(eq(inventoryExportJobs.id, id), eq(inventoryExportJobs.status, 'PROCESSING'))) + .returning(); + return failed ?? null; + }, + }; +} diff --git a/apps/api/src/modules/exports/routes/admin-export-routes.js b/apps/api/src/modules/exports/routes/admin-export-routes.js index 7f0946f..8688e95 100644 --- a/apps/api/src/modules/exports/routes/admin-export-routes.js +++ b/apps/api/src/modules/exports/routes/admin-export-routes.js @@ -48,4 +48,34 @@ export async function registerAdminExportRoutes(app, options) { .header('content-disposition', 'attachment; filename="inventory.xls"') .send(data); }); + + app.post('/exports/inventory.json/jobs', async (request, reply) => { + const job = await adminExportService.enqueueInventoryJson({ + sellerId: request.admin.sellerId, + }); + return reply.code(202).send({ + data: { id: job.id, status: job.status, createdAt: job.createdAt }, + }); + }); + + app.get('/exports/jobs/:id', async (request, reply) => { + const job = await adminExportService.getExportJob({ + sellerId: request.admin.sellerId, + jobId: request.params.id, + }); + if (!job) { + throw createApplicationError('EXPORT_JOB_NOT_FOUND', 'Export job was not found.', 404); + } + return reply.send({ + data: { + id: job.id, + format: job.format, + status: job.status, + error: job.error ?? null, + output: job.status === 'COMPLETED' ? job.output : null, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }, + }); + }); } diff --git a/apps/api/src/modules/exports/services/admin-export-service.js b/apps/api/src/modules/exports/services/admin-export-service.js index 35ad5ea..2cfa106 100644 --- a/apps/api/src/modules/exports/services/admin-export-service.js +++ b/apps/api/src/modules/exports/services/admin-export-service.js @@ -1,12 +1,20 @@ import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; -export function createAdminExportService({ inventoryItemService }) { +export function createAdminExportService({ inventoryItemService, inventoryExportJobRepository }) { async function inventoryRows(sellerId) { const inventoryItems = await inventoryItemService.listBySellerId(sellerId); return inventoryItems.map(toAdminInventoryItemDto); } return { + async enqueueInventoryJson({ sellerId }) { + return inventoryExportJobRepository.create({ sellerId, format: 'JSON' }); + }, + + async getExportJob({ sellerId, jobId }) { + return inventoryExportJobRepository.findByIdForSeller(jobId, sellerId); + }, + async inventoryJson({ sellerId }) { return inventoryRows(sellerId); }, diff --git a/apps/api/tests/http/admin-export-routes.test.js b/apps/api/tests/http/admin-export-routes.test.js index 5d935a5..41886e2 100644 --- a/apps/api/tests/http/admin-export-routes.test.js +++ b/apps/api/tests/http/admin-export-routes.test.js @@ -100,3 +100,29 @@ test('downloads seller-scoped inventory Excel XML for an authenticated admin', a assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"'); assert.equal(response.body, ''); }); + +test('enqueues and polls an asynchronous inventory export', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + adminExportService: { + async enqueueInventoryJson({ sellerId }) { + assert.equal(sellerId, 'seller-1'); + return { id: 'job-1', status: 'PENDING', createdAt: '2026-09-10' }; + }, + async getExportJob({ sellerId, jobId }) { + assert.equal(sellerId, 'seller-1'); + assert.equal(jobId, 'job-1'); + return { id: jobId, format: 'JSON', status: 'COMPLETED', output: [{ id: 'item-1' }] }; + }, + }, + }); + testContext.after(() => app.close()); + + const enqueue = await app.inject({ method: 'POST', url: '/api/v1/admin/exports/inventory.json/jobs' }); + const poll = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1' }); + + assert.equal(enqueue.statusCode, 202); + assert.equal(enqueue.json().data.id, 'job-1'); + assert.equal(poll.statusCode, 200); + assert.deepEqual(poll.json().data.output, [{ id: 'item-1' }]); +}); diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js index 3a9eb85..354dbbd 100644 --- a/apps/web/src/pages/AdminHomePage.stories.js +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -58,6 +58,16 @@ export const InventoryExcelExport = { }, }; +export const AsyncInventoryExport = { + parameters: { + docs: { + description: { + story: 'The admin workspace can queue a large inventory JSON export and report its job status.', + }, + }, + }, +}; + export const BulkTagSelection = { parameters: { docs: { diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index dba1b09..7dcbac3 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -14,6 +14,8 @@ import { listAdminProducts, refreshAdminInventoryPricing, updateAdminInventoryTags, + enqueueAdminInventoryExport, + getAdminInventoryExportJob, } from "@/services/admin-catalog-service.js"; import { createAdminListing, @@ -52,6 +54,7 @@ const products = ref([]); const inventory = ref([]); const selectedInventoryIds = ref([]); const inventoryTags = ref(""); +const exportStatus = ref(""); const listings = ref([]); const sales = ref([]); const acquisitions = ref([]); @@ -254,6 +257,21 @@ async function updateSelectedTags(action) { } } +async function enqueueInventoryExport() { + isSaving.value = true; + exportStatus.value = "Starting export..."; + try { + const job = await enqueueAdminInventoryExport(csrfToken()); + const completed = await getAdminInventoryExportJob(job.id); + exportStatus.value = `Export ${completed.status.toLowerCase()}.`; + } catch (requestError) { + error.value = requestError; + exportStatus.value = "Inventory export failed."; + } finally { + isSaving.value = false; + } +} + async function createListing() { const item = inventory.value.find( (candidate) => candidate.id === listingInventoryId.value, @@ -515,6 +533,15 @@ onMounted(load); > Download inventory Excel + + {{ exportStatus }}

Loading inventory... diff --git a/apps/web/src/services/admin-catalog-service.js b/apps/web/src/services/admin-catalog-service.js index a548192..05b7a75 100644 --- a/apps/web/src/services/admin-catalog-service.js +++ b/apps/web/src/services/admin-catalog-service.js @@ -46,4 +46,15 @@ export function updateAdminInventoryTags(inventoryItemIds, add, remove, csrfToke headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify({ action: 'TAGS', inventoryItemIds, add, remove }), }); +} + +export function enqueueAdminInventoryExport(csrfToken) { + return request('/admin/exports/inventory.json/jobs', { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + }); +} + +export function getAdminInventoryExportJob(jobId) { + return request(`/admin/exports/jobs/${jobId}`); } \ No newline at end of file diff --git a/apps/web/tests/catalog-service.test.js b/apps/web/tests/catalog-service.test.js index d1b6847..ce6aa04 100644 --- a/apps/web/tests/catalog-service.test.js +++ b/apps/web/tests/catalog-service.test.js @@ -70,6 +70,21 @@ test('archives selected admin inventory items through the bulk API', async () => } }); +test('fetches an admin inventory export job through the bulk job API', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options) => { + assert.equal(url, '/api/v1/admin/exports/jobs/job-1'); + return { ok: true, async json() { return { data: { id: 'job-1', status: 'COMPLETED' } }; } }; + }; + + try { + const { getAdminInventoryExportJob } = await import('../src/services/admin-catalog-service.js'); + assert.deepEqual(await getAdminInventoryExportJob('job-1'), { id: 'job-1', status: 'COMPLETED' }); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('enqueues selected admin inventory pricing refreshes through the bulk API', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async (url, options) => { diff --git a/apps/worker/src/inventory-export-worker.js b/apps/worker/src/inventory-export-worker.js new file mode 100644 index 0000000..79047dc --- /dev/null +++ b/apps/worker/src/inventory-export-worker.js @@ -0,0 +1,14 @@ +export async function processNextInventoryExportJob({ jobRepository, exportInventory }) { + const claimed = await jobRepository.claimNext(); + if (!claimed) return null; + + try { + const output = await exportInventory(claimed); + await jobRepository.complete(claimed.id, output); + return { ...claimed, status: 'COMPLETED', output }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Inventory export failed.'; + await jobRepository.fail(claimed.id, message); + return { ...claimed, status: 'FAILED', error: message }; + } +} diff --git a/apps/worker/src/worker-runtime.js b/apps/worker/src/worker-runtime.js index 01d4eeb..fe66a60 100644 --- a/apps/worker/src/worker-runtime.js +++ b/apps/worker/src/worker-runtime.js @@ -19,6 +19,11 @@ import { createPricingService } from "../../api/src/modules/pricing/services/pri import { createPricingProviderRegistry } from "../../api/src/modules/pricing/providers/pricing-provider.js"; import { createFixturePricingProvider } from "../../api/src/modules/pricing/providers/fixture-pricing-provider.js"; import { createHttpPricingProvider } from "../../api/src/modules/pricing/providers/http-pricing-provider.js"; +import { createInventoryItemRepository } from "../../api/src/modules/inventory/repositories/inventory-item-repository.js"; +import { createInventoryItemService } from "../../api/src/modules/inventory/services/inventory-item-service.js"; +import { createInventoryExportJobRepository } from "../../api/src/modules/exports/repositories/inventory-export-job-repository.js"; +import { createAdminExportService } from "../../api/src/modules/exports/services/admin-export-service.js"; +import { processNextInventoryExportJob } from "./inventory-export-worker.js"; import { processNextPersistentImageDerivativeJob } from "./image-derivative-worker.js"; import { processNextPersistentPricingRefreshJob, @@ -50,6 +55,13 @@ async function runWorker() { const pricingRefreshJobRepository = createPricingRefreshJobRepository( database.db, ); + const inventoryExportJobRepository = createInventoryExportJobRepository(database.db); + const adminExportService = createAdminExportService({ + inventoryExportJobRepository, + inventoryItemService: createInventoryItemService({ + inventoryItemRepository: createInventoryItemRepository(database.db), + }), + }); const providers = []; if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true") providers.push(createFixturePricingProvider()); @@ -82,6 +94,13 @@ async function runWorker() { } async function processOne() { + const exportResult = await processNextInventoryExportJob({ + jobRepository: inventoryExportJobRepository, + exportInventory: (job) => adminExportService.inventoryJson({ sellerId: job.sellerId }), + }); + if (exportResult) + console.log(`Inventory export job ${exportResult.id} ${exportResult.status.toLowerCase()}.`); + const result = await processNextPersistentImageDerivativeJob({ jobRepository, async loadSource(sourceKey) { diff --git a/apps/worker/tests/inventory-export-worker.test.js b/apps/worker/tests/inventory-export-worker.test.js new file mode 100644 index 0000000..1ae9eaa --- /dev/null +++ b/apps/worker/tests/inventory-export-worker.test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { processNextInventoryExportJob } from '../src/inventory-export-worker.js'; + +test('completes a claimed inventory export job', async () => { + const calls = []; + const result = await processNextInventoryExportJob({ + jobRepository: { + async claimNext() { return { id: 'job-1', status: 'PROCESSING' }; }, + async complete(id, output) { calls.push(['complete', id, output]); }, + }, + async exportInventory() { return [{ id: 'item-1' }]; }, + }); + + assert.equal(result.status, 'COMPLETED'); + assert.deepEqual(calls, [['complete', 'job-1', [{ id: 'item-1' }]]]); +}); + +test('fails an inventory export job when generation errors', async () => { + const calls = []; + const result = await processNextInventoryExportJob({ + jobRepository: { + async claimNext() { return { id: 'job-1', status: 'PROCESSING' }; }, + async fail(id, error) { calls.push(['fail', id, error]); }, + }, + async exportInventory() { throw new Error('export unavailable'); }, + }); + + assert.equal(result.status, 'FAILED'); + assert.deepEqual(calls, [['fail', 'job-1', 'export unavailable']]); +}); diff --git a/database/migrations/0024_inventory_export_jobs.sql b/database/migrations/0024_inventory_export_jobs.sql new file mode 100644 index 0000000..9458571 --- /dev/null +++ b/database/migrations/0024_inventory_export_jobs.sql @@ -0,0 +1,13 @@ +CREATE TABLE "inventory_export_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "seller_id" uuid NOT NULL REFERENCES "sellers"("id") ON DELETE CASCADE, + "format" text NOT NULL, + "status" text DEFAULT 'PENDING' NOT NULL, + "output" jsonb, + "error" text, + "created_at" timestamptz DEFAULT now() NOT NULL, + "updated_at" timestamptz DEFAULT now() NOT NULL +); + +CREATE INDEX "inventory_export_jobs_status_created_at_index" + ON "inventory_export_jobs" ("status", "created_at"); diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index bbf2b8e..633a3c8 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1790582400000, "tag": "0023_seller_settings", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1790668800000, + "tag": "0024_inventory_export_jobs", + "breakpoints": true } ] } diff --git a/docs/API.md b/docs/API.md index 9db5b78..0728f02 100644 --- a/docs/API.md +++ b/docs/API.md @@ -102,6 +102,8 @@ GET /api/v1/admin/dashboard GET /api/v1/admin/exports/inventory.json GET /api/v1/admin/exports/inventory.csv GET /api/v1/admin/exports/inventory.xls +POST /api/v1/admin/exports/inventory.json/jobs +GET /api/v1/admin/exports/jobs/:id GET /api/v1/admin/settings PATCH /api/v1/admin/settings diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index dd3bd90..dabebb5 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -153,6 +153,7 @@ Remaining roadmap areas are bulk status actions, asynchronous large exports, app - Admin inventory CSV export is implemented at `GET /api/v1/admin/exports/inventory.csv` with escaped spreadsheet-safe fields and seller-scoped admin DTOs. - Admin inventory Excel-compatible export is implemented at `GET /api/v1/admin/exports/inventory.xls` using the XML workbook format and seller-scoped admin DTOs. - Seller-scoped admin settings are implemented through `GET/PATCH /api/v1/admin/settings` with strict category allowlists and server-side partial-update merging. +- Large inventory JSON exports can be enqueued through `POST /api/v1/admin/exports/inventory.json/jobs` and polled through `GET /api/v1/admin/exports/jobs/:id`; the worker owns claim, completion, and failure processing. - PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow. - `npm audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.