6.4 KiB
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:
{
"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:
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<string, BulkOperationHandler> = 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.