# 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<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.
