From 7c9518923d0556de1059e7dc0ad252322329e434 Mon Sep 17 00:00:00 2001
From: FragginWagon
Date: Thu, 10 Sep 2026 00:31:00 -0400
Subject: [PATCH] feat: add admin inventory CSV export
---
.../exports/routes/admin-export-routes.js | 11 +++++++++
.../exports/services/admin-export-service.js | 22 ++++++++++++++++--
.../application/admin-export-service.test.js | 21 +++++++++++++++++
.../tests/http/admin-export-routes.test.js | 23 +++++++++++++++++++
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, 95 insertions(+), 2 deletions(-)
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.