From 24f51212e5b6c2a58dcaa3dd3e3f980d5df2a706 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 10 Sep 2026 01:11:07 -0400 Subject: [PATCH] feat: add admin inventory Excel export --- .../exports/routes/admin-export-routes.js | 11 ++++++++ .../exports/services/admin-export-service.js | 25 +++++++++++++++++++ .../application/admin-export-service.test.js | 15 +++++++++++ .../tests/http/admin-export-routes.test.js | 20 +++++++++++++++ apps/web/src/pages/AdminHomePage.stories.js | 11 ++++++++ apps/web/src/pages/AdminHomePage.vue | 7 ++++++ docs/API.md | 1 + docs/IMPLEMENTATION-STATUS.md | 1 + 8 files changed, 91 insertions(+) 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 14ef428..7f0946f 100644 --- a/apps/api/src/modules/exports/routes/admin-export-routes.js +++ b/apps/api/src/modules/exports/routes/admin-export-routes.js @@ -37,4 +37,15 @@ export async function registerAdminExportRoutes(app, options) { .header('content-disposition', 'attachment; filename="inventory.csv"') .send(data); }); + + app.get('/exports/inventory.xls', async (request, reply) => { + const data = await adminExportService.inventoryExcel({ + sellerId: request.admin.sellerId, + }); + + return reply + .type('application/vnd.ms-excel; charset=utf-8') + .header('content-disposition', 'attachment; filename="inventory.xls"') + .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 d1efbed..35ad5ea 100644 --- a/apps/api/src/modules/exports/services/admin-export-service.js +++ b/apps/api/src/modules/exports/services/admin-export-service.js @@ -19,9 +19,34 @@ export function createAdminExportService({ inventoryItemService }) { ...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')), ].join('\r\n'); }, + + async inventoryExcel({ sellerId }) { + const rows = await inventoryRows(sellerId); + const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt']; + const header = columns.map((column) => excelCell(column, true)).join(''); + const body = rows.map((row) => ( + `${columns.map((column) => excelCell(row[column])).join('')}` + )).join(''); + + return `\n\n${header}${body}
`; + }, }; } +function excelCell(value, header = false) { + const style = header ? ' ss:StyleID="Header"' : ''; + return `${escapeXml(value)}`; +} + +function escapeXml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + 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 ee3f462..292ec19 100644 --- a/apps/api/tests/application/admin-export-service.test.js +++ b/apps/api/tests/application/admin-export-service.test.js @@ -52,3 +52,18 @@ test('exports inventory as escaped CSV rows', async () => { '"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""', ].join('\r\n')); }); + +test('exports inventory as an escaped Excel XML workbook', async () => { + const service = createAdminExportService({ + inventoryItemService: { + async listBySellerId() { + return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE', notes: 'A ' }]; + }, + }, + }); + + const workbook = await service.inventoryExcel({ sellerId: 'seller-1' }); + assert.match(workbook, /Excel\.Sheet/); + assert.match(workbook, /A <note>/); + assert.match(workbook, /id<\/Data><\/Cell>/); +}); diff --git a/apps/api/tests/http/admin-export-routes.test.js b/apps/api/tests/http/admin-export-routes.test.js index 60170eb..5d935a5 100644 --- a/apps/api/tests/http/admin-export-routes.test.js +++ b/apps/api/tests/http/admin-export-routes.test.js @@ -80,3 +80,23 @@ test('downloads seller-scoped inventory CSV for an authenticated admin', async ( assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"'); assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"'); }); + +test('downloads seller-scoped inventory Excel XML for an authenticated admin', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + adminExportService: { + async inventoryExcel({ sellerId }) { + assert.equal(sellerId, 'seller-1'); + return ''; + }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/inventory.xls' }); + + assert.equal(response.statusCode, 200); + assert.equal(response.headers['content-type'], 'application/vnd.ms-excel; charset=utf-8'); + assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"'); + assert.equal(response.body, ''); +}); diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js index 5e3d9f8..3a9eb85 100644 --- a/apps/web/src/pages/AdminHomePage.stories.js +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -47,6 +47,17 @@ export const InventoryCsvExport = { }, }; +export const InventoryExcelExport = { + parameters: { + docs: { + description: { + story: + "The admin workspace exposes a seller-scoped Excel-compatible inventory download.", + }, + }, + }, +}; + export const BulkTagSelection = { parameters: { docs: { diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index 1967de6..dba1b09 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -508,6 +508,13 @@ onMounted(load); > Download inventory CSV + + Download inventory Excel +

Loading inventory... diff --git a/docs/API.md b/docs/API.md index 59723a4..8e7137a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,6 +101,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/exports/inventory.xls 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 11bbc9b..4330973 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -151,6 +151,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett - Bulk archive, asynchronous bulk pricing refresh, and Product-level bulk tag updates are implemented through the seller-scoped API boundary; bulk status changes 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. +- 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. - 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.