feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,311 @@
# ADMIN BULK UPDATE — DESIGN.md
## 1. Module Structure
```
src/modules/bulk/
├── domain/
│ ├── bulk-operation.ts # Types, enums
│ └── ports.ts # BulkOperationHandler interface
├── application/
│ ├── bulk-service.ts # Orchestrator: validate → preview → execute
│ └── handlers/
│ ├── price-handlers.ts # SET, INCREASE/DECREASE (percent + fixed)
│ └── category-handlers.ts # ADD, REMOVE, REPLACE
├── infrastructure/
│ └── pg-bulk-repository.ts # Batch product reads + price updates
└── api/
└── bulk.routes.ts # POST /admin/bulk/preview, POST /admin/bulk/execute
```
## 2. Domain Types
```typescript
// src/modules/bulk/domain/bulk-operation.ts
export type BulkOperationType =
| 'SET_PRICE'
| 'INCREASE_PERCENT'
| 'DECREASE_PERCENT'
| 'INCREASE_FIXED'
| 'DECREASE_FIXED'
| 'ADD_CATEGORY'
| 'REMOVE_CATEGORY'
| 'REPLACE_CATEGORIES';
export interface BulkPriceParams {
netUnitAmountCents?: number; // for SET_PRICE
percent?: number; // for INCREASE/DECREASE_PERCENT
fixedCents?: number; // for INCREASE/DECREASE_FIXED
vatRate?: VatRate; // vatRate must be specified on price changes
}
export interface BulkCategoryParams {
categoryId?: string; // for ADD/REMOVE
categoryIds?: string[]; // for REPLACE
}
export type BulkOperationParams = BulkPriceParams | BulkCategoryParams;
export interface BulkOperationRequest {
productIds: string[];
operation: BulkOperationType;
parameters: BulkOperationParams;
}
export interface PreviewItem {
productId: string;
currentPriceCents: number | null;
proposedPriceCents: number | null;
currentCategoryIds: string[];
proposedCategoryIds: string[] | null;
status: 'valid' | 'failed';
error: string | null;
}
export interface PreviewResult {
items: PreviewItem[];
summary: {
total: number;
valid: number;
failed: number;
};
}
export interface ExecuteResult {
operationId: string;
status: 'COMPLETED' | 'COMPLETED_WITH_ERRORS' | 'FAILED';
results: {
total: number;
successful: number;
failed: number;
errors: { productId: string; error: string }[];
};
}
```
## 3. Handler Interface
```typescript
// src/modules/bulk/domain/ports.ts
export interface ValidationResult {
valid: boolean;
error?: string;
}
export interface BulkOperationHandler {
readonly type: BulkOperationType;
readonly description: string; // e.g., "Increase price by %"
validate(params: unknown): ValidationResult;
// Returns the proposed change for one product (dry-run)
preview(product: ProductWithPrice, params: unknown): PreviewItem;
// Returns the domain patch to apply (actual write)
buildPatch(product: ProductWithPrice, params: unknown): ProductPatch;
}
```
## 4. Price Handlers
### 4.1 IncreasePercentHandler
```typescript
class IncreasePercentHandler implements BulkOperationHandler {
readonly type = 'INCREASE_PERCENT';
validate(params: unknown): ValidationResult {
const p = params as BulkPriceParams;
if (!p.percent || p.percent <= 0 || p.percent > 1000) {
return { valid: false, error: 'PERCENT_INVALID: must be 01000' };
}
if (!p.vatRate) return { valid: false, error: 'VAT_RATE_REQUIRED' };
return { valid: true };
}
preview(product: ProductWithPrice, params: unknown): PreviewItem {
if (!product.price) {
return { productId: product.id, currentPriceCents: null, proposedPriceCents: null,
currentCategoryIds: [], proposedCategoryIds: null, status: 'failed', error: 'PRICE_NOT_FOUND' };
}
const proposed = Math.round(product.price.netUnitAmountCents * (1 + p.percent / 100));
return { ... };
}
buildPatch(product: ProductWithPrice, params: unknown): ProductPatch {
// Returns patch for PATCH /catalog/products/:id
return { pricing: { netUnitAmountCents: proposed, vatRate: p.vatRate } };
}
}
```
**Rounding**: Use `Math.round()` — standard currency rounding. Backend pricing service may apply additional rounding; preview uses the same calculation.
**VAT**: VAT is stored as net price. The proposed change is applied to the net price. Gross price is computed by the pricing domain (not by this handler).
### 4.2 DecreasePercentHandler
Same as IncreasePercentHandler but `1 - p.percent / 100`. Floor at 0 (no negative prices).
### 4.3 IncreaseFixedHandler / DecreaseFixedHandler
Apply `current + fixedCents` or `current - fixedCents`. Floor at 0.
### 4.4 SetPriceHandler
Validate `netUnitAmountCents >= 0`. Apply exact value.
## 5. Category Handlers
### 5.1 AddCategoryHandler
```typescript
class AddCategoryHandler implements BulkOperationHandler {
readonly type = 'ADD_CATEGORY';
validate(params): ValidationResult {
const p = params as BulkCategoryParams;
if (!p.categoryId) return { valid: false, error: 'CATEGORY_ID_REQUIRED' };
return { valid: true };
}
buildPatch(product: Product, params): ProductPatch {
const newIds = [...new Set([...product.categoryIds, p.categoryId])];
return { categoryIds: newIds };
}
}
```
### 5.2 RemoveCategoryHandler
```typescript
buildPatch(product: Product, params): ProductPatch {
return { categoryIds: product.categoryIds.filter(id => id !== p.categoryId) };
}
```
### 5.3 ReplaceCategoriesHandler
```typescript
buildPatch(product: Product, params): ProductPatch {
return { categoryIds: p.categoryIds ?? [] };
}
```
## 6. BulkService Orchestrator
```typescript
export class BulkService {
constructor(
private readonly products: BulkProductRepository, // read-only with price
private readonly productRepo: ProductRepository, // write
private readonly audit: AuditLogger,
) {}
async preview(req: BulkOperationRequest): Promise<PreviewResult> {
const handler = HANDLERS.get(req.operation);
if (!handler) throw new AppError(400, 'UNKNOWN_OPERATION', `No handler for ${req.operation}`);
const validation = handler.validate(req.parameters);
if (!validation.valid) throw new AppError(422, 'VALIDATION_ERROR', validation.error);
const products = await this.products.findByIds(req.productIds);
const items = products.map(p => handler.preview(p, req.parameters));
return {
items,
summary: { total: items.length, valid: items.filter(i => i.status === 'valid').length, failed: ... }
};
}
async execute(req: BulkOperationRequest): Promise<ExecuteResult> {
const handler = HANDLERS.get(req.operation);
if (!handler) throw new AppError(400, 'UNKNOWN_OPERATION', `No handler for ${req.operation}`);
const validation = handler.validate(req.parameters);
if (!validation.valid) throw new AppError(422, 'VALIDATION_ERROR', validation.error);
const products = await this.products.findByIds(req.productIds);
const operationId = generateId();
const { successful, failed } = await this.applyInTransaction(products, handler, req.parameters);
await this.audit.log({
actorId: req.actorId,
action: 'bulk.execute',
target: `bulk:${handler.type.toLowerCase()}`,
metadata: { operationId, operation: req.operation, parameters: req.parameters, results: { total: products.length, successful, failed } },
});
return {
operationId,
status: failed === 0 ? 'COMPLETED' : 'COMPLETED_WITH_ERRORS',
results: { total: products.length, successful, failed, errors: failedItems },
};
}
}
```
## 7. Transaction Strategy
```typescript
private async applyInTransaction(products, handler, params) {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// ... apply each patch
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
```
On rollback, no product is partially modified.
## 8. API Routes
```
POST /admin/bulk/preview
auth: admin
body: { productIds: string[], operation: string, parameters: object }
→ 200 PreviewResult
POST /admin/bulk/execute
auth: admin
body: { productIds: string[], operation: string, parameters: object }
→ 200 ExecuteResult
```
## 9. Admin UI
```
/admin/bulk-update (new route)
└── Step 1: Select Products
├── Product list with checkboxes
├── Search/filter bar (reuse existing product list)
└── Selected count display: "18 products selected"
└── Step 2: Choose Operation
├── [Price] → sub-options appear
└── [Categories] → sub-options appear
└── Step 3: Configure
├── Price: operation selector + parameter input
└── Category: operation selector + category picker
└── Step 4: Preview
└── Table: Product | Current | Proposed | Status
└── Step 5: Confirm
└── Warning: "18 changes will be applied. This cannot be undone."
└── Step 6: Results
└── Summary: X successful, Y failed
```
Each step is a separate view in the wizard. No animations. Simple form state.
## 10. No Second Page for Bulk Update
Adding `/admin/bulk-update` as a separate route is cleaner than embedding in the products list. It keeps the product list focused on browsing and editing, and the bulk wizard has its own clear lifecycle.

View 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`.

View File

@@ -0,0 +1,306 @@
# ADMIN BULK UPDATE — TASKS.md
## Backend
### BULK-BE-001
**ID**: BULK-BE-001
**Title**: BulkOperation domain types
**Goal**: Define all types for bulk operations
**Why**: Shared contracts for handlers, orchestrator, and API
**Dependencies**: None
**Applications**: Backend
**Modules**: bulk/domain/bulk-operation.ts
**Database impact**: None
**API contracts**: Shapes of request/response objects
**Permissions**: N/A
**Implementation**: Define BulkOperationType enum, BulkOperationParams union, PreviewItem, PreviewResult, ExecuteResult interfaces; ValidationResult
**Tests**: Type tests (TypeScript compilation)
**Expected blast radius**: Low
**Definition of Done**: All types defined; TypeScript compiles without errors
### BULK-BE-002
**ID**: BULK-BE-002
**Title**: BulkOperationHandler port + handler registry
**Goal**: Define the extension interface for bulk operations
**Why**: Allows adding future operations without touching core orchestrator
**Dependencies**: BULK-BE-001
**Applications**: Backend
**Modules**: bulk/domain/ports.ts
**Database impact**: None
**API contracts**: BulkOperationHandler interface
**Permissions**: N/A
**Implementation**: Define `BulkOperationHandler` interface; create `HANDLERS` registry map; export from bulk/index.ts
**Tests**: Handler lookup by type works; unknown type returns undefined
**Expected blast radius**: Low
**Definition of Done**: Interface exists; registry is extensible; adding a handler requires only adding to registry
### BULK-BE-003
**ID**: BULK-BE-003
**Title**: Price bulk operation handlers
**Goal**: Implement SET, INCREASE_PERCENT, DECREASE_PERCENT, INCREASE_FIXED, DECREASE_FIXED
**Why**: Initial price operations
**Dependencies**: BULK-BE-002
**Applications**: Backend
**Modules**: bulk/application/handlers/price-handlers.ts
**Database impact**: None
**API contracts**: None (domain)
**Permissions**: N/A
**Implementation**: Five handler classes; each implements validate(), preview(), buildPatch(); rounding uses Math.round(); VAT is stored as net price
**Tests**:
- BULK-BE-UT-001: INCREASE_PERCENT 5% on 350 cents → 368 cents
- BULK-BE-UT-002: DECREASE_PERCENT 5% on 350 cents → 333 cents
- BULK-BE-UT-003: INCREASE_FIXED 50 on 350 cents → 400 cents
- BULK-BE-UT-004: DECREASE_FIXED 50 on 350 cents → 300 cents
- BULK-BE-UT-005: DECREASE_FIXED below 0 → floor at 0
- BULK-BE-UT-006: SET_PRICE 500 → 500
- BULK-BE-UT-007: invalid percent (0, negative, >1000) → ValidationResult.valid=false
- BULK-BE-UT-008: missing VAT rate → ValidationResult.valid=false
- BULK-BE-UT-009: product without price → PreviewItem.status=failed, error=PRICE_NOT_FOUND
**Expected blast radius**: Low
**Definition of Done**: All five handlers produce correct price calculations; invalid params rejected at validate()
### BULK-BE-004
**ID**: BULK-BE-004
**Title**: Category bulk operation handlers
**Goal**: Implement ADD_CATEGORY, REMOVE_CATEGORY, REPLACE_CATEGORIES
**Why**: Initial category operations
**Dependencies**: BULK-BE-002
**Applications**: Backend
**Modules**: bulk/application/handlers/category-handlers.ts
**Database impact**: None
**API contracts**: None (domain)
**Permissions**: N/A
**Implementation**: Three handler classes; ADD deduplicates; REMOVE filters; REPLACE replaces entirely; validates categoryId exists (optional — can be deferred)
**Tests**:
- BULK-BE-UT-010: ADD_CATEGORY to product with 2 categories → 3 categories
- BULK-BE-UT-011: ADD_CATEGORY with duplicate → no-op (deduplicated)
- BULK-BE-UT-012: REMOVE_CATEGORY from product with 3 categories → 2 categories
- BULK-BE-UT-013: REMOVE_CATEGORY not present → no change
- BULK-BE-UT-014: REPLACE_CATEGORIES with 2 new categories → exactly those 2
- BULK-BE-UT-015: ADD without categoryId → ValidationResult.valid=false
**Expected blast radius**: Low
**Definition of Done**: All three handlers produce correct category patches; validation rejects invalid input
### BULK-BE-005
**ID**: BULK-BE-005
**Title**: BulkService orchestrator
**Goal**: Orchestrate preview + execute; integrate with audit
**Why**: Core business logic tying handlers, repository, and audit together
**Dependencies**: BULK-BE-001, BULK-BE-002, BULK-BE-003, BULK-BE-004
**Applications**: Backend
**Modules**: bulk/application/bulk-service.ts
**Database impact**: None
**API contracts**: None (service)
**Permissions**: N/A
**Implementation**: BulkService with preview() and execute(); execute() uses transaction; logs audit entry on execution; rollback on error; generate operationId
**Tests**:
- BULK-BE-UT-020: preview() calls handler.validate() and handler.preview() for each product
- BULK-BE-UT-021: execute() calls handler.buildPatch() for each valid product
- BULK-BE-UT-022: execute() logs one audit entry per bulk operation
- BULK-BE-UT-023: execute() rollback on error → no product modified
- BULK-BE-UT-024: unknown operation type → AppError(400)
**Expected blast radius**: Medium — changes persistence
**Definition of Done**: BulkService correctly orchestrates all operations; audit entry is complete; transaction is atomic
### BULK-BE-006
**ID**: BULK-BE-006
**Title**: BulkProductRepository — read products with pricing in batch
**Goal**: Efficiently load product+price data for bulk operations
**Why**: Avoids N queries for N products
**Dependencies**: BULK-BE-001
**Applications**: Backend
**Modules**: bulk/infrastructure/pg-bulk-repository.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Single SQL query to JOIN catalog_products + pricing_variants; returns ProductWithPrice[] (internal type); handles products with no price gracefully
**Tests**: BULK-BE-IT-001: batch load of 20 products returns correct data; BULK-BE-IT-002: product without price returns null price field
**Expected blast radius**: Low
**Definition of Done**: Products loaded in one query; missing price handled gracefully
### BULK-BE-007
**ID**: BULK-BE-007
**Title**: Bulk API routes — preview and execute
**Goal**: Expose bulk operations via REST API
**Why**: Admin UI needs these endpoints
**Dependencies**: BULK-BE-005, BULK-BE-006
**Applications**: Backend
**Modules**: bulk/api/bulk.routes.ts
**Database impact**: None (read for preview; write for execute)
**API contracts**: POST /admin/bulk/preview, POST /admin/bulk/execute
**Permissions**: admin role required on both
**Implementation**: Register routes in build-app.ts; parse request body; call BulkService; serialize response; handle errors with AppError
**Tests**:
- BULK-BE-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
- BULK-BE-IT-011: POST /admin/bulk/preview with invalid params → 422
- BULK-BE-IT-012: POST /admin/bulk/preview without auth → 401
- BULK-BE-IT-013: POST /admin/bulk/execute with valid params → 200 + ExecuteResult
- BULK-BE-IT-014: POST /admin/bulk/execute → applies changes to DB; products updated
- BULK-BE-IT-015: audit log entry created after execute
**Expected blast radius**: Low — new routes
**Definition of Done**: Both routes respond correctly; auth enforced; changes persisted
### BULK-BE-008
**ID**: BULK-BE-008
**Title**: Wire bulk module into build-app.ts
**Goal**: Register bulk routes with Fastify
**Why**: Module must be mounted in the application
**Dependencies**: BULK-BE-007
**Applications**: Backend
**Modules**: app/build-app.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Import registerBulkRoutes; call in build-app.ts alongside other modules; pass pool, authenticate, audit
**Tests**: Integration test that /admin/bulk/preview and /admin/bulk/execute respond
**Expected blast radius**: Low
**Definition of Done**: Bulk routes accessible at /admin/bulk/*
## Admin
### BULK-ADM-001
**ID**: BULK-ADM-001
**Title**: Bulk Update page shell + product selection
**Goal**: Product selection step of the wizard
**Why**: Admin needs to pick which products to update
**Dependencies**: BULK-BE-007
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: None
**API contracts**: GET /catalog/products (existing)
**Permissions**: products.bulk_update
**Implementation**: Full-width page with product table; checkbox column; search/filter bar; selected count badge; "Continue to Configure" button
**Tests**: Products load; checkboxes select/deselect; selected count accurate; filter narrows selection
**Expected blast radius**: Low
**Definition of Done**: Admin can select products and see selection count
### BULK-ADM-002
**ID**: BULK-ADM-002
**Title**: Operation selector + configuration step
**Goal**: Choose and configure the bulk operation type
**Why**: Second step of the wizard
**Dependencies**: BULK-ADM-001
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: None
**API contracts**: None yet
**Permissions**: products.bulk_update
**Implementation**: Two cards: Price / Categories; selecting Price shows sub-options (SET, +%, -%, +FIXED, -FIXED) with parameter inputs; selecting Categories shows (ADD, REMOVE, REPLACE) with category picker; Back button returns to selection
**Tests**: Selecting each operation shows correct sub-options; invalid input shows error state
**Expected blast radius**: Low
**Definition of Done**: Admin can select operation type and enter parameters
### BULK-ADM-003
**ID**: BULK-ADM-003
**Title**: Preview step
**Goal**: Show proposed changes before committing
**Why**: Critical safety step — see what will change before writing
**Dependencies**: BULK-BE-007, BULK-ADM-002
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: None (dry-run)
**API contracts**: POST /admin/bulk/preview
**Permissions**: products.bulk_update
**Implementation**: Call preview API with selected productIds, operation, parameters; display table (Product | Current | Proposed | Status); show summary bar ("18 valid, 2 failed"); Back button to Configure
**Tests**: Preview loads; shows correct current→proposed values; failed rows highlighted with error reason
**Expected blast radius**: Low
**Definition of Done**: Preview accurately reflects what execute() would do; no product modified
### BULK-ADM-004
**ID**: BULK-ADM-004
**Title**: Confirmation + execution step
**Goal**: Require explicit confirmation before executing bulk operation
**Why**: Safety barrier — prevent accidental bulk changes
**Dependencies**: BULK-ADM-003, BULK-BE-007
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: MODIFIES PRODUCTS
**API contracts**: POST /admin/bulk/execute
**Permissions**: products.bulk_update
**Implementation**: Show warning message ("X changes will be applied. This cannot be undone."); [Cancel] [Apply Changes] buttons; on Apply: call execute API; on success: show result summary; on error: show error + retry option
**Tests**: Confirmation dialog shows before any write; cancel returns to preview; apply calls execute; result shows success/failure counts
**Expected blast radius**: HIGH — modifies products
**Definition of Done**: Confirmation required; execute called only after explicit confirmation; result displayed accurately
### BULK-ADM-005
**ID**: BULK-ADM-005
**Title**: Navigation sidebar — add Bulk Update link
**Goal**: Make bulk update accessible from admin navigation
**Why**: Discoverability
**Dependencies**: BULK-ADM-001
**Applications**: Admin
**Modules**: apps/admin/src/lib/permissions.ts
**Database impact**: None
**API contracts**: None
**Permissions**: products.bulk_update
**Implementation**: Add to NAV_ITEMS: { href: '/bulk-update', label: 'Actualización masiva', icon: '📦', permission: 'products.bulk_update' }
**Tests**: Bulk Update appears in sidebar for admin users
**Expected blast radius**: Low
**Definition of Done**: Nav item visible in admin sidebar
## QA
### BULK-QA-001
**ID**: BULK-QA-001
**Title**: Bulk update E2E tests
**Goal**: End-to-end validation of complete bulk update flow
**Why**: Critical safety feature — full flow must work
**Dependencies**: BULK-BE-007, BULK-ADM-004
**Applications**: QA
**Modules**: E2E tests
**Tests**:
- BULK-QA-UT-001: Select 5 products → preview shows correct proposed prices
- BULK-QA-UT-002: Preview does NOT modify any product
- BULK-QA-UT-003: Confirm → products actually updated in DB
- BULK-QA-UT-004: Audit log entry exists for bulk operation
- BULK-QA-UT-005: 5% increase on products with VAT general → correct net price applied
- BULK-QA-UT-006: ADD_CATEGORY → categories added without removing existing
- BULK-QA-UT-007: REMOVE_CATEGORY → category removed, others preserved
- BULK-QA-UT-008: REPLACE_CATEGORIES → only new categories remain
- BULK-QA-UT-009: Product without price → failed in preview and in execute (not silently skipped)
- BULK-QA-UT-010: Non-admin user → 401 on /admin/bulk/execute
**Expected blast radius**: N/A
**Definition of Done**: All tests pass; QA sign-off obtained
---
## Task Summary Table
| Task | Layer | Feature | Depends On | Risk | Parallel |
|------|-------|---------|-----------|------|---------|
| BULK-BE-001 | Backend | Domain types | — | Low | * |
| BULK-BE-002 | Backend | Handler interface | BULK-BE-001 | Low | * |
| BULK-BE-003 | Backend | Price handlers | BULK-BE-002 | Low | * |
| BULK-BE-004 | Backend | Category handlers | BULK-BE-002 | Low | * |
| BULK-BE-005 | Backend | BulkService orchestrator | BULK-BE-001..004 | Medium | * |
| BULK-BE-006 | Backend | BulkProductRepository | BULK-BE-001 | Low | * |
| BULK-BE-007 | Backend | API routes | BULK-BE-005, BULK-BE-006 | Medium | * |
| BULK-BE-008 | Backend | Wire into build-app | BULK-BE-007 | Low | * |
| BULK-ADM-001 | Admin | Product selection UI | BULK-BE-007 | Low | * |
| BULK-ADM-002 | Admin | Operation config step | BULK-ADM-001 | Low | * |
| BULK-ADM-003 | Admin | Preview step | BULK-ADM-002, BULK-BE-007 | Low | * |
| BULK-ADM-004 | Admin | Confirm + execute step | BULK-ADM-003, BULK-BE-007 | Medium | * |
| BULK-ADM-005 | Admin | Sidebar nav link | BULK-ADM-001 | Low | * |
| BULK-QA-001 | QA | E2E tests | BULK-BE-007, BULK-ADM-004 | Medium | After all |
**Parallel group**: BULK-BE-001 and BULK-BE-002 are independent; BULK-BE-003 and BULK-BE-004 are parallel (both depend on BE-002); BULK-BE-005 depends on all four handlers; BULK-BE-006 independent; BULK-BE-007 depends on BE-005 + BE-006; Admin tasks BULK-ADM-001 through BULK-ADM-005 are sequential (wizard flow).
**Recommended order**:
1. BULK-BE-001 + BULK-BE-002 + BULK-BE-006 (parallel, no deps)
2. BULK-BE-003 + BULK-BE-004 (parallel, both depend on BE-002)
3. BULK-BE-005 (depends on BE-001..004)
4. BULK-BE-007 (depends on BE-005, BE-006)
5. BULK-BE-008 (depends on BE-007)
6. BULK-ADM-001 (depends on BE-007)
7. BULK-ADM-002 through BULK-ADM-004 (sequential wizard)
8. BULK-ADM-005 (parallel with any ADM task)
9. BULK-QA-001 (after everything)
**High-risk tasks**:
- BULK-BE-005: Transaction logic must be correct — wrong rollback = data corruption
- BULK-ADM-004: Executes actual DB writes — requires confirmation UI; must not auto-submit
- BULK-BE-007: New API endpoints with admin auth — verify auth enforced
**MVP boundary**: BULK-BE-001 through BULK-BE-008 (backend) + BULK-ADM-001 through BULK-ADM-005. BULK-QA-001 is required before go-live.
**Migration risks**: None — bulk update is purely additive; no existing data migration required.

View File

@@ -0,0 +1,166 @@
# ADMIN BULK UPDATE — TESTS.md
## Unit Tests
### Price Handlers
```
BULK-UT-001: INCREASE_PERCENT 5% on 350 cents → proposed = 368 cents
BULK-UT-002: INCREASE_PERCENT 100% on 200 cents → proposed = 400 cents
BULK-UT-003: DECREASE_PERCENT 5% on 350 cents → proposed = 333 cents
BULK-UT-004: DECREASE_PERCENT 50% on 200 cents → proposed = 100 cents
BULK-UT-005: DECREASE_FIXED 50 on 350 cents → proposed = 300 cents
BULK-UT-006: INCREASE_FIXED 50 on 350 cents → proposed = 400 cents
BULK-UT-007: DECREASE_FIXED 500 on 350 cents → proposed = 0 (floor at 0)
BULK-UT-008: SET_PRICE 500 → proposed = 500
BULK-UT-009: INCREASE_PERCENT with percent=0 → ValidationResult.valid=false
BULK-UT-010: INCREASE_PERCENT with percent=-5 → ValidationResult.valid=false
BULK-UT-011: INCREASE_PERCENT with percent=1001 → ValidationResult.valid=false
BULK-UT-012: INCREASE_PERCENT without vatRate → ValidationResult.valid=false
BULK-UT-013: DECREASE_PERCENT with vatRate=general → validation passes
BULK-UT-014: preview() for product without price → status=failed, error=PRICE_NOT_FOUND
BULK-UT-015: preview() for product with price → status=valid, proposedPriceCents calculated
BULK-UT-016: buildPatch() returns correct { pricing: { netUnitAmountCents, vatRate } }
```
### Category Handlers
```
BULK-UT-020: ADD_CATEGORY to product with [A, B] → proposedCategoryIds = [A, B, C] (C added)
BULK-UT-021: ADD_CATEGORY where C already present → no duplicate (deduplicated)
BULK-UT-022: REMOVE_CATEGORY from product with [A, B, C] → [A, B]
BULK-UT-023: REMOVE_CATEGORY where not present → no change
BULK-UT-024: REPLACE_CATEGORIES with [X, Y] → proposedCategoryIds = [X, Y]
BULK-UT-025: REPLACE_CATEGORIES with empty array → proposedCategoryIds = []
BULK-UT-026: ADD_CATEGORY without categoryId → ValidationResult.valid=false
BULK-UT-027: REMOVE_CATEGORY without categoryId → ValidationResult.valid=false
BULK-UT-028: buildPatch() returns correct { categoryIds: [...] }
```
### BulkService
```
BULK-UT-030: preview() returns items for all provided productIds
BULK-UT-031: preview() returns summary with correct valid/failed counts
BULK-UT-032: preview() throws AppError(400) for unknown operation type
BULK-UT-033: preview() throws AppError(422) for invalid parameters
BULK-UT-034: execute() returns operationId
BULK-UT-035: execute() returns COMPLETED when all succeed
BULK-UT-036: execute() returns COMPLETED_WITH_ERRORS when some fail
BULK-UT-037: execute() calls audit.log() with correct metadata
BULK-UT-038: execute() rollback on error → no product modified (verify by checking DB)
```
## Integration Tests
### Database
```
BULK-IT-001: catalog_products.updated_at updated after bulk price change
BULK-IT-002: pricing_variants.net_unit_amount_cents updated after SET_PRICE
BULK-IT-003: catalog_product_categories updated correctly for ADD_CATEGORY
BULK-IT-004: catalog_product_categories updated correctly for REMOVE_CATEGORY
BULK-IT-005: catalog_product_categories updated correctly for REPLACE_CATEGORIES
BULK-IT-006: Concurrent bulk operations on overlapping product sets → no race condition
BULK-IT-007: Price changed by bulk update → subsequent checkout uses new price
```
### API Routes
```
BULK-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
BULK-IT-011: POST /admin/bulk/preview with DECREASE_PERCENT → 200 + PreviewResult
BULK-IT-012: POST /admin/bulk/preview with ADD_CATEGORY → 200 + PreviewResult
BULK-IT-013: POST /admin/bulk/preview with REMOVE_CATEGORY → 200 + PreviewResult
BULK-IT-014: POST /admin/bulk/preview with REPLACE_CATEGORIES → 200 + PreviewResult
BULK-IT-015: POST /admin/bulk/preview with invalid percent → 422 + error message
BULK-IT-016: POST /admin/bulk/preview without session cookie → 401
BULK-IT-017: POST /admin/bulk/preview with customer role → 403
BULK-IT-020: POST /admin/bulk/execute with valid SET_PRICE → 200 + ExecuteResult + DB updated
BULK-IT-021: POST /admin/bulk/execute with valid INCREASE_PERCENT → 200 + DB updated
BULK-IT-022: POST /admin/bulk/execute with valid ADD_CATEGORY → 200 + DB updated
BULK-IT-023: POST /admin/bulk/execute → security_audit_log row created
BULK-IT-024: POST /admin/bulk/execute without auth → 401
BULK-IT-025: POST /admin/bulk/execute with customer role → 403
BULK-IT-026: POST /admin/bulk/execute with partial failures → 200 + COMPLETED_WITH_ERRORS
```
## Admin UI Tests
```
BULK-ADM-UT-001: Page loads with product table and checkboxes
BULK-ADM-UT-002: Selecting 3 products updates "3 products selected" counter
BULK-ADM-UT-003: Deselecting all shows "0 products selected"
BULK-ADM-UT-004: Search filter narrows product list
BULK-ADM-UT-005: "Continue" button disabled when 0 products selected
BULK-ADM-UT-010: Clicking Price card shows SET, +%, -%, +FIXED, -FIXED options
BULK-ADM-UT-011: Clicking Categories card shows ADD, REMOVE, REPLACE options
BULK-ADM-UT-012: INCREASE_PERCENT selected → percentage input appears
BULK-ADM-UT-013: INCREASE_PERCENT with invalid input → error state on field
BULK-ADM-UT-014: ADD_CATEGORY selected → category picker appears
BULK-ADM-UT-020: Preview shows product rows with current → proposed values
BULK-ADM-UT-021: Failed rows highlighted in red with error reason
BULK-ADM-UT-022: Summary bar shows "X valid, Y failed"
BULK-ADM-UT-023: "Back to Configure" navigates back
BULK-ADM-UT-024: Preview does NOT show [Apply Changes] button
BULK-ADM-UT-030: Confirmation dialog shows warning text with correct count
BULK-ADM-UT-031: "Cancel" returns to preview without calling execute
BULK-ADM-UT-032: "Apply Changes" calls execute API once
BULK-ADM-UT-033: Result shows success count and failure count
BULK-ADM-UT-034: "New Bulk Update" button resets wizard to step 1
```
## Given/When/Then Acceptance Criteria
```
GIVEN 20 selected products with valid prices
WHEN operator previews a 5% price increase
THEN no product is modified
AND all 20 proposed prices are calculated by the backend
AND the preview shows current and proposed values
GIVEN a product without a price variant
WHEN operator previews any price operation on that product
THEN the preview item has status=failed and error=PRICE_NOT_FOUND
AND the summary shows failed=1
GIVEN 20 selected products
WHEN operator clicks "Apply Changes" on the confirmation step
THEN the backend applies the changes in a single transaction
AND the security_audit_log receives one entry for the bulk operation
AND each affected product's updated_at is updated
AND the result shows successful=20, failed=0
GIVEN a bulk execute with 2 products invalid and 18 valid
WHEN the operation runs
THEN the 18 valid products are updated
AND the 2 invalid products are not modified
AND the result shows COMPLETED_WITH_ERRORS with failure details
AND the audit log entry captures the partial failure
GIVEN a non-admin user
WHEN they call POST /admin/bulk/execute
THEN the backend returns 403 Forbidden
AND no audit entry is created
AND no product is modified
GIVEN a preview shows 18 valid changes
WHEN a second operator applies a price change to one of those products before execute
THEN the execute still applies the original preview's proposed price
AND no optimistic concurrency check is silently bypassed (document this behavior)
```
---
## Preview Staleness Policy
Preview data may be stale when execute runs (another admin changed a price). Current design: execute uses the same calculation on current DB state, which is correct behavior for bulk updates (we want to apply the percentage to whatever the current price is, not the stale preview price).
If stricter concurrency control is needed (e.g., "apply only if price hasn't changed since preview"), this requires:
- `expected_version` or `updated_at` in preview response
- Compare at execute time
- This is a future enhancement, not MVP scope.