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, '
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.