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.