feat(ADM-018): completed feature
This commit is contained in:
225
project/specs/bulk-update/SPEC.md
Normal file
225
project/specs/bulk-update/SPEC.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 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<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`.
|
||||
Reference in New Issue
Block a user