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 { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
|
||||||
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js";
|
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js";
|
||||||
import { createAdminExportService } from "../modules/exports/services/admin-export-service.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 { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
|
||||||
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
|
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ async function startServer() {
|
|||||||
}),
|
}),
|
||||||
inventoryItemService,
|
inventoryItemService,
|
||||||
adminExportService: createAdminExportService({
|
adminExportService: createAdminExportService({
|
||||||
|
inventoryExportJobRepository: createInventoryExportJobRepository(database.db),
|
||||||
inventoryItemService,
|
inventoryItemService,
|
||||||
}),
|
}),
|
||||||
sellerSettingsService,
|
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"')
|
.header('content-disposition', 'attachment; filename="inventory.xls"')
|
||||||
.send(data);
|
.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';
|
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
|
||||||
|
|
||||||
export function createAdminExportService({ inventoryItemService }) {
|
export function createAdminExportService({ inventoryItemService, inventoryExportJobRepository }) {
|
||||||
async function inventoryRows(sellerId) {
|
async function inventoryRows(sellerId) {
|
||||||
const inventoryItems = await inventoryItemService.listBySellerId(sellerId);
|
const inventoryItems = await inventoryItemService.listBySellerId(sellerId);
|
||||||
return inventoryItems.map(toAdminInventoryItemDto);
|
return inventoryItems.map(toAdminInventoryItemDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
async enqueueInventoryJson({ sellerId }) {
|
||||||
|
return inventoryExportJobRepository.create({ sellerId, format: 'JSON' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getExportJob({ sellerId, jobId }) {
|
||||||
|
return inventoryExportJobRepository.findByIdForSeller(jobId, sellerId);
|
||||||
|
},
|
||||||
|
|
||||||
async inventoryJson({ sellerId }) {
|
async inventoryJson({ sellerId }) {
|
||||||
return inventoryRows(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.headers['content-disposition'], 'attachment; filename="inventory.xls"');
|
||||||
assert.equal(response.body, '<?xml version="1.0"?><Workbook />');
|
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 = {
|
export const BulkTagSelection = {
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
listAdminProducts,
|
listAdminProducts,
|
||||||
refreshAdminInventoryPricing,
|
refreshAdminInventoryPricing,
|
||||||
updateAdminInventoryTags,
|
updateAdminInventoryTags,
|
||||||
|
enqueueAdminInventoryExport,
|
||||||
|
getAdminInventoryExportJob,
|
||||||
} from "@/services/admin-catalog-service.js";
|
} from "@/services/admin-catalog-service.js";
|
||||||
import {
|
import {
|
||||||
createAdminListing,
|
createAdminListing,
|
||||||
@@ -52,6 +54,7 @@ const products = ref([]);
|
|||||||
const inventory = ref([]);
|
const inventory = ref([]);
|
||||||
const selectedInventoryIds = ref([]);
|
const selectedInventoryIds = ref([]);
|
||||||
const inventoryTags = ref("");
|
const inventoryTags = ref("");
|
||||||
|
const exportStatus = ref("");
|
||||||
const listings = ref([]);
|
const listings = ref([]);
|
||||||
const sales = ref([]);
|
const sales = ref([]);
|
||||||
const acquisitions = 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() {
|
async function createListing() {
|
||||||
const item = inventory.value.find(
|
const item = inventory.value.find(
|
||||||
(candidate) => candidate.id === listingInventoryId.value,
|
(candidate) => candidate.id === listingInventoryId.value,
|
||||||
@@ -515,6 +533,15 @@ onMounted(load);
|
|||||||
>
|
>
|
||||||
Download inventory Excel
|
Download inventory Excel
|
||||||
</a>
|
</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>
|
||||||
<p v-if="isLoading" class="state state--loading" role="status">
|
<p v-if="isLoading" class="state state--loading" role="status">
|
||||||
Loading inventory...
|
Loading inventory...
|
||||||
|
|||||||
@@ -47,3 +47,14 @@ export function updateAdminInventoryTags(inventoryItemIds, add, remove, csrfToke
|
|||||||
body: JSON.stringify({ action: 'TAGS', inventoryItemIds, add, remove }),
|
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 () => {
|
test('enqueues selected admin inventory pricing refreshes through the bulk API', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async (url, options) => {
|
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 { createPricingProviderRegistry } from "../../api/src/modules/pricing/providers/pricing-provider.js";
|
||||||
import { createFixturePricingProvider } from "../../api/src/modules/pricing/providers/fixture-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 { 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 { processNextPersistentImageDerivativeJob } from "./image-derivative-worker.js";
|
||||||
import {
|
import {
|
||||||
processNextPersistentPricingRefreshJob,
|
processNextPersistentPricingRefreshJob,
|
||||||
@@ -50,6 +55,13 @@ async function runWorker() {
|
|||||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||||
database.db,
|
database.db,
|
||||||
);
|
);
|
||||||
|
const inventoryExportJobRepository = createInventoryExportJobRepository(database.db);
|
||||||
|
const adminExportService = createAdminExportService({
|
||||||
|
inventoryExportJobRepository,
|
||||||
|
inventoryItemService: createInventoryItemService({
|
||||||
|
inventoryItemRepository: createInventoryItemRepository(database.db),
|
||||||
|
}),
|
||||||
|
});
|
||||||
const providers = [];
|
const providers = [];
|
||||||
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
||||||
providers.push(createFixturePricingProvider());
|
providers.push(createFixturePricingProvider());
|
||||||
@@ -82,6 +94,13 @@ async function runWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function processOne() {
|
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({
|
const result = await processNextPersistentImageDerivativeJob({
|
||||||
jobRepository,
|
jobRepository,
|
||||||
async loadSource(sourceKey) {
|
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,
|
"when": 1790582400000,
|
||||||
"tag": "0023_seller_settings",
|
"tag": "0023_seller_settings",
|
||||||
"breakpoints": true
|
"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.json
|
||||||
GET /api/v1/admin/exports/inventory.csv
|
GET /api/v1/admin/exports/inventory.csv
|
||||||
GET /api/v1/admin/exports/inventory.xls
|
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
|
GET /api/v1/admin/settings
|
||||||
PATCH /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 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.
|
- 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.
|
- 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.
|
- 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.
|
- `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