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 90876be..14ef428 100644 --- a/apps/api/src/modules/exports/routes/admin-export-routes.js +++ b/apps/api/src/modules/exports/routes/admin-export-routes.js @@ -26,4 +26,15 @@ export async function registerAdminExportRoutes(app, options) { .header('content-disposition', 'attachment; filename="inventory.json"') .send(JSON.stringify(data)); }); + + app.get('/exports/inventory.csv', async (request, reply) => { + const data = await adminExportService.inventoryCsv({ + sellerId: request.admin.sellerId, + }); + + return reply + .type('text/csv; charset=utf-8') + .header('content-disposition', 'attachment; filename="inventory.csv"') + .send(data); + }); } 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 1cdf69e..d1efbed 100644 --- a/apps/api/src/modules/exports/services/admin-export-service.js +++ b/apps/api/src/modules/exports/services/admin-export-service.js @@ -1,10 +1,28 @@ import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; export function createAdminExportService({ inventoryItemService }) { + async function inventoryRows(sellerId) { + const inventoryItems = await inventoryItemService.listBySellerId(sellerId); + return inventoryItems.map(toAdminInventoryItemDto); + } + return { async inventoryJson({ sellerId }) { - const inventoryItems = await inventoryItemService.listBySellerId(sellerId); - return inventoryItems.map(toAdminInventoryItemDto); + return inventoryRows(sellerId); + }, + + async inventoryCsv({ sellerId }) { + const rows = await inventoryRows(sellerId); + const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt']; + return [ + columns.join(','), + ...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')), + ].join('\r\n'); }, }; } + +function escapeCsvValue(value) { + const text = value == null ? '' : String(value); + return `"${text.replaceAll('"', '""')}"`; +} diff --git a/apps/api/tests/application/admin-export-service.test.js b/apps/api/tests/application/admin-export-service.test.js index 5e8ee98..ee3f462 100644 --- a/apps/api/tests/application/admin-export-service.test.js +++ b/apps/api/tests/application/admin-export-service.test.js @@ -31,3 +31,24 @@ test('exports seller inventory through the admin DTO boundary', async () => { updatedAt: '2026-09-10T00:00:00.000Z', }]); }); + +test('exports inventory as escaped CSV rows', async () => { + const service = createAdminExportService({ + inventoryItemService: { + async listBySellerId() { + return [{ + id: 'item-1', + productId: 'product-1', + conditionId: 'condition-1', + status: 'ACTIVE', + notes: 'Front, "clean"\nback', + }]; + }, + }, + }); + + assert.equal(await service.inventoryCsv({ sellerId: 'seller-1' }), [ + 'id,productId,conditionId,status,notes,createdAt,updatedAt', + '"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""', + ].join('\r\n')); +}); diff --git a/apps/api/tests/http/admin-export-routes.test.js b/apps/api/tests/http/admin-export-routes.test.js index 6ecde95..60170eb 100644 --- a/apps/api/tests/http/admin-export-routes.test.js +++ b/apps/api/tests/http/admin-export-routes.test.js @@ -57,3 +57,26 @@ test('requires authentication for inventory JSON exports', async (testContext) = assert.equal(response.statusCode, 401); assert.equal(response.json().error.code, 'UNAUTHORIZED'); }); + +test('downloads seller-scoped inventory CSV for an authenticated admin', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + adminExportService: { + async inventoryCsv({ sellerId }) { + assert.equal(sellerId, 'seller-1'); + return 'id,status\r\n"item-1","ACTIVE"'; + }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/admin/exports/inventory.csv', + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.headers['content-type'], 'text/csv; charset=utf-8'); + assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"'); + assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"'); +}); diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js index 7ecabf6..126aaa1 100644 --- a/apps/web/src/pages/AdminHomePage.stories.js +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -35,3 +35,14 @@ export const InventoryJsonExport = { }, }, }; + +export const InventoryCsvExport = { + parameters: { + docs: { + description: { + story: + "The admin workspace exposes a seller-scoped inventory CSV download for spreadsheet workflows.", + }, + }, + }, +}; diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index f8f3edd..b8b9075 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -479,6 +479,13 @@ onMounted(load); > Download inventory JSON + + Download inventory CSV +
Loading inventory... diff --git a/docs/API.md b/docs/API.md index a0ddc5b..30c216a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -98,6 +98,7 @@ POST /api/v1/admin/bulk GET /api/v1/admin/dashboard GET /api/v1/admin/exports/inventory.json +GET /api/v1/admin/exports/inventory.csv GET /api/v1/admin/saved-views POST /api/v1/admin/saved-views diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 79d49cf..b8c6ca9 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -150,6 +150,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett - Pricing refresh jobs have a bounded three-attempt retry policy with exponential backoff. The worker schedules completed-job cleanup using configurable retention days and cleanup interval settings. - Bulk archive and asynchronous bulk pricing refresh are implemented through the seller-scoped API boundary; bulk status changes and tags remain. - Admin inventory JSON export is implemented at `GET /api/v1/admin/exports/inventory.json` and serializes seller-scoped admin DTOs as a downloadable attachment. +- Admin inventory CSV export is implemented at `GET /api/v1/admin/exports/inventory.csv` with escaped spreadsheet-safe fields and seller-scoped admin DTOs. - 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.