# Bulk Operations

**Date:** 2026-05-31
**Status:** Draft
**Spec:** 42
**Depends on:** `foundation-design-system`, `foundation-auth-rbac`, `tasks-board-engine`, `invoices-core`, `customers-module`, `expenses-module`, `kb-module`, `crm-support-center`, `data-import`, `system-i18n`
**Referenced by:** `tasks-board-engine` (list view bulk select), `invoices-core` (bulk send/archive), `customers-module` (bulk delete/export), `expenses-module` (bulk archive/delete), `kb-module` (bulk publish/delete), `crm-support-center` (bulk assign/close)

---

## Overview

Bulk Operations is a cross-cutting capability that adds multi-row selection and batch actions to any `DataTable` in the application. Rather than reimplementing selection logic per module, a single `bulkActions` prop contract on `DataTable` handles: checkbox column injection, selection state management, the persistent bulk action bar, "select all matching records" semantics, per-action confirmation dialogs, async job dispatch for large batches, and permission-aware action visibility.

The bulk pattern covers six modules at launch: Tasks, Invoices, Customers, Expenses, KB Articles, and Support Tickets. Each module exposes a `POST /api/{module}/bulk` endpoint that accepts either explicit IDs or an `allMatching` filter envelope — eliminating the need to paginate through all selected records on the client.

Large batches (>100 items) are dispatched to a Cloudflare Queue and processed asynchronously. The `import_jobs` table is reused (with `type = 'bulk_action'`) to track job state and drive completion notifications.

---

## 1. DataTable Extension API

### 1.1 New Props on `DataTableProps<TData>`

`DataTable` lives in `packages/ui/src/data-display/DataTable.tsx` and is typed with TanStack Table v8.

```typescript
// Existing (unchanged)
interface DataTableProps<TData> {
  data: TData[]
  columns: ColumnDef<TData>[]
  loading?: boolean
  // sorting, filtering, pagination props (unchanged)
}

// New additions for bulk operations
interface DataTableProps<TData> {
  // ... existing props ...

  /**
   * When provided, injects a checkbox column and renders the BulkActionBar.
   * If undefined, table renders exactly as today — no regression.
   */
  bulkActions?: BulkAction<TData>[]

  /**
   * Called when the user triggers a bulk action after optional confirmation.
   * - selectedIds: IDs of checked rows (only populated when allSelected = false)
   * - allSelected: true when user clicked "Select all N matching" — caller should
   *   pass allMatching filters to the API instead of individual IDs.
   */
  onBulkAction?: (
    action: string,
    selectedIds: string[],
    allSelected: boolean
  ) => Promise<void>

  /**
   * Total count of records matching current filters (not just current page).
   * Used in "Select all N matching" copy. Required when bulkActions is set.
   * If omitted while bulkActions is set, "Select all matching" link is hidden.
   */
  totalMatchingCount?: number

  /**
   * Row ID extractor. Defaults to (row) => (row as any).id
   * Must return a stable string for selection tracking.
   */
  getRowId?: (row: TData) => string
}
```

### 1.2 `BulkAction<TData>` Type

```typescript
interface BulkAction<TData> {
  /** Stable string key passed to onBulkAction. Must be unique within bulkActions array. */
  id: string

  /** Localised label displayed in the bulk action bar button. */
  label: string

  /**
   * 'default': standard outlined button.
   * 'danger': text and border rendered in --danger token. Shown last in bar.
   */
  variant?: 'default' | 'danger'

  /**
   * When true, clicking the button shows a ConfirmationDialog before firing onBulkAction.
   * Use for destructive operations (delete, send-to-client, close-tickets).
   */
  requiresConfirmation?: boolean

  /**
   * Body copy shown inside the ConfirmationDialog.
   * If omitted, a generic "This will affect {n} items. Proceed?" message is used.
   * Supports {count} placeholder replaced with current selection count.
   */
  confirmationMessage?: string

  /**
   * Predicate evaluated on the current selection.
   * When it returns false, the action button is disabled (not hidden).
   * Evaluated on every selection change.
   * If omitted, action is always enabled when ≥1 row is selected.
   */
  isEnabled?: (selectedItems: TData[]) => boolean
}
```

### 1.3 Internal Selection State

Selection state is managed entirely inside `DataTable` via `useState` — no external state required from the parent. The parent's `onBulkAction` callback is the only integration surface.

```typescript
// Internal to DataTable — not exported
interface BulkSelectionState {
  // Set of row IDs checked on the current page
  selectedIds: Set<string>
  // True after user clicks "Select all N matching"
  allMatchingSelected: boolean
}
```

On data change (e.g. page navigation, filter change): `selectedIds` is reset to empty. `allMatchingSelected` is reset to false. This prevents stale selection across page boundaries.

### 1.4 Checkbox Column Injection

When `bulkActions` is provided, `DataTable` prepends a synthetic column to the resolved `columns` array before passing to TanStack Table:

```typescript
const checkboxColumn: ColumnDef<TData> = {
  id: '__bulk_select__',
  header: ({ table }) => (
    <Checkbox
      checked={isAllPageSelected(table)}
      indeterminate={isSomePageSelected(table)}
      onCheckedChange={(checked) => toggleAllPage(checked, table)}
      aria-label={t('bulk.selectAllPage')}
    />
  ),
  cell: ({ row }) => (
    <Checkbox
      checked={selectedIds.has(getRowId(row.original))}
      onCheckedChange={(checked) => toggleRow(checked, row.original)}
      aria-label={t('bulk.selectRow')}
      onClick={(e) => e.stopPropagation()} // prevent row click from firing
    />
  ),
  size: 40,
  enableSorting: false,
  enableColumnFilter: false,
}
```

- The column is injected at index 0 (leftmost in LTR, rightmost in RTL via CSS `order` on flex row — see §RTL).
- Header checkbox state: unchecked if 0 rows selected; indeterminate if some rows selected; checked if all rows on current page selected.
- Checking the header selects all rows on the current page. It does **not** trigger "Select all matching" — that requires an explicit link click.

## 1.5 DataTable A11y Contract

All `DataTable` instances across all modules must meet this contract regardless of which module uses the component.

```
Table role:
  role="grid"  — when rows are interactive (clickable/selectable)
  role="table" — read-only tables with no row actions

Column header sorting:
  sortable headers: aria-sort="ascending|descending|none"
  on sort change: aria-live="polite" region announces "Sorted by {column} {direction}"

Row selection checkboxes:
  row checkbox:      aria-label="Select {entity name}"   (NOT just "Select")
  select-all:        aria-label="Select all {n} {entities}"
  Example:           aria-label="Select invoice INV-2025-001"
  Example:           aria-label="Select all 47 invoices"

Bulk action toolbar (visible when ≥1 row selected):
  role="toolbar" aria-label="Bulk actions"
  separate aria-live="polite" region: "{n} items selected"
  update on every selection change

Empty state row:
  role="row" with single cell spanning all columns
  cell: role="gridcell"  (do NOT omit — gridcell mismatch breaks AT column count)
  content: the empty-state message text
```

---

## 2. Bulk Action Bar Component

### 2.1 Wireframe

```
RTL (Hebrew default):
┌─────────────────────────────────────────────────────────────────────────────┐
│  [✕]  [מחיקה]  [שנה סטטוס ▾]  [הקצה למשתמש]      נבחרו 12 פריטים  [☑]   │
└─────────────────────────────────────────────────────────────────────────────┘

LTR (English):
┌─────────────────────────────────────────────────────────────────────────────┐
│  [☑]  12 selected   [Assign]  [Change Status ▾]  [Delete]              [✕]  │
└─────────────────────────────────────────────────────────────────────────────┘

"Select all matching" sub-line (appears below bar when all-on-page checked):
  ✓ All 12 on this page selected.  Select all 248 matching records →
```

### 2.2 Visual Spec

| Property | Value |
|----------|-------|
| Background | `--surface` token (resolves per theme; see `dark-light-theme`, spec 114) |
| Border top | `1px solid var(--line)` |
| Padding | `0 24px` (horizontal), height `52px` |
| Position | Sticky to bottom of table scroll container (`position: sticky; bottom: 0`) |
| Shadow | `0 -2px 8px oklch(12% 0.04 240 / 0.08)` (lifts bar above table body) |
| Transition | `transform 200ms ease` — slides up from `translateY(100%)` when selection ≥ 1, slides down when selection = 0 |
| Count badge | `--ink-soft`, font-size `0.875rem` |
| Default action buttons | Outlined variant, `--ink` border, height `32px`, `border-radius: var(--radius)` |
| Danger action buttons | `color: var(--danger)`, `border-color: var(--danger)` |
| Disabled buttons | `opacity: 0.45`, `cursor: not-allowed` |
| Clear button `✕` | Icon-only button, `--ink-soft`, `ms-auto` to push to trailing edge |

### 2.3 `BulkActionBar` Component API

```typescript
// packages/ui/src/data-display/BulkActionBar.tsx
interface BulkActionBarProps<TData> {
  selectedCount: number
  totalMatchingCount?: number
  allMatchingSelected: boolean
  actions: BulkAction<TData>[]
  selectedItems: TData[]
  onAction: (actionId: string) => void
  onSelectAllMatching: () => void
  onClearSelection: () => void
  loading?: boolean // true while onBulkAction promise is pending
}
```

`BulkActionBar` is an internal implementation detail of `DataTable` — not exported directly from the package. Module code interacts only via `DataTableProps`.

### 2.4 "Select All Matching" Link

The sub-line appears below the bulk action bar (rendered inside the same sticky container) when:
- `allMatchingSelected` is false, **and**
- all rows on the current page are selected (header checkbox is checked), **and**
- `totalMatchingCount > pageSize` (there are records on other pages)

Copy (i18n key `bulk.selectAllMatchingPrompt`):
```
✓ All {pageSize} on this page are selected.  Select all {totalMatchingCount} matching records →
```

Once clicked:
- `allMatchingSelected` becomes true
- Sub-line changes to: `All {totalMatchingCount} matching records are selected.  Clear selection`
- `selectedIds` continues to hold current-page IDs (used as UI indicator only; API receives `allMatching`)

### 2.5 Confirmation Dialog

When `requiresConfirmation: true`:

```
┌─────────────────────────────────────┐
│  Confirm bulk action                 │
│                                      │
│  [confirmationMessage with {count}]  │
│                                      │
│         [Cancel]  [Confirm]          │
└─────────────────────────────────────┘
```

- Uses the shared `ConfirmationDialog` primitive from `packages/ui/src/feedback/ConfirmationDialog.tsx`.
- Danger actions: Confirm button rendered in `--danger` variant.
- While `onBulkAction` is pending: Confirm button shows spinner, Cancel is disabled.

---

## 3. Bulk API Pattern

### 3.1 Endpoint Shape

Every module that supports bulk operations exposes:

```
POST /api/{module}/bulk
Authorization: Bearer {jwt}
Content-Type: application/json
```

**Request body:**

```typescript
interface BulkRequest {
  action: string       // matches BulkAction.id
  ids?: string[]       // used when allMatching is absent
  allMatching?: {
    filters: Record<string, unknown>  // same filter shape as the module's list endpoint
  }
}
```

**Response — synchronous (≤100 items):**

```typescript
interface BulkResponseSync {
  processed: number
  failed: number
  errors?: Array<{ id: string; reason: string }>
}
// HTTP 200
```

**Response — async (>100 items or when action is queue-eligible):**

```typescript
interface BulkResponseAsync {
  jobId: string   // import_jobs.id with type = 'bulk_action'
  estimated: number
}
// HTTP 202 Accepted
```

### 3.2 `allMatching` Filter Semantics

When `allMatching` is present in the request body, the server:

1. Reconstructs the query using the same filter logic as `GET /api/{module}` (shared filter-builder function).
2. Ignores `ids` (if also supplied).
3. Applies the bulk action to **all results of that query** regardless of pagination.
4. Enforces a single DB transaction for ≤100 items; dispatches to queue for >100.

Filter values in `allMatching.filters` must be validated against the same Zod schema used by the list endpoint. Unrecognised filter keys are rejected with HTTP 400.

**Security:** The query always injects `tenant_id = ctx.tenantId` from JWT claims. Clients cannot escape tenant scope via `allMatching.filters`.

### 3.3 Server-side Permission Enforcement

The bulk endpoint re-checks permissions for each affected record server-side. Records the requester cannot act on are skipped, counted in `failed`, and their IDs returned in `errors`. The operation is not atomic across failures — successfully processed items are not rolled back due to others failing.

Exception: Delete actions are transactional per batch of 50. If a batch insert fails, that batch is rolled back; prior batches remain committed.

### 3.4 Route Registration

```typescript
// apps/zync-api/src/routes/{module}/bulk.ts
import { bulkRouter } from './{module}/bulk'
app.route('/api/{module}/bulk', bulkRouter)
```

Each module's bulk router is co-located with its other route handlers.

---

## 4. Per-Module Bulk Actions

### 4.1 Tasks

**Endpoint:** `POST /api/tasks/bulk`

| Action ID | Label (EN) | Label (HE) | Variant | Requires Confirmation | `isEnabled` constraint |
|---|---|---|---|---|---|
| `assign` | Assign to user | הקצה למשתמש | default | No | always |
| `change_status` | Change status | שנה סטטוס | default | No | always |
| `delete` | Delete | מחיקה | danger | Yes — "Delete {count} tasks? This cannot be undone." | ADMIN/OWNER only (see §5) |

**`assign` action:** Opens an inline user-picker dropdown in the bulk action bar before dispatching. Selected assignee ID is passed as `payload.assigneeId` alongside `ids`/`allMatching`.

**`change_status` action:** Opens an inline status-picker dropdown. Valid statuses match the project's configured columns.

**Bulk request extension for task actions:**

```typescript
interface TasksBulkRequest extends BulkRequest {
  payload?: {
    assigneeId?: string   // for assign
    status?: string       // for change_status
  }
}
```

### 4.2 Invoices

**Endpoint:** `POST /api/invoices/bulk`

| Action ID | Label (EN) | Label (HE) | Variant | Requires Confirmation | `isEnabled` constraint |
|---|---|---|---|---|---|
| `send` | Send | שלח | default | Yes — "Send {count} invoices to clients?" | `isEnabled: (items) => items.every(i => i.status === 'DRAFT')` |
| `archive` | Archive | ארכיון | default | No | always |
| `delete` | Delete | מחיקה | danger | Yes — "Permanently delete {count} draft invoices?" | `isEnabled: (items) => items.every(i => i.status === 'DRAFT')` |

**`send` action:** Transitions DRAFT → SENT. Enqueues delivery job per invoice. Uses existing invoice send pipeline (spec 15 + spec 5).

**Delete constraint:** Server rejects (HTTP 422) any delete request that includes non-DRAFT invoice IDs. The `isEnabled` guard on the client mirrors this but server enforcement is authoritative.

### 4.3 Customers

**Endpoint:** `POST /api/customers/bulk`

| Action ID | Label (EN) | Label (HE) | Variant | Requires Confirmation | `isEnabled` constraint |
|---|---|---|---|---|---|
| `delete` | Delete | מחיקה | danger | Yes — "Delete {count} customers? Associated data (contacts, notes) will also be deleted." | `isEnabled: (items) => items.every(i => i.activeInvoiceCount === 0)` |
| `export_csv` | Export to CSV | ייצוא ל-CSV | default | No | always |

**Delete constraint:** Server rejects customers with linked active (unpaid, non-terminal) invoices (status IN ('DRAFT','SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID')). `activeInvoiceCount` must be included in the list view row data (already present in the customers module list response per spec 9).

**`export_csv` action:** Does not use the queue. Generates CSV synchronously server-side (regardless of count — export is read-only and fast). Returns `Content-Disposition: attachment` response with `text/csv`. For `allMatching`, streams cursor-paginated results into the CSV writer to avoid memory pressure. Maximum 10,000 rows per export; larger sets return HTTP 400 with a user-facing message.

**CSV columns:** id, name, email, phone, address, city, country, created_at, total_invoiced (ILS), outstanding_balance (ILS).

### 4.4 Expenses

**Endpoint:** `POST /api/expenses/bulk`

| Action ID | Label (EN) | Label (HE) | Variant | Requires Confirmation | `isEnabled` constraint |
|---|---|---|---|---|---|
| `archive` | Archive | ארכיון | default | No | always |
| `delete` | Delete | מחיקה | danger | Yes — "Delete {count} expenses permanently?" | always |

**Archive:** Sets `archived_at = NOW()`. Archived expenses are excluded from default list views (filter `archived_at IS NULL`) but remain in reports. Archiving is reversible via single-item restore (out of scope for this spec).

### 4.5 KB Articles

**Endpoint:** `POST /api/kb/bulk`

| Action ID | Label (EN) | Label (HE) | Variant | Requires Confirmation | `isEnabled` constraint |
|---|---|---|---|---|---|
| `publish` | Publish | פרסם | default | No | `isEnabled: (items) => items.some(i => i.status !== 'published')` |
| `unpublish` | Unpublish | בטל פרסום | default | No | `isEnabled: (items) => items.some(i => i.status === 'published')` |
| `delete` | Delete | מחיקה | danger | Yes — "Delete {count} articles? This cannot be undone." | always |

**Publish/Unpublish:** Sets `kb_articles.status` to `'published'` / `'draft'` and updates `published_at`. Does not send notifications. Respects `kb_articles.space_id` — MEMBER can only bulk-publish articles in spaces they have write access to (enforced server-side per spec 20).

### 4.6 Support Tickets

**Endpoint:** `POST /api/tickets/bulk`

| Action ID | Label (EN) | Label (HE) | Variant | Requires Confirmation | `isEnabled` constraint |
|---|---|---|---|---|---|
| `assign` | Assign to agent | הקצה לנציג | default | No | ADMIN/OWNER only |
| `change_status` | Change status | שנה סטטוס | default | No | always |
| `close` | Close | סגור | danger | Yes — "Close {count} tickets?" | `isEnabled: (items) => items.some(i => i.status !== 'closed')` |

**`assign` action:** Same inline agent-picker pattern as Tasks. Passes `payload.agentId`.

**`close` action:** Sets status = 'closed', `closed_at = NOW()`. Sends a closure notification to the customer if ticket has a customer contact (uses spec 5 notification pipeline). Notification is enqueued asynchronously — it does not block the bulk response.

---

## 5. Permission Matrix

`DataTable` receives role from the module page context (via `useAuth()` hook). The `bulkActions` array passed to `DataTable` is constructed at the call site after role check — inaccessible actions are simply not included in the array. The server enforces independently.

| Role | Can select rows | Default bulk actions | Danger bulk actions | Notes |
|------|----------------|---------------------|---------------------|-------|
| OWNER | Yes | Yes | Yes | Full access |
| ADMIN | Yes | Yes | Yes | Full access |
| MEMBER | Yes | Yes (own items only) | No | Cannot bulk-delete any resource. Can bulk-assign/status tasks they own. |
| VIEWER | No | No | No | Checkbox column not rendered (`bulkActions` prop omitted at call site) |
| CONTRACTOR | No | No | No | Same as VIEWER for bulk purposes |

**MEMBER scope enforcement:**

For Tasks: MEMBER may bulk `assign` and `change_status` only on tasks where `task.assignee_id = auth.userId OR task.created_by = auth.userId`. Server validates each ID and rejects non-owned tasks with HTTP 403 entries in the `errors` array.

For all modules: MEMBER cannot trigger `delete` or `archive` — these actions are omitted from `bulkActions` when role = MEMBER at the call site.

**Entitlement gate:** Bulk operations are available on all tiers (Freelancer, Business, Enterprise). No tier restriction.

---

## 6. "Select All Matching" — Full Implementation Detail

### 6.1 Client Flow

1. User applies filters on a module list page (e.g. Tasks filtered by `status=in_progress&assignee=unassigned`).
2. User selects all rows on the current page via header checkbox.
3. Sub-line appears: "All 25 on this page are selected. Select all 248 matching records →"
4. User clicks the link. `allMatchingSelected` = true.
5. User clicks a bulk action (e.g. "Assign").
6. `onBulkAction('assign', currentPageIds, true)` is called.
7. The module page handler calls `POST /api/tasks/bulk` with:
   ```json
   {
     "action": "assign",
     "allMatching": {
       "filters": { "status": "in_progress", "assignee": "unassigned" }
     },
     "payload": { "assigneeId": "user_abc" }
   }
   ```
   Note: `ids` is omitted when `allMatching` is present (or sent as empty array — server prefers `allMatching`).

### 6.2 Filter Serialisation

The module page maintains current filter state (already done for the existing list view). When building the `allMatching.filters` payload, the page passes its current filter state object directly. This is the same object used to construct the `GET` query string for the list view — a single source of truth.

```typescript
// In module page (e.g. TasksListPage.tsx)
const handleBulkAction = async (
  action: string,
  selectedIds: string[],
  allSelected: boolean
) => {
  await bulkMutation.mutateAsync({
    action,
    ids: allSelected ? [] : selectedIds,
    allMatching: allSelected ? { filters: currentFilters } : undefined,
    payload: pendingPayload,
  })
}
```

### 6.3 Server Filter Re-application

The server's bulk handler delegates filter parsing to the **same Zod schema + query builder** used by the list endpoint. Example pattern:

```typescript
// apps/zync-api/src/routes/tasks/bulk.ts
import { parseTaskFilters, buildTaskQuery } from './list'

bulkRouter.post('/', async (ctx) => {
  const body = await ctx.req.json<TasksBulkRequest>()
  const tenantId = ctx.get('tenantId')

  let targetIds: string[]

  if (body.allMatching) {
    const filters = parseTaskFilters(body.allMatching.filters)
    const rows = await db
      .select({ id: tasks.id })
      .from(tasks)
      .where(and(eq(tasks.tenantId, tenantId), buildTaskQuery(filters)))
    targetIds = rows.map(r => r.id)
  } else {
    targetIds = body.ids ?? []
  }

  if (targetIds.length > 100) {
    return dispatchBulkJob(ctx, body.action, targetIds, body.payload)
  }
  return executeBulkAction(ctx, body.action, targetIds, body.payload)
})
```

### 6.4 No Count Cap

There is no upper limit on the number of records an `allMatching` bulk action can affect. The 10,000-row export cap in §4.3 is specific to CSV export (memory constraint). All other bulk actions dispatch to the queue when >100 items are resolved, so memory is not a concern.

---

## 7. Async Large Batch Processing

### 7.1 Threshold

> **Threshold:** > 100 items → async via Cloudflare Queue.

≤ 100 items are processed synchronously in the same request handler. The UI waits for the HTTP 200 response and shows an inline success toast.

### 7.2 Queue Dispatch

```typescript
// apps/zync-api/src/lib/bulk-queue.ts
async function dispatchBulkJob(
  ctx: HonoContext,
  action: string,
  targetIds: string[],
  payload?: unknown
): Promise<Response> {
  const jobId = ulid()
  await db.insert(importJobs).values({
    id: jobId,
    tenantId: ctx.get('tenantId'),
    userId: ctx.get('userId'),
    type: 'bulk_action',
    status: 'pending',
    meta: { action, targetIds, payload },
    totalRows: targetIds.length,
    processedRows: 0,
    createdAt: new Date(),
  })
  await ctx.env.BULK_ACTION_QUEUE.send({ jobId })
  return ctx.json({ jobId, estimated: targetIds.length }, 202)
}
```

### 7.3 `import_jobs` Table Reuse

No new table. The existing `import_jobs` table (spec 40) accepts `type = 'bulk_action'`:

```sql
-- New discriminator value added to import_jobs.type check constraint
-- (spec 40 defines the table; this spec adds the type variant)
ALTER TABLE import_jobs DROP CONSTRAINT import_jobs_type_check;
ALTER TABLE import_jobs ADD CONSTRAINT import_jobs_type_check
  CHECK (type IN ('csv_import', 'bulk_action'));
```

`meta` JSONB column stores `{ action, targetIds, payload }` for the consumer to read.

### 7.4 Queue Consumer

```typescript
// apps/zync-api/src/queues/bulk-action.ts  (Cloudflare Queue consumer)
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 result = await executeBulkAction(job, action, chunk, payload, env)
        processed += result.processed
        failed += result.failed
        errors.push(...(result.errors ?? []))
        await updateJobProgress(job.id, processed, env)
      }

      await updateJobStatus(job.id, 'completed', env, { processed, failed, errors })
      await notifyUser(job.userId, job.tenantId, {
        type: 'bulk_action_complete',
        jobId: job.id,
        action,
        processed,
        failed,
      }, env)

      msg.ack()
    }
  }
}
```

### 7.5 Cloudflare Binding

Add to `wrangler.toml` and `00-index.md` Foundation Deltas:

```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"
```

### 7.6 Client Notification on Completion

When the consumer completes the job, it calls the existing notification pipeline (spec 5) to push an in-app notification:

```
✓ Bulk action complete: 248 tasks assigned. (2 failed — view details)
```

For async jobs, the UI immediately shows a toast:
```
Processing 248 items in background. You'll be notified when done.
```

Selection is cleared immediately on HTTP 202. The table refreshes (via React Query invalidation) when the completion notification arrives via WebSocket.

---

## 8. RTL Layout

The bulk action bar uses logical CSS properties throughout. `DataTable` already uses logical properties for column headers.

**Checkbox column position:** In RTL, the injected checkbox column appears on the right (trailing side of the row). Achieved by setting `dir` on the table container (already done via `useLocale()`) — the `__bulk_select__` column renders at DOM index 0 but appears on the correct side in both directions.

**Bulk action bar layout:**

```css
.bulk-action-bar {
  display: flex;
  align-items: center;
  padding-inline: 24px;
  gap: 8px;
}

.bulk-action-bar__count {
  /* leading side */
}

.bulk-action-bar__actions {
  display: flex;
  gap: 8px;
}

.bulk-action-bar__clear {
  margin-inline-start: auto; /* pushes to trailing edge in both LTR and RTL */
}
```

All spacing uses `ms-*` / `me-*` / `ps-*` / `pe-*` Tailwind utilities. No `ml-*` / `mr-*` / `pl-*` / `pr-*`.

---

## 9. Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Bar position: bottom vs. top** | Bottom sticky | Top would require the bar to either push content down (layout shift) or overlay the table header (blocks column names). Bottom sticky is unobtrusive, always visible on scroll, and matches the pattern used by Gmail, Notion, Linear, Airtable. |
| **Slide-in animation vs. instant** | `transform: translateY` 200ms ease | Draws attention to the selection state change without being disruptive. Pure CSS transform — no layout recalculation. |
| **`allMatching` filter envelope vs. server cursor** | Filter envelope | Sends the filter object rather than a cursor or all IDs. This is compact (URL-param-sized), avoids the N×ID payload for large tables, and is self-describing — the server can log, audit, and reproduce the exact query. Cursors would require the client to paginate and collect IDs — defeating the purpose. |
| **Reuse `import_jobs` vs. new `bulk_jobs` table** | Reuse `import_jobs` with `type = 'bulk_action'` | Bulk actions and imports share the same lifecycle: pending → processing → completed/failed, with progress tracking and user notification. A separate table would duplicate structure. The `type` discriminator cleanly separates concerns. |
| **Synchronous ≤100 / async >100 threshold** | 100 items | Empirically, 100 Postgres row updates complete in <400ms on Neon (well within the 30s Cloudflare Worker CPU limit). Above 100, queue dispatch is safer, enables retries, and provides progress reporting. The threshold is configurable via `env.BULK_SYNC_THRESHOLD` (default 100). |
| **No atomic rollback across failures** | Skip-and-report | All-or-nothing semantics for bulk operations on heterogeneous records (some may be locked, owned by others, in wrong state) would mean a single bad record aborts the whole batch. Skip-and-report gives the user a clear list of what failed and why, and commits partial progress. Exceptions: delete operations batch in groups of 50 for partial atomicity. |
| **Per-action `isEnabled` on client + server enforcement** | Both | Client-side `isEnabled` gives immediate feedback (disabled button before user clicks). Server re-validates because the client state may be stale. Server validation is always authoritative. |
| **MEMBER cannot bulk-delete** | By design | Bulk delete is a high-impact irreversible action. Even if MEMBERs can delete individual items, the scale of bulk delete warrants restricting it to ADMIN/OWNER. This matches the policy for export and archival operations in other modules. |
| **CSV export bypasses queue** | Sync streaming | Export is read-only; it cannot cause side-effects or fail partway through in a recoverable way. Cursor-paged streaming to the HTTP response is memory-efficient and avoids the complexity of async file generation + signed URL delivery for a simple CSV. 10k row cap guards against timeout. |

---

## 10. Foundation Delta

Add to `00-index.md` Foundation Deltas:

### Queue added by spec 42

| Queue | Consumer | Purpose | Added by |
|-------|----------|---------|----------|
| `bulk-action` | bulk-action Worker | Process bulk operations >100 items with retry + progress tracking | spec 42 |

### Table amendment added by spec 42

`import_jobs.type` check constraint: add `'bulk_action'` variant. No new columns needed — `meta` JSONB and existing progress/status columns cover all bulk job state.

---

## 11. File Locations

| File | Purpose |
|------|---------|
| `packages/ui/src/data-display/DataTable.tsx` | Add `bulkActions`, `onBulkAction`, `totalMatchingCount`, `getRowId` props; inject checkbox column |
| `packages/ui/src/data-display/BulkActionBar.tsx` | New component — bulk action bar + "select all matching" sub-line |
| `packages/ui/src/feedback/ConfirmationDialog.tsx` | Existing component — used for `requiresConfirmation` actions |
| `apps/zync-api/src/routes/tasks/bulk.ts` | Tasks bulk endpoint |
| `apps/zync-api/src/routes/invoices/bulk.ts` | Invoices bulk endpoint |
| `apps/zync-api/src/routes/customers/bulk.ts` | Customers bulk endpoint + CSV streaming |
| `apps/zync-api/src/routes/expenses/bulk.ts` | Expenses bulk endpoint |
| `apps/zync-api/src/routes/kb/bulk.ts` | KB articles bulk endpoint |
| `apps/zync-api/src/routes/tickets/bulk.ts` | Support tickets bulk endpoint |
| `apps/zync-api/src/lib/bulk-queue.ts` | `dispatchBulkJob` shared helper |
| `apps/zync-api/src/queues/bulk-action.ts` | Cloudflare Queue consumer |
| `apps/zync-api/src/lib/execute-bulk-action.ts` | Shared action executor (delegates to module handlers) |
