226 lines
6.4 KiB
Markdown
226 lines
6.4 KiB
Markdown
# 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`.
|