# ADMIN BULK UPDATE — SPEC.md ## 1. Concept & Vision A safe, auditable bulk update module for MercadoDeVida Admin that lets operators change prices and categories across multiple products at once. Every bulk operation requires a **preview** before execution, confirms before committing, and logs every change to the audit trail. **Rule: PREVIEW != WRITE. SELECT → PREVIEW → CONFIRM → EXECUTE. NEVER SILENT MUTATION.** ## 2. Initial Capabilities - **Bulk Price Update**: SET, INCREASE BY %, DECREASE BY %, INCREASE BY FIXED, DECREASE BY FIXED - **Bulk Category Update**: ADD CATEGORY, REMOVE CATEGORY, REPLACE CATEGORIES The architecture must allow adding future operations (stock, visibility, brands, etc.) without redesign. ## 3. Why Not PATCH in a Loop? Making N individual `PATCH /products/:id` calls from the browser for bulk operations causes: - N network requests - N separate audit entries (not grouped) - Partial failures with no atomicity - Race conditions if prices change between calls - Poor observability A single backend bulk operation groups these concerns: atomic or near-atomic execution, single audit entry, transactional semantics. ## 4. Bulk Operation Types ### 4.1 Price Operations ``` SET_PRICE → Set netUnitAmountCents to exact value INCREASE_PERCENT → netUnitAmountCents = current * (1 + pct/100), round to nearest cent DECREASE_PERCENT → netUnitAmountCents = current * (1 - pct/100), round to nearest cent INCREASE_FIXED → netUnitAmountCents = current + fixedCents DECREASE_FIXED → netUnitAmountCents = current - fixedCents ``` ### 4.2 Category Operations ``` ADD_CATEGORY → Append categoryId to product's categoryIds (deduplicated) REMOVE_CATEGORY → Remove categoryId from product's categoryIds REPLACE_CATEGORIES → Replace product's categoryIds entirely with provided list ``` **Critical**: "UPDATE CATEGORY" does not exist. The three explicit operations above have very different semantics. ## 5. Selection Strategy Admin can select products through: - **Checkbox row selection**: tick individual products in the list - **Search/filter**: apply filters to the list, implicitly selecting the filtered set The UI must be explicit about what "Selected products" means: - "18 products selected" = 18 explicitly checked rows - "All 42 filtered products" = entire filtered result set (requires separate confirmation) Do NOT mix these semantics. ## 6. Preview Preview is a **dry-run**. No product is modified. Preview is powered by a backend endpoint that returns proposed changes without applying them: ``` POST /admin/bulk/preview { "productIds": ["uuid1", "uuid2", ...], "operation": "INCREASE_PERCENT", "parameters": { "percent": 5 } } → 200 { "items": [ { "productId": "uuid1", "currentPriceCents": 350, "proposedPriceCents": 368, ← calculated by backend "currentCategoryIds": ["cat-a"], "proposedCategoryIds": null, ← null if not a category op "status": "valid", "error": null }, { "productId": "uuid2", "currentPriceCents": 290, "proposedPriceCents": null, "status": "failed", "error": "PRICE_NOT_FOUND" ← variant has no price } ], "summary": { "total": 20, "valid": 18, "failed": 2 } } ``` The preview response is **not stored**. It is regenerated on each request. Backend calculates prices using existing pricing rules (VAT, rounding, etc.) — NOT frontend JavaScript. ## 7. Confirmation After preview, admin sees: - What will change (product, old value, new value) - What will fail (product, reason) - Summary: "18 products will be updated, 2 skipped" Admin must explicitly confirm: ``` Are you sure you want to apply these 18 changes? This action cannot be undone. [Cancel] [Apply Changes] ``` ## 8. Execution Confirmed preview is executed via: ``` POST /admin/bulk/execute { "productIds": ["uuid1", "uuid2", ...], "operation": "INCREASE_PERCENT", "parameters": { "percent": 5 } } → 200 { "operationId": "bulk-uuid", "status": "COMPLETED", "results": { "total": 20, "successful": 18, "failed": 2, "errors": [ { "productId": "uuid2", "error": "PRICE_NOT_FOUND" } ] } } ``` Backend applies changes in a single transaction per operation. If the operation fails partway, the transaction rolls back (no partial mutations on error). ## 9. Audit Every bulk execution logs ONE audit entry via existing AuditLogger: ```json { "actorId": "admin-uuid", "action": "bulk.execute", "target": "bulk:price.increase_percent", "metadata": { "operationId": "bulk-uuid", "operation": "INCREASE_PERCENT", "parameters": { "percent": 5 }, "productIds": ["uuid1", "uuid2"], "results": { "total": 20, "successful": 18, "failed": 2, "failures": [{ "productId": "uuid2", "error": "PRICE_NOT_FOUND" }] } } } ``` Each individual product mutation is NOT logged separately for bulk operations (that would create N audit entries for one admin action). ## 10. RBAC Bulk operations require dedicated permission: ``` products.bulk_update ``` In the current `permissions.ts`, `admin` role gets all permissions via `can(role, _)`. Future granular permissions can add `products.bulk_update` to the `Permission` type. ## 11. Extension Points The BulkUpdateService uses a handler registry: ```typescript interface BulkOperationHandler { readonly type: string; // e.g., 'INCREASE_PERCENT' readonly domain: string; // e.g., 'price', 'category' validate(params: unknown): ValidationResult; buildPreview(product: Product, params: unknown): PreviewItem; apply(product: Product, params: unknown): ProductPatch; } const HANDLERS: ReadonlyMap = new Map([ ['INCREASE_PERCENT', new IncreasePercentHandler()], ['ADD_CATEGORY', new AddCategoryHandler()], // Future: ['STOCK_SET', new StockSetHandler()] ]); ``` Adding a new operation = adding one new handler class + registering it. No changes to routing or execution engine. ## 12. Out of Scope - Stock bulk update (future) - Visibility bulk update (future) - Brand bulk update (future) - Asynchronous background jobs (not needed at expected data volume) - Operation cancellation (not needed at expected data volume) - Bulk export (different feature) ## 13. Feature Flag ``` admin_bulk_update default: false ``` Flip to true after MVP is tested. ## 14. Acceptance Criteria See `TESTS.md`.