# Bulk Operations — Implementation Plan

**Spec:** docs/specs/2026-05-31-bulk-operations.md  ·  **Slug:** bulk-operations  ·  **Wave:** 8
**Depends on:** crm-support-center, customers-module, data-import, expenses-module, foundation-auth-rbac, foundation-design-system, invoices-core, kb-module, system-i18n, tasks-board-engine

## Goal
Add multi-row selection and batch actions to every `DataTable` in the app via a single `bulkActions` prop contract, eliminating per-module selection logic. Six modules ship bulk actions at launch (Tasks, Invoices, Customers, Expenses, KB Articles, Support Tickets), each exposing `POST /api/{module}/bulk` that accepts explicit IDs or an `allMatching` filter envelope. Batches >100 items dispatch to a Cloudflare Queue and run async, tracked by the existing `import_jobs` table with `type = 'bulk_action'`, with completion pushed back through the notification pipeline.

## Architecture
- **UI layer (`packages/ui`):** `DataTable` gains four optional props (`bulkActions`, `onBulkAction`, `totalMatchingCount`, `getRowId`). When `bulkActions` is set, `DataTable` injects a synthetic checkbox column at DOM index 0, manages selection state internally via `useState`, and renders the new `BulkActionBar` plus a "select all matching" sub-line. A new `ConfirmationDialog` primitive (built on the existing `Dialog` compound) backs `requiresConfirmation` actions. Consumes existing exports: `Checkbox`, `Dialog`, `Button`, `DropdownMenu`, `cn`, `useDirection`, `translations`.
- **API layer (`apps/zync-api`):** Each module gets a co-located `bulk.ts` router mounted at `/api/{module}/bulk`. A shared `execute-bulk-action.ts` dispatcher routes `(action, ids, payload)` to per-module handlers. `bulk-queue.ts` exposes `dispatchBulkJob` which inserts an `import_jobs` row (`type='bulk_action'`) and sends `{ jobId }` to `BULK_ACTION_QUEUE`. The `bulk-action.ts` queue consumer processes target IDs in chunks of 50, updates job progress, and notifies the user via `createNotification`/`deliverNotification`.
- **Data layer (`packages/db`):** Reuse `import_jobs` (defined by `data-import`, already lists `'bulk_action'` in its `type` CHECK). Add a `meta JSONB` column and relax `original_filename`/`r2_key` NOT NULL for bulk jobs. Add `archived_at TIMESTAMPTZ` to `expenses`.
- **Filter reuse:** Each module's bulk router imports its list endpoint's Zod filter schema + Drizzle query builder so `allMatching.filters` resolves to the exact same row set as `GET /api/{module}`. `tenant_id = ctx.tenantId` is always injected from JWT claims — clients cannot escape tenant scope.
- **Upstream tables consumed:** `tasks`, `invoices`, `customers`, `customer_contacts`, `customer_communications`, `expenses`, `kb_articles`, `tickets`, `import_jobs`, `notifications`, `tenants`, `users`.
- **Upstream exports consumed:** `tenantQuery`, `requirePermission`, `authMiddleware`, `createDb`/`createDb`→`Db`, `createNotification`, `deliverNotification`, `buildPaginated`, `Checkbox`, `Dialog`, `Button`, `DataTable`, `DataTableProps`, `translations`, `useDirection`, `cn`, `TaskStatus`, `InvoiceStatus`, `TicketStatus`, `RoleId`.

## Tech Stack
- **Packages:** `packages/ui` (React 18, TanStack Table v8, Radix primitives, Tailwind with logical-property utilities), `packages/db` (Drizzle ORM schema + migration), `packages/types` (shared bulk request/response types), `packages/i18n`/`@zync/config` translations.
- **App:** `apps/zync-api` — Hono on Cloudflare Workers; Drizzle over Neon Postgres via Hyperdrive; `zod` validation; `ulid` for job IDs.
- **Cloudflare bindings:** `BULK_ACTION_QUEUE` (producer + consumer), `bulk-action-dlq` dead-letter queue, `HYPERDRIVE`/`DB`, existing notification bindings. New env var `BULK_SYNC_THRESHOLD` (default 100).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a — schema & shared types | 1, 2 | `packages/db/src/schema/*`, migration, `packages/types/src/bulk.ts` | Task 1 & 2 parallel |
| 8b — shared API infra | 3, 4 | `apps/zync-api/src/lib/bulk-queue.ts`, `apps/zync-api/src/lib/execute-bulk-action.ts`, `apps/zync-api/src/queues/bulk-action.ts`, `wrangler.toml` | After 1,2 |
| 8c — UI primitives | 5, 6, 7 | `packages/ui/.../ConfirmationDialog.tsx`, `BulkActionBar.tsx`, `DataTable.tsx`, i18n | After 2; parallel with 8b |
| 8d — module bulk routers | 8, 9, 10, 11, 12, 13 | `apps/zync-api/src/routes/{tasks,invoices,customers,expenses,kb,tickets}/bulk.ts` | After 3,4; all six parallel |
| 8e — module page wiring | 14 | module list-page components in `apps/web` | After 5–13 |

## Tasks

### Task 1: Schema — reuse `import_jobs`, add `meta`; add `expenses.archived_at`
**Blocks:** 3, 4, 11  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/0042_bulk_operations.sql`
- Modify: `packages/db/src/schema/import-jobs.ts`
- Modify: `packages/db/src/schema/expenses.ts`
**Steps:**
- [ ] Add `meta JSONB` to `import_jobs` to hold `{ action, targetIds, payload }` for bulk jobs (the canonical table has no `meta`; `column_mapping` stays import-specific).
- [ ] Relax `import_jobs.original_filename` and `import_jobs.r2_key` to NULL-able, guarded by a CHECK that keeps them required for CSV import types and optional for `'bulk_action'`. Do NOT touch the existing `type` CHECK — `data-import` already lists `'bulk_action'`.
- [ ] Add `archived_at TIMESTAMPTZ` (nullable) to `expenses`; add partial index for the default `archived_at IS NULL` list filter.
- [ ] Mirror all changes in the Drizzle schema modules so generated types include `meta` and `archivedAt`.
**Schema / Interfaces:**
```sql
-- import_jobs already exists (data-import migration). The `meta JSONB` column and the
-- nullable relaxation of original_filename/r2_key are owned by bulk-invoice-generation
-- (wave 7) and already applied at this wave — NOT repeated here. This plan adds only the
-- file-required guard constraint over the (already-nullable) file columns:
ALTER TABLE import_jobs ADD CONSTRAINT import_jobs_file_required_for_import CHECK (
  type = 'bulk_action'
  OR (original_filename IS NOT NULL AND r2_key IS NOT NULL)
);
-- NOTE: import_jobs.type CHECK already includes 'bulk_action' (data-import spec);
-- no ALTER on the type constraint is performed here.

ALTER TABLE expenses ADD COLUMN archived_at TIMESTAMPTZ;
CREATE INDEX expenses_active_idx ON expenses (tenant_id, expense_date DESC)
  WHERE archived_at IS NULL;
```
```typescript
// packages/db/src/schema/import-jobs.ts (additions to existing table)
meta: jsonb('meta').$type<{ action: string; targetIds: string[]; payload?: unknown }>(),
// original_filename / r2_key changed to .notNull() removed (now nullable)

// packages/db/src/schema/expenses.ts (addition)
archivedAt: timestamp('archived_at', { withTimezone: true }),
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; `import_jobs` accepts a row with `type='bulk_action'`, null filename/r2_key, populated `meta`.
- [ ] Inserting a `type='customers'` row with null `original_filename` fails the new CHECK.
- [ ] `expenses` rows can be set/queried by `archived_at`.

### Task 2: Shared bulk request/response types
**Blocks:** 3, 5, 6, 7, 8–13  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/bulk.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define `BulkRequest`, `BulkResponseSync`, `BulkResponseAsync`, `TasksBulkRequest`, `TicketsBulkRequest` (agent picker payload), and the UI-side `BulkAction<TData>` contract.
- [ ] Export all from the package barrel under names other specs reference.
**Schema / Interfaces:**
```typescript
export interface BulkRequest {
  action: string
  ids?: string[]
  allMatching?: { filters: Record<string, unknown> }
}
export interface TasksBulkRequest extends BulkRequest {
  payload?: { assigneeId?: string; status?: string }
}
export interface TicketsBulkRequest extends BulkRequest {
  payload?: { agentId?: string; status?: string }
}
export interface BulkResponseSync {
  processed: number
  failed: number
  errors?: Array<{ id: string; reason: string }>
}
export interface BulkResponseAsync {
  jobId: string
  estimated: number
}
export interface BulkAction<TData> {
  id: string
  label: string
  variant?: 'default' | 'danger'
  requiresConfirmation?: boolean
  confirmationMessage?: string        // supports {count} placeholder
  isEnabled?: (selectedItems: TData[]) => boolean
}
```
**Acceptance:**
- [ ] `import { BulkRequest, BulkAction } from '@zync/types'` resolves; package builds.

### Task 3: `dispatchBulkJob` queue helper + `BULK_ACTION_QUEUE` binding
**Blocks:** 8–13  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/lib/bulk-queue.ts`
- Modify: `apps/zync-api/wrangler.toml`
- Modify: `apps/zync-api/src/types/env.ts` (add `BULK_ACTION_QUEUE`, `BULK_SYNC_THRESHOLD`)
**Steps:**
- [ ] Implement `dispatchBulkJob(ctx, action, targetIds, payload)` → insert `import_jobs` row with canonical column names (`created_by`, `total_rows`, `rows_processed`, `meta`, `tenant_id`), send `{ jobId }` to the queue, return HTTP 202 `{ jobId, estimated }`.
- [ ] Read threshold from `ctx.env.BULK_SYNC_THRESHOLD` (default 100) — used by routers to decide sync vs async.
- [ ] Register the queue producer/consumer and dead-letter queue in `wrangler.toml`.
- [ ] Add `BULK_ACTION_QUEUE: Queue<{ jobId: string }>` and `BULK_SYNC_THRESHOLD?: string` to the `Env` type.
**Schema / Interfaces:**
```typescript
// apps/zync-api/src/lib/bulk-queue.ts
import { ulid } from 'ulid'
import { importJobs } from '@zync/db'

export const BULK_SYNC_THRESHOLD_DEFAULT = 100

export async function dispatchBulkJob(
  ctx: HonoContext,
  action: string,
  targetIds: string[],
  payload?: unknown,
): Promise<Response> {
  const db = ctx.get('db') as Db
  const jobId = ulid()
  await db.insert(importJobs).values({
    id: jobId,
    tenantId: ctx.get('tenantId'),
    createdBy: ctx.get('userId'),
    type: 'bulk_action',
    status: 'pending',
    meta: { action, targetIds, payload },
    totalRows: targetIds.length,
    rowsProcessed: 0,
  })
  await ctx.env.BULK_ACTION_QUEUE.send({ jobId })
  return ctx.json({ jobId, estimated: targetIds.length }, 202)
}

export function bulkSyncThreshold(env: Env): number {
  const v = Number(env.BULK_SYNC_THRESHOLD)
  return Number.isFinite(v) && v > 0 ? v : BULK_SYNC_THRESHOLD_DEFAULT
}
```
```toml
# apps/zync-api/wrangler.toml
[[queues.producers]]
queue = "bulk-action"
binding = "BULK_ACTION_QUEUE"

[[queues.consumers]]
queue = "bulk-action"
max_batch_size = 10
max_retries = 3
dead_letter_queue = "bulk-action-dlq"
```
**Acceptance:**
- [ ] `dispatchBulkJob` inserts a valid `import_jobs` row and returns 202 with `{ jobId, estimated }`.
- [ ] `wrangler dev` boots with the new queue bindings.

### Task 4: Shared action executor + queue consumer
**Blocks:** 8–13  ·  **Blocked by:** 1, 3
**Files:**
- Create: `apps/zync-api/src/lib/execute-bulk-action.ts`
- Create: `apps/zync-api/src/queues/bulk-action.ts`
- Modify: `apps/zync-api/src/index.ts` (register queue consumer export)
**Steps:**
- [ ] Define a module-handler registry: `registerBulkHandler(module, action, fn)` where each module's `bulk.ts` registers its `(job, ids, payload, env) => Promise<{processed, failed, errors}>` handlers. `executeBulkAction` looks up by `meta.action` namespace and runs it.
- [ ] Implement per-module action dispatch keyed by a `{module}:{action}` string stored in `meta.action`. (Routers pass `\`${module}:${action}\``.)
- [ ] Build the queue consumer per spec §7.4: fetch job, set `processing`, chunk `targetIds` by 50, accumulate `processed`/`failed`/`errors`, update `rows_processed` after each chunk, set `completed`, then notify the user.
- [ ] On completion call `createNotification` + `deliverNotification` with `type='bulk_action_complete'`, payload `{ jobId, action, processed, failed }`. Notification copy: "Bulk action complete: {processed} {entity} {verb}. ({failed} failed — view details)".
- [ ] `msg.ack()` on success; rely on `max_retries`/DLQ for transient failures.
**Schema / Interfaces:**
```typescript
// apps/zync-api/src/lib/execute-bulk-action.ts
export interface BulkActionResult {
  processed: number
  failed: number
  errors?: Array<{ id: string; reason: string }>
}
export type BulkHandler = (
  ctx: { tenantId: string; userId: string; role: RoleId; db: Db; env: Env },
  ids: string[],
  payload: unknown,
) => Promise<BulkActionResult>

export function registerBulkHandler(key: string, fn: BulkHandler): void
export async function executeBulkAction(
  ctx: { tenantId: string; userId: string; role: RoleId; db: Db; env: Env },
  action: string,      // "{module}:{actionId}", e.g. "tasks:assign"
  ids: string[],
  payload: unknown,
): Promise<BulkActionResult>
```
```typescript
// apps/zync-api/src/queues/bulk-action.ts
export default {
  async queue(batch: MessageBatch<{ jobId: string }>, env: Env) {
    for (const msg of batch.messages) {
      const job = await getJob(msg.body.jobId, env)
      if (!job) { msg.ack(); continue }
      await updateJobStatus(job.id, 'processing', env)
      const { action, targetIds, payload } = job.meta
      const batchSize = 50
      let processed = 0, failed = 0
      const errors: Array<{ id: string; reason: string }> = []
      for (let i = 0; i < targetIds.length; i += batchSize) {
        const chunk = targetIds.slice(i, i + batchSize)
        const r = await executeBulkAction(jobCtx(job, env), action, chunk, payload)
        processed += r.processed; failed += r.failed
        errors.push(...(r.errors ?? []))
        await updateJobProgress(job.id, processed, env) // sets rows_processed
      }
      await updateJobStatus(job.id, 'completed', env, { processed, failed, errors })
      await notifyBulkComplete(job, action, processed, failed, env)
      msg.ack()
    }
  },
}
```
**Acceptance:**
- [ ] A queued job with 250 ids processes in 5 chunks, advances `rows_processed`, ends `status='completed'`, and emits one `bulk_action_complete` notification.
- [ ] Unknown `meta.action` key fails the job gracefully (status `failed`, error_message set) without throwing out of the consumer loop.

### Task 5: `ConfirmationDialog` primitive
**Blocks:** 6, 7  ·  **Blocked by:** 2
**Files:**
- Create: `packages/ui/src/feedback/ConfirmationDialog.tsx`
- Modify: `packages/ui/src/index.ts` (export)
**Steps:**
- [ ] Build on the existing `Dialog` compound. Props: `open`, `onOpenChange`, `title`, `message`, `confirmLabel`, `cancelLabel`, `danger?`, `loading?`, `onConfirm`.
- [ ] When `danger`, render Confirm in `--danger` variant. While `loading`, Confirm shows a `Spinner` and Cancel is disabled.
- [ ] A11y: `role="alertdialog"`, focus trapped on open, initial focus on Cancel, Esc cancels (unless `loading`).
- [ ] All spacing uses logical utilities (`ms-*`/`me-*`/`ps-*`/`pe-*`); no hardcoded colors (token vars only).
**Schema / Interfaces:**
```typescript
export interface ConfirmationDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  message: string
  confirmLabel?: string
  cancelLabel?: string
  danger?: boolean
  loading?: boolean
  onConfirm: () => void | Promise<void>
}
export function ConfirmationDialog(props: ConfirmationDialogProps): JSX.Element
```
**Acceptance:**
- [ ] Rendering with `danger` shows a danger-token Confirm button; `loading` disables Cancel and shows a spinner.
- [ ] `ConfirmationDialog` is exported from `@zync/ui`.

### Task 6: `BulkActionBar` component
**Blocks:** 7  ·  **Blocked by:** 2, 5
**Files:**
- Create: `packages/ui/src/data-display/BulkActionBar.tsx`
**Steps:**
- [ ] Implement per spec §2.3 props. Render count badge, action buttons (danger actions last), and the trailing clear `✕` button.
- [ ] Render the "select all matching" sub-line inside the same sticky container when `!allMatchingSelected && totalMatchingCount > pageSize && all-page-selected`. After click, swap to "All {totalMatchingCount} matching records are selected. Clear selection".
- [ ] Evaluate each action's `isEnabled(selectedItems)`; disabled buttons get `opacity:0.45; cursor:not-allowed` and `disabled` + `aria-disabled`. Actions with `requiresConfirmation` open `ConfirmationDialog` (count substituted into `confirmationMessage`/generic copy) before invoking `onAction`.
- [ ] Visual spec §2.2: `--surface` bg, `1px solid var(--line)` top border, height 52px, `padding-inline: 24px`, sticky bottom, shadow `0 -2px 8px oklch(12% 0.04 240 / 0.08)`, slide `transform: translateY` 200ms ease — gated on `prefers-reduced-motion: reduce` (no transform animation when set).
- [ ] A11y: container `role="toolbar" aria-label` from i18n `bulk.toolbarLabel`; a sibling `aria-live="polite"` region announces `bulk.itemsSelected` count on every change.
- [ ] RTL: `bulk-action-bar__clear` uses `margin-inline-start:auto`; gaps/padding all logical. Buttons use `--ink` border default, `--danger` for danger variant. `loading` disables all action buttons.
**Schema / Interfaces:**
```typescript
interface BulkActionBarProps<TData> {
  selectedCount: number
  totalMatchingCount?: number
  allMatchingSelected: boolean
  pageSize: number
  actions: BulkAction<TData>[]
  selectedItems: TData[]
  onAction: (actionId: string) => void
  onSelectAllMatching: () => void
  onClearSelection: () => void
  loading?: boolean
}
// Not exported from @zync/ui — internal to DataTable.
```
**Acceptance:**
- [ ] Danger actions render after default actions and use `--danger` tokens.
- [ ] Sub-line appears only when page fully selected and `totalMatchingCount > pageSize`; clicking it calls `onSelectAllMatching`.
- [ ] Toolbar exposes `role="toolbar"` and a polite live region announcing the selected count; animation suppressed under reduced-motion.

### Task 7: `DataTable` extension — checkbox column + selection state
**Blocks:** 14  ·  **Blocked by:** 6
**Files:**
- Modify: `packages/ui/src/data-display/DataTable.tsx`
- Modify: `packages/ui/src/index.ts` (export updated `DataTableProps`)
**Steps:**
- [ ] Add `bulkActions?`, `onBulkAction?`, `totalMatchingCount?`, `getRowId?` to `DataTableProps`. When `bulkActions` is undefined the table renders exactly as today (no regression).
- [ ] Internal `useState` selection: `selectedIds: Set<string>` (current page) and `allMatchingSelected: boolean`. Reset both to empty/false on `data` identity change, page navigation, or filter change.
- [ ] When `bulkActions` set, prepend the synthetic `__bulk_select__` column (size 40, no sort/filter) at index 0 with header + row `Checkbox` per spec §1.4. Header checkbox: unchecked/indeterminate/checked reflecting page selection; checking it selects/deselects all current-page rows. Row checkbox `onClick` stops propagation so row click does not fire.
- [ ] `getRowId` defaults to `(row) => (row as any).id`.
- [ ] Render `BulkActionBar` inside the table's scroll container with `selectedCount`, `selectedItems` (resolved from page rows), `totalMatchingCount`, `pageSize`, and wire `onAction` → confirm-then-`onBulkAction(actionId, [...selectedIds], allMatchingSelected)`; `onSelectAllMatching` → set `allMatchingSelected=true`; `onClearSelection` → reset. Show bar `loading` while the `onBulkAction` promise is pending; clear selection on resolve.
- [ ] A11y contract §1.5: table `role="grid"` when `bulkActions` set (interactive), else `role="table"`; sortable headers keep `aria-sort`; row checkbox `aria-label` = `bulk.selectRow` interpolated with entity label; header checkbox `aria-label` = `bulk.selectAllPage`; empty-state row uses `role="row"` + `role="gridcell"` spanning all columns.
- [ ] RTL: checkbox column stays DOM index 0 but renders on the trailing side via the container `dir` from `useDirection()`.
**Schema / Interfaces:**
```typescript
interface DataTableProps<TData> {
  // existing: data, columns, loading, pagination, sorting, filtering, emptyState, rowActions, onRowClick
  bulkActions?: BulkAction<TData>[]
  onBulkAction?: (action: string, selectedIds: string[], allSelected: boolean) => Promise<void>
  totalMatchingCount?: number   // required (for "select all matching") when bulkActions set
  getRowId?: (row: TData) => string
}
interface BulkSelectionState {  // internal, not exported
  selectedIds: Set<string>
  allMatchingSelected: boolean
}
```
**Acceptance:**
- [ ] With `bulkActions` undefined, existing tables render unchanged (no checkbox column, no bar).
- [ ] Header checkbox shows indeterminate when some rows selected; selecting it checks all page rows; selection resets on page/filter change.
- [ ] Table announces `role="grid"`, checkbox `aria-label`s interpolate the entity, empty row uses `role="gridcell"`.

### Task 8: Tasks bulk router
**Blocks:** 14  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/tasks/bulk.ts`
- Modify: `apps/zync-api/src/routes/tasks/index.ts` (mount `app.route('/api/tasks/bulk', bulkRouter)`)
**Steps:**
- [ ] Validate `TasksBulkRequest` with Zod (require-zod-validation-in-routes). Resolve target IDs: if `allMatching`, import `parseTaskFilters`/`buildTaskQuery` from the list route and select `tasks.id` with `eq(tasks.tenantId, tenantId)` injected (reject unknown filter keys → 400); else use `body.ids`.
- [ ] Threshold: if `targetIds.length > bulkSyncThreshold(env)` → `dispatchBulkJob(ctx, 'tasks:'+action, targetIds, payload)` (202). Else run sync via `executeBulkAction` and return `BulkResponseSync` (200).
- [ ] Register handlers `tasks:assign`, `tasks:change_status`, `tasks:delete`. `assign` sets `assignee_id = payload.assigneeId`; `change_status` sets `status = payload.status` (validate against `TaskStatus` / project columns). `delete` transactional per batch of 50.
- [ ] Server permission enforcement (§3.3, §5): for `MEMBER`, allow `assign`/`change_status` only where `task.assignee_id = userId OR task.created_by = userId`; non-owned IDs → `errors` entry with HTTP-403 reason, counted in `failed`, never rolled back. `delete` is ADMIN/OWNER only — reject MEMBER `delete` outright.
**Schema / Interfaces:**
```typescript
// Request: TasksBulkRequest (Task 2). payload: { assigneeId?, status? }
// Sync 200 → BulkResponseSync ; Async 202 → BulkResponseAsync
// route: POST /api/tasks/bulk
```
**Acceptance:**
- [ ] `assign` with `allMatching` re-resolves the same rows as `GET /api/tasks` for those filters, tenant-scoped.
- [ ] MEMBER assigning a non-owned task gets that ID in `errors` with `failed` incremented; owned tasks still processed.
- [ ] 250 matched ids return 202 with a `jobId`.

### Task 9: Invoices bulk router
**Blocks:** 14  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/invoices/bulk.ts`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (mount route)
**Steps:**
- [ ] Zod-validate `BulkRequest`. Resolve IDs via shared invoice list filter schema + query builder (tenant-scoped) or `ids`.
- [ ] Register `invoices:send`, `invoices:archive`, `invoices:delete`.
- [ ] `send`: only `status='DRAFT'` invoices transition DRAFT→SENT and enqueue delivery via the existing invoice send pipeline; non-DRAFT IDs → `errors`. `archive`: always allowed. `delete`: reject (HTTP 422) the whole request if any target is non-DRAFT; otherwise delete (transactional per 50).
- [ ] Use canonical invoice status enum: `DRAFT, SENT, APPROVED, REJECTED, TAX_ISSUED, PAID, PARTIALLY_PAID, VOID, WRITTEN_OFF, BAD_DEBT`.
- [ ] Threshold dispatch as Task 8. Permissions per §5 (MEMBER no delete).
**Schema / Interfaces:**
```typescript
// POST /api/invoices/bulk  — BulkRequest in; BulkResponseSync|BulkResponseAsync out
// delete guard: 422 if any target invoice.status != 'DRAFT'
```
**Acceptance:**
- [ ] `send` transitions only DRAFT invoices and enqueues one delivery per invoice.
- [ ] A `delete` request containing a non-DRAFT id returns 422 and deletes nothing.

### Task 10: Customers bulk router + CSV streaming
**Blocks:** 14  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/customers/bulk.ts`
- Modify: `apps/zync-api/src/routes/customers/index.ts` (mount route)
**Steps:**
- [ ] Zod-validate `BulkRequest`; resolve IDs via customers list filter schema/builder (tenant-scoped) or `ids`.
- [ ] Register `customers:delete`, `customers:export_csv`.
- [ ] `delete`: reject customers whose `activeInvoiceCount > 0` (server checks linked invoices with `status IN ('DRAFT','SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID')`); rejected IDs → `errors`. Cascade-deletes associated contacts/communications. Transactional per 50. ADMIN/OWNER only.
- [ ] `export_csv`: bypass the queue entirely — generate synchronously regardless of count. For `allMatching`, stream cursor-paginated rows into the CSV writer. Cap 10,000 rows; over cap → HTTP 400 with user-facing message. Return `text/csv` with `Content-Disposition: attachment`.
- [ ] CSV columns exactly: `id, name, email, phone, address, city, country, created_at, total_invoiced, outstanding_balance`. `address`/`city`/`country` flattened from the `customers.address` JSONB (`{street,city,state,zip,country}`); monetary columns in ILS.
**Schema / Interfaces:**
```typescript
// POST /api/customers/bulk
// export_csv → 200 text/csv (attachment), NOT 202; max 10000 rows else 400
// CSV header: id,name,email,phone,address,city,country,created_at,total_invoiced,outstanding_balance
```
**Acceptance:**
- [ ] `delete` skips customers with active invoices, returning them in `errors`.
- [ ] `export_csv` streams correct columns; a >10,000-row `allMatching` export returns 400.

### Task 11: Expenses bulk router
**Blocks:** 14  ·  **Blocked by:** 1, 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/expenses/bulk.ts`
- Modify: `apps/zync-api/src/routes/expenses/index.ts` (mount route)
**Steps:**
- [ ] Zod-validate `BulkRequest`; resolve IDs via expenses list filter schema/builder (tenant-scoped) or `ids`.
- [ ] Register `expenses:archive`, `expenses:delete`. `archive`: set `archived_at = now()` (excluded from default list views via `archived_at IS NULL`; remain in reports). `delete`: permanent, transactional per 50.
- [ ] Permissions per §5: MEMBER cannot `archive` or `delete` (omitted at call site; server still rejects).
- [ ] Threshold dispatch as Task 8.
**Schema / Interfaces:**
```typescript
// POST /api/expenses/bulk
// archive → UPDATE expenses SET archived_at = now() WHERE id = ANY(...) AND tenant_id = $tenant
```
**Acceptance:**
- [ ] `archive` sets `archived_at`; archived rows disappear from default list, persist in reports.
- [ ] MEMBER `delete`/`archive` rejected server-side.

### Task 12: KB articles bulk router
**Blocks:** 14  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/kb/bulk.ts`
- Modify: `apps/zync-api/src/routes/kb/index.ts` (mount route)
**Steps:**
- [ ] Zod-validate `BulkRequest`; resolve IDs via KB list filter schema/builder (tenant-scoped) or `ids`.
- [ ] Register `kb:publish`, `kb:unpublish`, `kb:delete`. Use canonical `kb_articles.status` enum `'DRAFT' | 'PUBLISHED'` (uppercase). `publish`: set `status='PUBLISHED'`, `published_at=now()`. `unpublish`: set `status='DRAFT'`. No notifications sent.
- [ ] Enforce `kb_articles.space_id` write access: MEMBER may only bulk publish/unpublish articles in spaces they have write access to; others → `errors`.
- [ ] `delete`: transactional per 50. Threshold dispatch as Task 8.
**Schema / Interfaces:**
```typescript
// POST /api/kb/bulk
// publish:   UPDATE kb_articles SET status='PUBLISHED', published_at=now() ...
// unpublish: UPDATE kb_articles SET status='DRAFT' ...
```
**Acceptance:**
- [ ] `publish`/`unpublish` flip the uppercase status enum and set `published_at` on publish.
- [ ] MEMBER bulk-publishing an article in a no-write space gets that id in `errors`.

### Task 13: Support tickets bulk router
**Blocks:** 14  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/tickets/bulk.ts`
- Modify: `apps/zync-api/src/routes/tickets/index.ts` (mount route)
**Steps:**
- [ ] Zod-validate `TicketsBulkRequest`; resolve IDs via tickets list filter schema/builder (tenant-scoped) or `ids`.
- [ ] Register `tickets:assign`, `tickets:change_status`, `tickets:close`. Use canonical ticket status enum `'open' | 'in_progress' | 'pending_customer' | 'resolved' | 'closed'`. `assign`: set assignee from `payload.agentId` (ADMIN/OWNER only). `change_status`: set `status = payload.status`. `close`: set `status='closed'`, `closed_at=now()`; if the ticket has a linked `customer_contact`, enqueue an async closure notification via the notification pipeline (does not block the response).
- [ ] Threshold dispatch as Task 8. Permissions per §5.
**Schema / Interfaces:**
```typescript
// POST /api/tickets/bulk  — TicketsBulkRequest; payload: { agentId?, status? }
// close: UPDATE tickets SET status='closed', closed_at=now() ... ; enqueue customer closure notification
```
**Acceptance:**
- [ ] `close` sets `closed_at` and enqueues a closure notification when a customer contact exists, without blocking the HTTP response.
- [ ] `assign` is rejected for MEMBER server-side.

### Task 14: Module list-page wiring + i18n keys
**Blocks:** —  ·  **Blocked by:** 7, 8, 9, 10, 11, 12, 13
**Files:**
- Modify: `apps/web` list pages — `TasksListPage`, invoices list, `CustomerListPage`, expenses list, KB list, tickets list
- Modify: translation files (EN + HE) in `@zync/config`/i18n package
**Steps:**
- [ ] In each list page, build the `bulkActions` array at the call site after a role check via `useAuth()` (VIEWER/CONTRACTOR: omit `bulkActions` entirely; MEMBER: omit `delete`/`archive`/`export_csv`; include danger actions only for ADMIN/OWNER). Pass `totalMatchingCount` from the list response and `getRowId`.
- [ ] Implement `handleBulkAction(action, selectedIds, allSelected)` → React Query mutation calling `POST /api/{module}/bulk` with `ids` (when `!allSelected`) or `allMatching: { filters: currentFilters }` (when `allSelected`), plus `payload` for `assign`/`change_status` (inline picker selection). On 202, toast "Processing {n} items in background…" and clear selection; on 200, success toast. Invalidate the module list query; refresh on the WebSocket `bulk_action_complete` notification.
- [ ] Tasks/Tickets: inline assignee/agent + status pickers in the bar feed `pendingPayload` before dispatch.
- [ ] Add i18n keys (EN + HE) used by the bar/dialog: `bulk.selectAllPage`, `bulk.selectRow`, `bulk.toolbarLabel`, `bulk.itemsSelected`, `bulk.selectAllMatchingPrompt`, `bulk.allMatchingSelected`, `bulk.confirmTitle`, `bulk.genericConfirm`, plus per-action labels (EN/HE) from spec §4 tables.
**Schema / Interfaces:**
```typescript
const handleBulkAction = async (action: string, selectedIds: string[], allSelected: boolean) => {
  await bulkMutation.mutateAsync({
    action,
    ids: allSelected ? [] : selectedIds,
    allMatching: allSelected ? { filters: currentFilters } : undefined,
    payload: pendingPayload,
  })
}
```
**Acceptance:**
- [ ] Each of the six list pages renders bulk actions matching spec §4 with HE/EN labels and correct role gating.
- [ ] `allMatching` path sends current filter state; sync responses toast success, async (202) toast background processing and clear selection.
- [ ] VIEWER/CONTRACTOR see no checkbox column; MEMBER sees no delete/archive/export action.
