This commit is contained in:
@@ -20,6 +20,7 @@ import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/a
|
|||||||
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
|
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
|
||||||
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
|
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
|
||||||
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-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) {
|
export function buildApp(dependencies) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
@@ -88,6 +89,10 @@ export function buildApp(dependencies) {
|
|||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: "/api/v1/admin",
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
|
app.register(registerAdminExportRoutes, {
|
||||||
|
...dependencies,
|
||||||
|
prefix: "/api/v1/admin",
|
||||||
|
});
|
||||||
app.register(registerPublicSearchRoutes, {
|
app.register(registerPublicSearchRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: "/api/v1",
|
prefix: "/api/v1",
|
||||||
|
|||||||
+18
-15
@@ -52,6 +52,7 @@ import { createProviderMappingService } from "../modules/pricing/services/provid
|
|||||||
import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
|
import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
|
||||||
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
|
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
|
||||||
import { createHttpPricingProvider } from "../modules/pricing/providers/http-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) {
|
function parsePort(value) {
|
||||||
const port = Number.parseInt(value ?? "3000", 10);
|
const port = Number.parseInt(value ?? "3000", 10);
|
||||||
@@ -101,6 +102,18 @@ async function startServer() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const pricingProviders = createPricingProviderRegistry(providers);
|
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 userRepository = createUserRepository(database.db);
|
||||||
const sessionRepository = createSessionRepository(database.db);
|
const sessionRepository = createSessionRepository(database.db);
|
||||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||||
@@ -146,10 +159,9 @@ async function startServer() {
|
|||||||
keyPrefix: "pokemon-inventory:auth:password-reset:",
|
keyPrefix: "pokemon-inventory:auth:password-reset:",
|
||||||
redisClient: redis.client,
|
redisClient: redis.client,
|
||||||
}),
|
}),
|
||||||
inventoryItemService: createInventoryItemService({
|
inventoryItemService,
|
||||||
conditionRepository,
|
adminExportService: createAdminExportService({
|
||||||
inventoryItemRepository,
|
inventoryItemService,
|
||||||
productRepository,
|
|
||||||
}),
|
}),
|
||||||
catalogLookupRepository,
|
catalogLookupRepository,
|
||||||
publicImageService,
|
publicImageService,
|
||||||
@@ -170,11 +182,7 @@ async function startServer() {
|
|||||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||||
dashboardService: createDashboardService({
|
dashboardService: createDashboardService({
|
||||||
productService: createProductService({ productRepository }),
|
productService: createProductService({ productRepository }),
|
||||||
inventoryItemService: createInventoryItemService({
|
inventoryItemService,
|
||||||
conditionRepository,
|
|
||||||
inventoryItemRepository,
|
|
||||||
productRepository,
|
|
||||||
}),
|
|
||||||
listingService: createListingService({
|
listingService: createListingService({
|
||||||
listingRepository,
|
listingRepository,
|
||||||
inventoryImageRepository,
|
inventoryImageRepository,
|
||||||
@@ -184,12 +192,7 @@ async function startServer() {
|
|||||||
pricingRepository,
|
pricingRepository,
|
||||||
pricingRefreshJobRepository,
|
pricingRefreshJobRepository,
|
||||||
}),
|
}),
|
||||||
pricingService: createPricingService({
|
pricingService,
|
||||||
pricingRepository,
|
|
||||||
pricingRefreshJobRepository,
|
|
||||||
providerMappingRepository,
|
|
||||||
pricingProviderRegistry: pricingProviders,
|
|
||||||
}),
|
|
||||||
providerMappingService: createProviderMappingService({
|
providerMappingService: createProviderMappingService({
|
||||||
providerMappingRepository,
|
providerMappingRepository,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -24,3 +24,14 @@ export const BulkArchiveSelection = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const InventoryJsonExport = {
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
"The admin workspace exposes a seller-scoped inventory JSON download.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -471,6 +471,15 @@ onMounted(load);
|
|||||||
<p class="detail-notes">
|
<p class="detail-notes">
|
||||||
{{ principal?.email }} · Build the catalog one physical copy at a time.
|
{{ principal?.email }} · Build the catalog one physical copy at a time.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
class="button button--secondary"
|
||||||
|
href="/api/v1/admin/exports/inventory.json"
|
||||||
|
download="inventory.json"
|
||||||
|
>
|
||||||
|
Download inventory JSON
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
<p v-if="isLoading" class="state state--loading" role="status">
|
<p v-if="isLoading" class="state state--loading" role="status">
|
||||||
Loading inventory...
|
Loading inventory...
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ POST /api/v1/admin/inventory/:id/pricing/refresh
|
|||||||
POST /api/v1/admin/bulk
|
POST /api/v1/admin/bulk
|
||||||
|
|
||||||
GET /api/v1/admin/dashboard
|
GET /api/v1/admin/dashboard
|
||||||
|
GET /api/v1/admin/exports/inventory.json
|
||||||
|
|
||||||
GET /api/v1/admin/saved-views
|
GET /api/v1/admin/saved-views
|
||||||
POST /api/v1/admin/saved-views
|
POST /api/v1/admin/saved-views
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- `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