This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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('"', '""')}"`;
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -479,6 +479,13 @@ onMounted(load);
|
||||
>
|
||||
Download inventory JSON
|
||||
</a>
|
||||
<a
|
||||
class="button button--secondary"
|
||||
href="/api/v1/admin/exports/inventory.csv"
|
||||
download="inventory.csv"
|
||||
>
|
||||
Download inventory CSV
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="isLoading" class="state state--loading" role="status">
|
||||
Loading inventory...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user