# 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 0–1000' }; } 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 { 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 { 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.