diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index ee6d085..979ec46 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -218,6 +218,7 @@ export const products = pgTable( cardNumber: text("card_number"), eventName: text("event_name"), releaseYear: integer("release_year"), + tags: text("tags").array().default([]).notNull(), ...timestamps, }, (table) => [ 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 79dd98a..ae15c23 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-item-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js @@ -57,6 +57,24 @@ export function createInventoryItemRepository(db) { .returning(); }, + async updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags) { + const rows = await db + .select({ productId: inventoryItems.productId }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(inArray(inventoryItems.id, inventoryItemIds), eq(products.sellerId, sellerId))); + const productIds = [...new Set(rows.map(({ productId }) => productId))]; + const updated = []; + for (const productId of productIds) { + const [product] = await db.select().from(products).where(eq(products.id, productId)); + const tags = [...new Set([...(product.tags ?? []), ...addTags.map((tag) => tag.toLowerCase())])] + .filter((tag) => !removeTags.map((value) => value.toLowerCase()).includes(tag)); + const [result] = await db.update(products).set({ tags, updatedAt: new Date() }).where(eq(products.id, productId)).returning(); + updated.push(result); + } + return updated; + }, + 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 7e55dda..febab40 100644 --- a/apps/api/src/modules/inventory/routes/admin-inventory-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-inventory-routes.js @@ -78,16 +78,25 @@ export async function registerAdminInventoryRoutes(app, options) { inventoryItemIds: result.data.inventoryItemIds, sellerId: request.admin.sellerId, }) - : await inventoryItemService.refreshPricingMany({ + : result.data.action === 'PRICING_REFRESH' + ? await inventoryItemService.refreshPricingMany({ inventoryItemIds: result.data.inventoryItemIds, providerId: result.data.providerId, sellerId: request.admin.sellerId, - }); + }) + : await inventoryItemService.updateTagsMany({ + inventoryItemIds: result.data.inventoryItemIds, + sellerId: request.admin.sellerId, + addTags: result.data.add ?? [], + removeTags: result.data.remove ?? [], + }); if (result.data.action === 'ARCHIVE') { return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) }); } + if (result.data.action === 'TAGS') return reply.send({ data: inventoryItems }); + return reply.code(202).send({ data: inventoryItems.map(({ id, status, createdAt }) => ({ id, status, createdAt })), }); 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 770fc45..6601bff 100644 --- a/apps/api/src/modules/inventory/services/inventory-item-service.js +++ b/apps/api/src/modules/inventory/services/inventory-item-service.js @@ -68,5 +68,13 @@ export function createInventoryItemService({ conditionId: item.conditionId, }))); }, + + async updateTagsMany({ inventoryItemIds, sellerId, addTags = [], removeTags = [] }) { + const items = await inventoryItemRepository.listBySellerId(sellerId); + if (items.filter(({ id }) => inventoryItemIds.includes(id)).length !== inventoryItemIds.length) { + throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404); + } + return inventoryItemRepository.updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags); + }, }; } \ No newline at end of file diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index a5e57a5..a10fa22 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -125,6 +125,7 @@ export function createListingRepository(db) { ilike(products.description, `%${term}%`), ilike(products.setName, `%${term}%`), ilike(products.brand, `%${term}%`), + sql`${products.tags}::text ILIKE ${`%${term}%`}`, ), ), ), diff --git a/apps/api/src/modules/products/domain/product.js b/apps/api/src/modules/products/domain/product.js index ccd9c5f..2aaa3fb 100644 --- a/apps/api/src/modules/products/domain/product.js +++ b/apps/api/src/modules/products/domain/product.js @@ -6,11 +6,13 @@ function requiredText(value, fieldName) { return value.trim(); } -export function createProduct({ name, categoryId, sellerId }) { +export function createProduct({ name, categoryId, sellerId, tags = [] }) { + const normalizedTags = [...new Set(tags.map((tag) => tag.trim().toLowerCase()))]; return { name: requiredText(name, 'Product name'), categoryId: requiredText(categoryId, 'Category ID'), sellerId: requiredText(sellerId, 'Seller ID'), + tags: normalizedTags, }; } diff --git a/apps/api/tests/application/inventory-item-service.test.js b/apps/api/tests/application/inventory-item-service.test.js index f811a1d..9354c7a 100644 --- a/apps/api/tests/application/inventory-item-service.test.js +++ b/apps/api/tests/application/inventory-item-service.test.js @@ -113,6 +113,34 @@ test('enqueues pricing refresh jobs for owned inventory items', async () => { assert.deepEqual(jobs, [{ id: 'job-1', status: 'PENDING' }]); }); +test('updates Product tags for selected owned inventory items', async () => { + const updates = []; + 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' }, + { id: 'item-2', productId: 'product-1', conditionId: 'condition-1' }, + ]; + }, + async updateProductTagsByIdsForSeller(...input) { + updates.push(input); + return [{ id: 'product-1', tags: ['binder', 'keep'] }]; + }, + }, + }); + + assert.deepEqual(await service.updateTagsMany({ + inventoryItemIds: ['item-1', 'item-2'], + sellerId: 'seller-id', + addTags: ['Binder'], + removeTags: ['sell'], + }), [{ id: 'product-1', tags: ['binder', 'keep'] }]); + assert.deepEqual(updates, [[['item-1', 'item-2'], 'seller-id', ['Binder'], ['sell']]]); +}); + 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/domain/product.test.js b/apps/api/tests/domain/product.test.js index a456aea..d2aa637 100644 --- a/apps/api/tests/domain/product.test.js +++ b/apps/api/tests/domain/product.test.js @@ -14,6 +14,7 @@ test('creates a product with required product identity fields', () => { name: 'Charizard ex', categoryId: 'category-id', sellerId: 'seller-id', + tags: [], }); }); diff --git a/apps/api/tests/http/admin-product-routes.test.js b/apps/api/tests/http/admin-product-routes.test.js index 900f255..938b5cb 100644 --- a/apps/api/tests/http/admin-product-routes.test.js +++ b/apps/api/tests/http/admin-product-routes.test.js @@ -69,6 +69,9 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, pricingServi return item; }); }, + async updateProductTagsByIdsForSeller() { + return []; + }, }, pricingService, }), @@ -248,6 +251,44 @@ test('enqueues selected inventory pricing refreshes through the bulk route', asy assert.equal(response.json().data[0].status, 'PENDING'); }); +test('updates Product tags 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', + tags: ['Keep'], + }, + }); + 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: 'TAGS', + inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'], + add: ['Binder'], + remove: ['sell'], + }, + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data, []); +}); + 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/api/tests/http/public-product-routes.test.js b/apps/api/tests/http/public-product-routes.test.js index 848fc50..ad681a8 100644 --- a/apps/api/tests/http/public-product-routes.test.js +++ b/apps/api/tests/http/public-product-routes.test.js @@ -50,6 +50,7 @@ test('returns a public product without private seller identity', async (testCont cardNumber: null, eventName: null, releaseYear: null, + tags: [], createdAt: '2026-09-07T00:00:00.000Z', updatedAt: '2026-09-07T00:00:00.000Z', }); diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js index 126aaa1..5e3d9f8 100644 --- a/apps/web/src/pages/AdminHomePage.stories.js +++ b/apps/web/src/pages/AdminHomePage.stories.js @@ -46,3 +46,14 @@ export const InventoryCsvExport = { }, }, }; + +export const BulkTagSelection = { + parameters: { + docs: { + description: { + story: + "The admin workspace supports adding or removing Product tags for selected physical copies.", + }, + }, + }, +}; diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index b8b9075..1967de6 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -13,6 +13,7 @@ import { listAdminInventory, listAdminProducts, refreshAdminInventoryPricing, + updateAdminInventoryTags, } from "@/services/admin-catalog-service.js"; import { createAdminListing, @@ -50,6 +51,7 @@ const { principal } = useAuth(); const products = ref([]); const inventory = ref([]); const selectedInventoryIds = ref([]); +const inventoryTags = ref(""); const listings = ref([]); const sales = ref([]); const acquisitions = ref([]); @@ -232,6 +234,26 @@ async function refreshSelectedPricing() { } } +async function updateSelectedTags(action) { + const tags = inventoryTags.value.split(",").map((tag) => tag.trim()).filter(Boolean); + if (!selectedInventoryIds.value.length || !tags.length) return; + isSaving.value = true; + try { + await updateAdminInventoryTags( + selectedInventoryIds.value, + action === "add" ? tags : [], + action === "remove" ? tags : [], + csrfToken(), + ); + inventoryTags.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, @@ -999,6 +1021,29 @@ onMounted(load); > Refresh pricing + + +
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 e6d73ed..a548192 100644 --- a/apps/web/src/services/admin-catalog-service.js +++ b/apps/web/src/services/admin-catalog-service.js @@ -38,4 +38,12 @@ export function refreshAdminInventoryPricing(inventoryItemIds, providerId, csrfT headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify({ action: 'PRICING_REFRESH', inventoryItemIds, providerId }), }); +} + +export function updateAdminInventoryTags(inventoryItemIds, add, remove, csrfToken) { + return request('/admin/bulk', { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + body: JSON.stringify({ action: 'TAGS', inventoryItemIds, add, remove }), + }); } \ 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 d686eef..0aa9334 100644 --- a/apps/web/tests/catalog-service.test.js +++ b/apps/web/tests/catalog-service.test.js @@ -92,4 +92,28 @@ test('enqueues selected admin inventory pricing refreshes through the bulk API', } finally { globalThis.fetch = originalFetch; } +}); + +test('updates selected admin inventory tags 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.deepEqual(JSON.parse(options.body), { + action: 'TAGS', + inventoryItemIds: ['item-1'], + add: ['binder'], + remove: [], + }); + return { ok: true, async json() { return { data: [{ id: 'product-1', tags: ['binder'] }] }; } }; + }; + + try { + const { updateAdminInventoryTags } = await import('../src/services/admin-catalog-service.js'); + assert.deepEqual(await updateAdminInventoryTags(['item-1'], ['binder'], [], 'csrf-token'), [ + { id: 'product-1', tags: ['binder'] }, + ]); + } finally { + globalThis.fetch = originalFetch; + } }); \ No newline at end of file diff --git a/database/migrations/0022_product_tags.sql b/database/migrations/0022_product_tags.sql new file mode 100644 index 0000000..c3b8b0a --- /dev/null +++ b/database/migrations/0022_product_tags.sql @@ -0,0 +1 @@ +ALTER TABLE "products" ADD COLUMN "tags" text[] DEFAULT ARRAY[]::text[] NOT NULL; diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index 25a6e00..798000d 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1790323200000, "tag": "0020_pricing_job_mapping_context", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1790409600000, + "tag": "0021_pricing_observation_mapping_context", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1790496000000, + "tag": "0022_product_tags", + "breakpoints": true } ] } diff --git a/docs/API.md b/docs/API.md index 30c216a..59723a4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -96,6 +96,8 @@ POST /api/v1/admin/inventory/:id/valuation/override POST /api/v1/admin/inventory/:id/pricing/refresh POST /api/v1/admin/bulk +`POST /api/v1/admin/bulk` supports `ARCHIVE`, `PRICING_REFRESH`, and `TAGS` actions. `TAGS` accepts `add` and/or `remove` arrays and applies labels to the selected items' Products. + GET /api/v1/admin/dashboard GET /api/v1/admin/exports/inventory.json GET /api/v1/admin/exports/inventory.csv diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index b8c6ca9..11bbc9b 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 and asynchronous bulk pricing refresh are implemented through the seller-scoped API boundary; bulk status changes and tags remain. +- Bulk archive, asynchronous bulk pricing refresh, and Product-level bulk tag updates are implemented through the seller-scoped API boundary; bulk status changes 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. diff --git a/packages/contracts/product.js b/packages/contracts/product.js index b4bfa6e..cde0c0d 100644 --- a/packages/contracts/product.js +++ b/packages/contracts/product.js @@ -15,6 +15,7 @@ export function toAdminProductDto(product) { cardNumber: product.cardNumber ?? null, eventName: product.eventName ?? null, releaseYear: product.releaseYear ?? null, + tags: product.tags ?? [], createdAt: toIsoString(product.createdAt), updatedAt: toIsoString(product.updatedAt), }; diff --git a/packages/contracts/public-product.js b/packages/contracts/public-product.js index f3a1f3e..0724b03 100644 --- a/packages/contracts/public-product.js +++ b/packages/contracts/public-product.js @@ -14,6 +14,7 @@ export function toPublicProductDto(product) { cardNumber: product.cardNumber ?? null, eventName: product.eventName ?? null, releaseYear: product.releaseYear ?? null, + tags: product.tags ?? [], createdAt: toIsoString(product.createdAt), updatedAt: toIsoString(product.updatedAt), }; diff --git a/packages/validation/inventory.js b/packages/validation/inventory.js index 6afd228..ecb6ca5 100644 --- a/packages/validation/inventory.js +++ b/packages/validation/inventory.js @@ -23,7 +23,21 @@ export const bulkPricingRefreshInventorySchema = z }) .strict(); +export const bulkTagInventorySchema = z + .object({ + action: z.literal('TAGS'), + inventoryItemIds: z.array(z.uuid()).min(1).max(100), + add: z.array(z.string().trim().min(1).max(100)).max(50).optional(), + remove: z.array(z.string().trim().min(1).max(100)).max(50).optional(), + }) + .strict() + .refine(({ add, remove }) => (add?.length ?? 0) > 0 || (remove?.length ?? 0) > 0, { + message: 'Add or remove at least one tag.', + path: ['add'], + }); + export const bulkInventorySchema = z.discriminatedUnion('action', [ bulkArchiveInventorySchema, bulkPricingRefreshInventorySchema, + bulkTagInventorySchema, ]); \ No newline at end of file diff --git a/packages/validation/product.js b/packages/validation/product.js index 16f6120..a3657bd 100644 --- a/packages/validation/product.js +++ b/packages/validation/product.js @@ -6,5 +6,6 @@ export const createProductSchema = z .object({ name: z.string().trim().min(1).max(255), categoryId: identifier, + tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(), }) .strict(); \ No newline at end of file