diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 8a0adee..6ec95ff 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -20,6 +20,7 @@ import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/a import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js"; import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js"; import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js"; +import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js"; export function buildApp(dependencies) { const app = Fastify(); @@ -88,6 +89,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: "/api/v1/admin", }); + app.register(registerAdminExportRoutes, { + ...dependencies, + prefix: "/api/v1/admin", + }); app.register(registerPublicSearchRoutes, { ...dependencies, prefix: "/api/v1", diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index ce6aacf..eaa261f 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -52,6 +52,7 @@ import { createProviderMappingService } from "../modules/pricing/services/provid import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js"; import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js"; import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js"; +import { createAdminExportService } from "../modules/exports/services/admin-export-service.js"; function parsePort(value) { const port = Number.parseInt(value ?? "3000", 10); @@ -101,6 +102,18 @@ async function startServer() { }), ); const pricingProviders = createPricingProviderRegistry(providers); + const pricingService = createPricingService({ + pricingRepository, + pricingRefreshJobRepository, + providerMappingRepository, + pricingProviderRegistry: pricingProviders, + }); + const inventoryItemService = createInventoryItemService({ + conditionRepository, + inventoryItemRepository, + pricingService, + productRepository, + }); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -146,10 +159,9 @@ async function startServer() { keyPrefix: "pokemon-inventory:auth:password-reset:", redisClient: redis.client, }), - inventoryItemService: createInventoryItemService({ - conditionRepository, - inventoryItemRepository, - productRepository, + inventoryItemService, + adminExportService: createAdminExportService({ + inventoryItemService, }), catalogLookupRepository, publicImageService, @@ -170,11 +182,7 @@ async function startServer() { acquisitionService: createAcquisitionService({ acquisitionRepository }), dashboardService: createDashboardService({ productService: createProductService({ productRepository }), - inventoryItemService: createInventoryItemService({ - conditionRepository, - inventoryItemRepository, - productRepository, - }), + inventoryItemService, listingService: createListingService({ listingRepository, inventoryImageRepository, @@ -184,12 +192,7 @@ async function startServer() { pricingRepository, pricingRefreshJobRepository, }), - pricingService: createPricingService({ - pricingRepository, - pricingRefreshJobRepository, - providerMappingRepository, - pricingProviderRegistry: pricingProviders, - }), + pricingService, providerMappingService: createProviderMappingService({ providerMappingRepository, }), diff --git a/apps/api/src/modules/exports/routes/admin-export-routes.js b/apps/api/src/modules/exports/routes/admin-export-routes.js new file mode 100644 index 0000000..90876be --- /dev/null +++ b/apps/api/src/modules/exports/routes/admin-export-routes.js @@ -0,0 +1,29 @@ +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export async function registerAdminExportRoutes(app, options) { + const { + adminExportService, + authenticateAdmin = async () => { + throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + }, + } = options; + + app.addHook('preHandler', async (request) => { + const admin = await authenticateAdmin(request); + if (!admin?.sellerId) { + throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + } + request.admin = admin; + }); + + app.get('/exports/inventory.json', async (request, reply) => { + const data = await adminExportService.inventoryJson({ + sellerId: request.admin.sellerId, + }); + + return reply + .type('application/json; charset=utf-8') + .header('content-disposition', 'attachment; filename="inventory.json"') + .send(JSON.stringify(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 new file mode 100644 index 0000000..1cdf69e --- /dev/null +++ b/apps/api/src/modules/exports/services/admin-export-service.js @@ -0,0 +1,10 @@ +import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; + +export function createAdminExportService({ inventoryItemService }) { + return { + async inventoryJson({ sellerId }) { + const inventoryItems = await inventoryItemService.listBySellerId(sellerId); + return inventoryItems.map(toAdminInventoryItemDto); + }, + }; +} diff --git a/apps/api/tests/application/admin-export-service.test.js b/apps/api/tests/application/admin-export-service.test.js new file mode 100644 index 0000000..5e8ee98 --- /dev/null +++ b/apps/api/tests/application/admin-export-service.test.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js'; + +test('exports seller inventory through the admin DTO boundary', async () => { + const service = createAdminExportService({ + inventoryItemService: { + async listBySellerId(sellerId) { + assert.equal(sellerId, 'seller-1'); + return [{ + id: 'item-1', + productId: 'product-1', + conditionId: 'condition-1', + status: 'ACTIVE', + notes: 'Private note', + createdAt: new Date('2026-09-10T00:00:00.000Z'), + updatedAt: new Date('2026-09-10T00:00:00.000Z'), + }]; + }, + }, + }); + + assert.deepEqual(await service.inventoryJson({ sellerId: 'seller-1' }), [{ + id: 'item-1', + productId: 'product-1', + conditionId: 'condition-1', + status: 'ACTIVE', + notes: 'Private note', + createdAt: '2026-09-10T00:00:00.000Z', + updatedAt: '2026-09-10T00:00:00.000Z', + }]); +}); diff --git a/apps/api/tests/http/admin-export-routes.test.js b/apps/api/tests/http/admin-export-routes.test.js new file mode 100644 index 0000000..6ecde95 --- /dev/null +++ b/apps/api/tests/http/admin-export-routes.test.js @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; +import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js'; + +test('downloads seller-scoped inventory JSON for an authenticated admin', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + adminExportService: createAdminExportService({ + inventoryItemService: { + async listBySellerId(sellerId) { + assert.equal(sellerId, 'seller-1'); + return [{ + id: 'item-1', + productId: 'product-1', + conditionId: 'condition-1', + status: 'ACTIVE', + notes: 'Private note', + }]; + }, + }, + }), + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/admin/exports/inventory.json', + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.headers['content-type'], 'application/json; charset=utf-8'); + assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.json"'); + assert.deepEqual(JSON.parse(response.body), [{ + id: 'item-1', + productId: 'product-1', + conditionId: 'condition-1', + status: 'ACTIVE', + notes: 'Private note', + }]); +}); + +test('requires authentication for inventory JSON exports', async (testContext) => { + const app = buildApp({ + adminExportService: createAdminExportService({ + inventoryItemService: { async listBySellerId() { return []; } }, + }), + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/admin/exports/inventory.json', + }); + + assert.equal(response.statusCode, 401); + assert.equal(response.json().error.code, 'UNAUTHORIZED'); +}); diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js index 45b3bce..7ecabf6 100644 --- a/apps/web/src/pages/AdminHomePage.stories.js +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -24,3 +24,14 @@ export const BulkArchiveSelection = { }, }, }; + +export const InventoryJsonExport = { + parameters: { + docs: { + description: { + story: + "The admin workspace exposes a seller-scoped inventory JSON download.", + }, + }, + }, +}; diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index c866386..f8f3edd 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -471,6 +471,15 @@ onMounted(load);

{{ principal?.email }} ยท Build the catalog one physical copy at a time.

+

+ + Download inventory JSON + +

Loading inventory...

diff --git a/docs/API.md b/docs/API.md index aa3d8b4..a0ddc5b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -97,6 +97,7 @@ POST /api/v1/admin/inventory/:id/pricing/refresh POST /api/v1/admin/bulk GET /api/v1/admin/dashboard +GET /api/v1/admin/exports/inventory.json 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 0bcedfd..79d49cf 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -149,6 +149,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett - Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries. - 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. - 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.