This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user