Files
2026-08-17 22:23:10 +02:00

9.2 KiB
Raw Permalink Blame History

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

// 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

// 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

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

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

buildPatch(product: Product, params): ProductPatch {
  return { categoryIds: product.categoryIds.filter(id => id !== p.categoryId) };
}

5.3 ReplaceCategoriesHandler

buildPatch(product: Product, params): ProductPatch {
  return { categoryIds: p.categoryIds ?? [] };
}

6. BulkService Orchestrator

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

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.