feat: add asynchronous inventory exports
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 01:25:11 -04:00
parent abb0b64217
commit 7c4885cf65
16 changed files with 269 additions and 1 deletions
+2
View File
@@ -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,
@@ -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;
},
};
}
@@ -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,
},
});
});
}
@@ -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);
},
@@ -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, '<?xml version="1.0"?><Workbook />');
});
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' }]);
});
@@ -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: {
+27
View File
@@ -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
</a>
<button
class="button button--secondary"
type="button"
:disabled="isSaving"
@click="enqueueInventoryExport"
>
Queue JSON export
</button>
<span v-if="exportStatus" role="status">{{ exportStatus }}</span>
</p>
<p v-if="isLoading" class="state state--loading" role="status">
Loading inventory...
@@ -47,3 +47,14 @@ export function updateAdminInventoryTags(inventoryItemIds, add, remove, csrfToke
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}`);
}
+15
View File
@@ -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) => {
@@ -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 };
}
}
+19
View File
@@ -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) {
@@ -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']]);
});
@@ -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");
+7
View File
@@ -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
}
]
}
+2
View File
@@ -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
+1
View File
@@ -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.