diff --git a/apps/api/src/modules/inventory/routes/admin-inventory-routes.js b/apps/api/src/modules/inventory/routes/admin-inventory-routes.js index 38636ee..7e55dda 100644 --- a/apps/api/src/modules/inventory/routes/admin-inventory-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-inventory-routes.js @@ -1,6 +1,6 @@ import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; import { - bulkArchiveInventorySchema, + bulkInventorySchema, createInventoryItemSchema, } from '@pokemon-inventory/validation/inventory'; @@ -67,17 +67,29 @@ export async function registerAdminInventoryRoutes(app, options) { request.cookies[sessionCookieName], request.headers['x-csrf-token'], ); - const result = bulkArchiveInventorySchema.safeParse(request.body); + const result = bulkInventorySchema.safeParse(request.body); if (!result.success) { return validationError(reply, result); } - const inventoryItems = await inventoryItemService.archiveMany({ - inventoryItemIds: result.data.inventoryItemIds, - sellerId: request.admin.sellerId, - }); + const inventoryItems = result.data.action === 'ARCHIVE' + ? await inventoryItemService.archiveMany({ + inventoryItemIds: result.data.inventoryItemIds, + sellerId: request.admin.sellerId, + }) + : await inventoryItemService.refreshPricingMany({ + inventoryItemIds: result.data.inventoryItemIds, + providerId: result.data.providerId, + sellerId: request.admin.sellerId, + }); - return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) }); + if (result.data.action === 'ARCHIVE') { + return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) }); + } + + return reply.code(202).send({ + data: inventoryItems.map(({ id, status, createdAt }) => ({ id, status, createdAt })), + }); }); } \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/inventory-item-service.js b/apps/api/src/modules/inventory/services/inventory-item-service.js index eb75a08..770fc45 100644 --- a/apps/api/src/modules/inventory/services/inventory-item-service.js +++ b/apps/api/src/modules/inventory/services/inventory-item-service.js @@ -5,6 +5,7 @@ export function createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository, + pricingService, }) { return { async listBySellerId(sellerId) { @@ -47,5 +48,25 @@ export function createInventoryItemService({ requestedItems.forEach(({ status }) => archiveInventoryItem(status)); return inventoryItemRepository.archiveByIdsForSeller(inventoryItemIds, sellerId); }, + + async refreshPricingMany({ inventoryItemIds, sellerId, providerId }) { + if (!pricingService) { + throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503); + } + + const items = await inventoryItemRepository.listBySellerId(sellerId); + const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id)); + if (requestedItems.length !== inventoryItemIds.length) { + throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404); + } + + return Promise.all(requestedItems.map((item) => pricingService.enqueueRefresh({ + sellerId, + inventoryItemId: item.id, + providerId, + productId: item.productId, + conditionId: item.conditionId, + }))); + }, }; } \ No newline at end of file diff --git a/apps/api/tests/application/inventory-item-service.test.js b/apps/api/tests/application/inventory-item-service.test.js index e8950d4..f811a1d 100644 --- a/apps/api/tests/application/inventory-item-service.test.js +++ b/apps/api/tests/application/inventory-item-service.test.js @@ -79,6 +79,40 @@ test('does not archive sold inventory items', async () => { ); }); +test('enqueues pricing refresh jobs for owned inventory items', async () => { + const enqueued = []; + const service = createInventoryItemService({ + productRepository: { async findById() { return null; } }, + conditionRepository: { async findById() { return null; } }, + inventoryItemRepository: { + async listBySellerId() { + return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE' }]; + }, + }, + pricingService: { + async enqueueRefresh(input) { + enqueued.push(input); + return { id: 'job-1', status: 'PENDING' }; + }, + }, + }); + + const jobs = await service.refreshPricingMany({ + inventoryItemIds: ['item-1'], + sellerId: 'seller-id', + providerId: 'fixture-provider', + }); + + assert.deepEqual(enqueued, [{ + sellerId: 'seller-id', + inventoryItemId: 'item-1', + providerId: 'fixture-provider', + productId: 'product-1', + conditionId: 'condition-1', + }]); + assert.deepEqual(jobs, [{ id: 'job-1', status: 'PENDING' }]); +}); + test('rejects conditions from another category', async () => { const service = createService({ product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' }, diff --git a/apps/api/tests/http/admin-product-routes.test.js b/apps/api/tests/http/admin-product-routes.test.js index ffc75a0..900f255 100644 --- a/apps/api/tests/http/admin-product-routes.test.js +++ b/apps/api/tests/http/admin-product-routes.test.js @@ -5,7 +5,7 @@ import { buildApp } from '../../src/app/build-app.js'; import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js'; import { createProductService } from '../../src/modules/products/services/product-service.js'; -function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { +function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, pricingService } = {}) { const products = []; const inventoryItems = []; const productRepository = { @@ -70,6 +70,7 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { }); }, }, + pricingService, }), productService: createProductService({ productRepository }), }); @@ -193,6 +194,60 @@ test('archives selected inventory through the authenticated bulk route', async ( assert.equal(response.json().data[0].status, 'ARCHIVED'); }); +test('enqueues selected inventory pricing refreshes through the bulk route', async (testContext) => { + const enqueued = []; + const app = createTestApp({ + authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }), + pricingService: { + async enqueueRefresh(input) { + enqueued.push(input); + return { + id: 'job-1', + status: 'PENDING', + createdAt: new Date('2026-09-10T00:00:00.000Z'), + }; + }, + }, + }); + testContext.after(() => app.close()); + + const productResponse = await app.inject({ + method: 'POST', + url: '/api/v1/admin/products', + payload: { + name: 'Pikachu Plush', + categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958', + }, + }); + await app.inject({ + method: 'POST', + url: '/api/v1/admin/inventory', + payload: { + productId: productResponse.json().data.id, + conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', + }, + }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/bulk', + payload: { + action: 'PRICING_REFRESH', + inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'], + providerId: 'fixture-provider', + }, + }); + + assert.equal(response.statusCode, 202); + assert.deepEqual(enqueued, [{ + sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f', + inventoryItemId: '8a0a836a-a597-454d-8c5e-e146d27dbbf2', + providerId: 'fixture-provider', + productId: productResponse.json().data.id, + conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', + }]); + assert.equal(response.json().data[0].status, 'PENDING'); +}); + test('rejects an authenticated product write without a CSRF token', async (testContext) => { const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }), diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js index 7305ed0..45b3bce 100644 --- a/apps/web/src/pages/AdminHomePage.stories.js +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -19,7 +19,7 @@ export const BulkArchiveSelection = { docs: { description: { story: - "The physical inventory section supports selecting active copies and archiving them in one authenticated operation.", + "The physical inventory section supports selecting active copies for archive or asynchronous pricing refresh operations.", }, }, }, diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index 8e32f90..c866386 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -12,6 +12,7 @@ import { listAdminConditions, listAdminInventory, listAdminProducts, + refreshAdminInventoryPricing, } from "@/services/admin-catalog-service.js"; import { createAdminListing, @@ -213,6 +214,24 @@ async function archiveSelectedInventory() { } } +async function refreshSelectedPricing() { + if (!selectedInventoryIds.value.length) return; + isSaving.value = true; + try { + const jobs = await refreshAdminInventoryPricing( + selectedInventoryIds.value, + pricingProvider.value, + csrfToken(), + ); + pricingJobs.value = [...jobs, ...pricingJobs.value]; + selectedInventoryIds.value = []; + } catch (requestError) { + error.value = requestError; + } finally { + isSaving.value = false; + } +} + async function createListing() { const item = inventory.value.find( (candidate) => candidate.id === listingInventoryId.value, @@ -956,6 +975,14 @@ onMounted(load); > Archive selected +

No physical copies yet. diff --git a/apps/web/src/services/admin-catalog-service.js b/apps/web/src/services/admin-catalog-service.js index e3156c7..e6d73ed 100644 --- a/apps/web/src/services/admin-catalog-service.js +++ b/apps/web/src/services/admin-catalog-service.js @@ -30,4 +30,12 @@ export function archiveAdminInventory(inventoryItemIds, csrfToken) { headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify({ action: 'ARCHIVE', inventoryItemIds }), }); +} + +export function refreshAdminInventoryPricing(inventoryItemIds, providerId, csrfToken) { + return request('/admin/bulk', { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + body: JSON.stringify({ action: 'PRICING_REFRESH', inventoryItemIds, providerId }), + }); } \ No newline at end of file diff --git a/apps/web/tests/catalog-service.test.js b/apps/web/tests/catalog-service.test.js index 70f1564..d686eef 100644 --- a/apps/web/tests/catalog-service.test.js +++ b/apps/web/tests/catalog-service.test.js @@ -68,4 +68,28 @@ test('archives selected admin inventory items through the bulk API', async () => } finally { globalThis.fetch = originalFetch; } +}); + +test('enqueues selected admin inventory pricing refreshes through the bulk API', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options) => { + assert.equal(url, '/api/v1/admin/bulk'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['x-csrf-token'], 'csrf-token'); + assert.deepEqual(JSON.parse(options.body), { + action: 'PRICING_REFRESH', + inventoryItemIds: ['item-1'], + providerId: 'fixture-provider', + }); + return { ok: true, async json() { return { data: [{ id: 'job-1', status: 'PENDING' }] }; } }; + }; + + try { + const { refreshAdminInventoryPricing } = await import('../src/services/admin-catalog-service.js'); + assert.deepEqual(await refreshAdminInventoryPricing(['item-1'], 'fixture-provider', 'csrf-token'), [ + { id: 'job-1', status: 'PENDING' }, + ]); + } finally { + globalThis.fetch = originalFetch; + } }); \ No newline at end of file diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 2bccd65..0bcedfd 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -148,7 +148,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett - Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly. - 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 is implemented through the seller-scoped API boundary; bulk status changes, tags, and bulk pricing refresh remain. +- Bulk archive and asynchronous bulk pricing refresh are implemented through the seller-scoped API boundary; bulk status changes and tags remain. - 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. diff --git a/packages/validation/inventory.js b/packages/validation/inventory.js index 8de716e..6afd228 100644 --- a/packages/validation/inventory.js +++ b/packages/validation/inventory.js @@ -13,4 +13,17 @@ export const bulkArchiveInventorySchema = z action: z.literal('ARCHIVE'), inventoryItemIds: z.array(z.uuid()).min(1).max(100), }) - .strict(); \ No newline at end of file + .strict(); + +export const bulkPricingRefreshInventorySchema = z + .object({ + action: z.literal('PRICING_REFRESH'), + inventoryItemIds: z.array(z.uuid()).min(1).max(100), + providerId: z.string().trim().min(1).max(100).optional(), + }) + .strict(); + +export const bulkInventorySchema = z.discriminatedUnion('action', [ + bulkArchiveInventorySchema, + bulkPricingRefreshInventorySchema, +]); \ No newline at end of file