diff --git a/apps/api/src/modules/inventory/domain/inventory-item.js b/apps/api/src/modules/inventory/domain/inventory-item.js index 09bca0f..75db5c4 100644 --- a/apps/api/src/modules/inventory/domain/inventory-item.js +++ b/apps/api/src/modules/inventory/domain/inventory-item.js @@ -31,6 +31,10 @@ export function transitionInventoryStatus(currentStatus, nextStatus) { return nextStatus; } +export function archiveInventoryItem(status) { + return transitionInventoryStatus(status, INVENTORY_STATUS.ARCHIVED); +} + export function assertInventoryItemEditable(status) { if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) { throw new Error('Inventory item is locked against ordinary edits.'); diff --git a/apps/api/src/modules/inventory/repositories/inventory-item-repository.js b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js index b8a1675..79dd98a 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-item-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js @@ -1,4 +1,4 @@ -import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'; +import { and, asc, count, desc, eq, exists, inArray, sql } from 'drizzle-orm'; import { inventoryItems, @@ -35,6 +35,28 @@ export function createInventoryItemRepository(db) { .then((rows) => rows.map(({ item }) => item)); }, + async archiveByIdsForSeller(inventoryItemIds, sellerId) { + if (inventoryItemIds.length === 0) return []; + + return db + .update(inventoryItems) + .set({ status: 'ARCHIVED', updatedAt: new Date() }) + .where(and( + inArray(inventoryItems.id, inventoryItemIds), + eq(inventoryItems.status, 'ACTIVE'), + exists( + db + .select({ one: sql`1` }) + .from(products) + .where(and( + eq(products.id, inventoryItems.productId), + eq(products.sellerId, sellerId), + )), + ), + )) + .returning(); + }, + async searchPublic({ page = 1, limit = 20 } = {}) { const conditions = [ eq(inventoryItems.status, 'ACTIVE'), 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 d15cd73..38636ee 100644 --- a/apps/api/src/modules/inventory/routes/admin-inventory-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-inventory-routes.js @@ -1,5 +1,8 @@ import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; -import { createInventoryItemSchema } from '@pokemon-inventory/validation/inventory'; +import { + bulkArchiveInventorySchema, + createInventoryItemSchema, +} from '@pokemon-inventory/validation/inventory'; import { createApplicationError } from '../../../shared/errors/application-error.js'; @@ -58,4 +61,23 @@ export async function registerAdminInventoryRoutes(app, options) { app.get('/inventory', async (request) => ({ data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto), })); + + app.post('/bulk', async (request, reply) => { + await authorizeUnsafeRequest?.( + request.cookies[sessionCookieName], + request.headers['x-csrf-token'], + ); + const result = bulkArchiveInventorySchema.safeParse(request.body); + + if (!result.success) { + return validationError(reply, result); + } + + const inventoryItems = await inventoryItemService.archiveMany({ + inventoryItemIds: result.data.inventoryItemIds, + sellerId: request.admin.sellerId, + }); + + return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) }); + }); } \ 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 c46d4e8..eb75a08 100644 --- a/apps/api/src/modules/inventory/services/inventory-item-service.js +++ b/apps/api/src/modules/inventory/services/inventory-item-service.js @@ -1,4 +1,4 @@ -import { createInventoryItem } from '../domain/inventory-item.js'; +import { archiveInventoryItem, createInventoryItem } from '../domain/inventory-item.js'; import { createApplicationError } from '../../../shared/errors/application-error.js'; export function createInventoryItemService({ @@ -35,5 +35,17 @@ export function createInventoryItemService({ return inventoryItemRepository.create(inventoryItem); }, + + async archiveMany({ inventoryItemIds, sellerId }) { + 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); + } + + requestedItems.forEach(({ status }) => archiveInventoryItem(status)); + return inventoryItemRepository.archiveByIdsForSeller(inventoryItemIds, sellerId); + }, }; } \ 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 5b80721..e8950d4 100644 --- a/apps/api/tests/application/inventory-item-service.test.js +++ b/apps/api/tests/application/inventory-item-service.test.js @@ -7,7 +7,13 @@ function createService({ product, condition }) { return createInventoryItemService({ productRepository: { async findById() { return product; } }, conditionRepository: { async findById() { return condition; } }, - inventoryItemRepository: { async create(inventoryItem) { return inventoryItem; } }, + inventoryItemRepository: { + async create(inventoryItem) { return inventoryItem; }, + async listBySellerId() { return []; }, + async archiveByIdsForSeller(inventoryItemIds) { + return inventoryItemIds.map((id) => ({ id, status: 'ARCHIVED' })); + }, + }, }); } @@ -31,6 +37,48 @@ test('creates an inventory item when its condition belongs to the product catego }); }); +test('archives owned active inventory items in bulk', async () => { + const service = createInventoryItemService({ + productRepository: { async findById() { return null; } }, + conditionRepository: { async findById() { return null; } }, + inventoryItemRepository: { + async listBySellerId() { + return [ + { id: 'item-1', status: 'ACTIVE' }, + { id: 'item-2', status: 'ACTIVE' }, + ]; + }, + async archiveByIdsForSeller(ids) { + return ids.map((id) => ({ id, status: 'ARCHIVED' })); + }, + }, + }); + + assert.deepEqual( + await service.archiveMany({ inventoryItemIds: ['item-1', 'item-2'], sellerId: 'seller-id' }), + [ + { id: 'item-1', status: 'ARCHIVED' }, + { id: 'item-2', status: 'ARCHIVED' }, + ], + ); +}); + +test('does not archive sold inventory items', async () => { + const service = createInventoryItemService({ + productRepository: { async findById() { return null; } }, + conditionRepository: { async findById() { return null; } }, + inventoryItemRepository: { + async listBySellerId() { return [{ id: 'item-1', status: 'SOLD' }]; }, + async archiveByIdsForSeller() { throw new Error('should not update'); }, + }, + }); + + await assert.rejects( + () => service.archiveMany({ inventoryItemIds: ['item-1'], sellerId: 'seller-id' }), + /Inventory status cannot transition from SOLD to ARCHIVED/, + ); +}); + 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 8cd2e5f..ffc75a0 100644 --- a/apps/api/tests/http/admin-product-routes.test.js +++ b/apps/api/tests/http/admin-product-routes.test.js @@ -7,6 +7,7 @@ import { createProductService } from '../../src/modules/products/services/produc function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { const products = []; + const inventoryItems = []; const productRepository = { async create(product) { const createdProduct = { @@ -49,13 +50,24 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { }, inventoryItemRepository: { async create(inventoryItem) { - return { - id: 'inventory-1', + const createdItem = { + id: '8a0a836a-a597-454d-8c5e-e146d27dbbf2', ...inventoryItem, status: 'ACTIVE', createdAt: new Date('2026-09-07T00:00:00.000Z'), updatedAt: new Date('2026-09-07T00:00:00.000Z'), }; + inventoryItems.push(createdItem); + return createdItem; + }, + async listBySellerId() { + return inventoryItems; + }, + async archiveByIdsForSeller(ids) { + return inventoryItems.filter(({ id }) => ids.includes(id)).map((item) => { + item.status = 'ARCHIVED'; + return item; + }); }, }, }), @@ -146,6 +158,41 @@ test('creates inventory only for the authenticated sellers product', async (test assert.equal(response.json().data.status, 'ACTIVE'); }); +test('archives selected inventory through the authenticated bulk route', async (testContext) => { + const app = createTestApp({ + authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }), + }); + 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: 'ARCHIVE', + inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'], + }, + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().data[0].status, 'ARCHIVED'); +}); + 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/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index f8834d5..2bccd65 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -108,6 +108,7 @@ docker context use orbstack - The worker owns an image derivative job runner with explicit processing, completion, and failure outcomes; queue transport and persistent job records remain. - Image metadata limits are enforced in the service boundary: supported MIME types, 10 MiB default file size, and 10,000-pixel maximum dimensions. - Acquisition, sales, dashboard, pricing observations, market valuations, audited sale voiding, and manual market-value overrides are implemented with seller-scoped API and admin UI workflows. +- Seller-scoped bulk archive is implemented through the CSRF-protected `POST /api/v1/admin/bulk` route; it validates active inventory lifecycle transitions and never substitutes for the dedicated sale workflow. ### Verified Baseline @@ -135,7 +136,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema Completed vertical slices include authentication, listings and image prerequisites, acquisitions, sales and audited corrections, dashboard metrics/alerts, pricing observations and quality handling, provider mappings, durable pricing refresh jobs, public search/detail/image states, admin workflows, CI integration/E2E gates, production Docker packaging, rollback/health checks, PostgreSQL backup verification, restore drill scripts, image-storage restore support, and deployment smoke checks. -Remaining roadmap areas are richer exports/settings, approved external provider credentials/adapters, broader accessibility/manual coverage, and deployment-specific NAS/TLS validation. +Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/settings, approved external provider credentials/adapters, broader accessibility/manual coverage, and deployment-specific NAS/TLS validation. ## Known Gaps @@ -147,6 +148,7 @@ Remaining roadmap areas are richer exports/settings, approved external provider - 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. - 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 dddee49..8de716e 100644 --- a/packages/validation/inventory.js +++ b/packages/validation/inventory.js @@ -6,4 +6,11 @@ export const createInventoryItemSchema = z conditionId: z.uuid(), notes: z.string().trim().max(10000).optional(), }) + .strict(); + +export const bulkArchiveInventorySchema = z + .object({ + action: z.literal('ARCHIVE'), + inventoryItemIds: z.array(z.uuid()).min(1).max(100), + }) .strict(); \ No newline at end of file