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

6.4 KiB

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:

{
  "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:

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.