# Native Zapier / Make Integration — Implementation Plan

**Spec:** docs/specs/2026-06-01-zapier-make-integration.md  ·  **Slug:** zapier-make-integration  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, settings-module, tenant-public-api, white-label-api

## Goal
Deliver official Zync listings on the Zapier and Make (Integromat) automation platforms for Business+ tenants. Each listing exposes curated triggers (delivered via REST hooks, not polling), actions, and search modules, authenticated with OAuth 2.0. This plan builds the two platform app bundles (`packages/zapier`, `packages/make`), registers the two third-party OAuth clients as seed data, extends the Zync public API with the endpoints those bundles call (`/v1/leads`, `/v1/time`, `POST /v1/invoices/:id/send`, and `?email`/`?name`/`?number` search filters), wires the OAuth→public-API scope mapping, and adds the Zapier + Make cards to `/settings/integrations`.

## Architecture
This spec is mostly a *consumer* of upstream infrastructure plus a small set of new public-API endpoints. Ownership boundary:

**Referenced (do NOT redefine — owned upstream):**
- OAuth 2.0 authorization-code flow, `oauth_clients`, `oauth_authorization_codes`, `oauth_access_tokens`, `oauth_refresh_tokens`, `oauth_connections`, `GET/POST /oauth/authorize`, `POST /oauth/token`, `GET /api/oauth/connections`, `DELETE /api/oauth/connections/:clientId` — owned by `oauth-authorization-code` (spec 177). This plan only seeds two rows into `oauth_clients`.
- Webhook gateway: `webhook_endpoints`, `webhook_deliveries`, `webhook.deliver` queue, the full event catalog, HMAC-SHA256 signing (`X-Zync-Signature`), and `/api/webhooks/endpoints` CRUD — owned by `white-label-api`. The Zapier REST-hook `subscribe`/`unsubscribe` calls these via the public API.
- Public API surface: `tenant_api_keys`, `ApiScope`, cursor pagination (`buildPaginated`, `encodeCursor`/`decodeCursor`), serializers (`serializeCustomer`, `serializeInvoice`, `serializeTask`, `serializeEvent`), `hasScope`, the `api.zync.is/v1/` Worker, `RATE_LIMITER_AUTH` binding, and the Business+ tier gate (`tier_required` 403 for Freelancer) — owned by `tenant-public-api`. This plan extends `ApiScope` and adds endpoints following its exact patterns.
- `leads` table (owned by `marketing-leads-pipeline`), `time_entries` table (owned by `time-management`), `customers`/`invoices`/`projects` (owned upstream). This plan reads/writes them through new serializers; it creates no new tables.
- Settings Integrations Hub `/settings/integrations` (owned by `settings-module`). This plan adds two cards into the existing grid.

**Owned (built here):**
- `packages/zapier/` — Zapier CLI app bundle (Node, runs on Zapier's platform).
- `packages/make/` — Make custom-app bundle (JSON module schemas + Node handlers, runs on Make's platform).
- New public-API endpoints in `packages/public-api`: `GET/POST /v1/leads`, `PATCH /v1/leads/:id`, `POST /v1/time`, `POST /v1/invoices/:id/send`; search query-params on `GET /v1/customers`, `GET /v1/projects`, `GET /v1/invoices`.
- `ApiScope` extension: `leads:read`, `leads:write`, `time:read`, `time:write`.
- New serializers `serializeLead`, `serializeTimeEntry`; `serializeProject` if not already present.
- Two `oauth_clients` seed rows (`zapier_zync`, `make_zync`) with `is_first_party = false`.
- The OAuth-scope → ApiScope mapping (`OAUTH_SCOPE_TO_API_SCOPE`) consumed by the public-API auth middleware so OAuth bearer tokens authorize `/v1/*` calls.
- Zapier + Make cards in `/settings/integrations`.

**Data flow (REST-hook trigger, e.g. "Invoice paid"):** User activates the Zap → Zapier calls `subscribe` → `POST /v1/webhooks` (proxy to `POST /api/webhooks/endpoints`) creates a `webhook_endpoints` row targeting Zapier's `targetUrl`, subscribed to `invoice.paid` → Zync emits `invoice.paid` → `webhook.deliver` queue HMAC-signs and POSTs to Zapier → Zapier's `perform` returns `bundle.cleanedRequest.body` to the user's Zap. Deactivating the trigger calls `unsubscribe` → `DELETE /v1/webhooks/:id`.

**Data flow (action, e.g. "Create lead"):** Zap step fires → Zapier sends `POST /v1/customers` or `POST /v1/leads` with the OAuth bearer token → public-API middleware hashes/validates token, resolves tenant, checks Business+ tier, maps OAuth scope to `ApiScope`, executes scoped write.

## Tech Stack
- **`packages/zapier`** — Zapier Platform CLI app. Plain Node (CommonJS, `z.request`), runs on Zapier's infra — NOT Cloudflare Workers, NOT Drizzle. Defines `authentication.js` (OAuth2), `triggers/`, `creates/`, `searches/`, `index.js`.
- **`packages/make`** — Make custom-app SDK bundle: JSON connection/module definitions + Node handler functions. Runs on Make's infra — NOT Cloudflare Workers.
- **`packages/public-api`** (`zync-public-api`) — Hono routes on the `api.zync.is/v1/` Cloudflare Worker, Drizzle against Neon Postgres via Hyperdrive. New endpoints + `ApiScope` extension live here.
- **`apps/zync-app` (Vite + React)** — `/settings/integrations` Zapier/Make cards, using `@zync/ui` (`Card`, `Button`, `Badge`).
- **`@zync/db`** — Drizzle migration for the two `oauth_clients` seed rows (data migration, not schema).
- **Bindings:** `RATE_LIMITER_AUTH` (reused), `DB`/Hyperdrive, `INTEGRATION_ENCRYPTION_KEY` (referenced indirectly via white-label-api webhook layer). New secret: `ZAPIER_OAUTH_CLIENT_SECRET`, `MAKE_OAUTH_CLIENT_SECRET` (plaintext used to compute the SHA-256 hash stored in `oauth_clients.client_secret_hash`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11a | Task 1 (ApiScope + OAuth-scope mapping), Task 2 (serializers), Task 13 (event-catalog delta) | `packages/public-api` types/serializers, white-label-api event catalog | Task 1 blocks 3-7; Tasks 2 and 13 parallel with 1 |
| 11b | Task 3 (leads endpoints), Task 4 (time endpoint), Task 5 (invoice send), Task 6 (search filters) | `packages/public-api` routes | Parallel after 11a |
| 11c | Task 7 (webhook proxy endpoints) | `packages/public-api` routes | After Task 1 |
| 11d | Task 8 (oauth_clients seed migration) | `packages/db` migrations | Parallel with 11b/11c |
| 11e | Task 9 (Zapier bundle), Task 10 (Make bundle) | `packages/zapier`, `packages/make` | Parallel after 11b/11c |
| 11f | Task 11 (settings cards) | `apps/zync-app` settings | After Task 8; parallel with 11e |
| 11g | Task 12 (tier gate + scope-limit verification tests) | test files | Last |

## Tasks

### Task 1: Extend `ApiScope` and define OAuth→API scope mapping
**Blocks:** 3, 4, 5, 7, 9, 10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/public-api/src/scopes.ts` (or wherever `ApiScope` is defined in `tenant-public-api`)
- Create: `packages/public-api/src/oauth-scope-map.ts`
**Steps:**
- [ ] Extend the `ApiScope` union with `leads:read`, `leads:write`, `time:read`, `time:write` (keeping existing `customers:*`, `invoices:*`, `tasks:*`, `events:read`).
- [ ] Update `hasScope` callers / scope matrix so write scopes still imply read for the new resources (a `leads:write` key passes `GET /v1/leads`; a `time:write` key passes any `time:read` use).
- [ ] Define `OAUTH_SCOPE_TO_API_SCOPE` mapping the spec-177 OAuth scope vocabulary (`read:invoices` style) to the public-API `ApiScope` vocabulary (`invoices:read` style), so an OAuth bearer token's granted scopes resolve to `ApiScope` values during `/v1/*` auth.
- [ ] In the public-API auth middleware, when the bearer token is an OAuth access token (`oauth_access_tokens` lookup by SHA-256 hash) rather than a `tenant_api_keys` key, map its `scope` string through `OAUTH_SCOPE_TO_API_SCOPE` before the scope check; still enforce the Business+ tier gate and `tier_required` 403 for Freelancer tenants.
**Schema / Interfaces:**
```ts
export type ApiScope =
  | 'customers:read' | 'customers:write'
  | 'invoices:read'  | 'invoices:write'
  | 'tasks:read'     | 'tasks:write'
  | 'leads:read'     | 'leads:write'
  | 'time:read'      | 'time:write'
  | 'events:read';

// OAuth (spec 177) scope vocabulary -> public-API ApiScope
export const OAUTH_SCOPE_TO_API_SCOPE: Record<string, ApiScope> = {
  'read:invoices':  'invoices:read',
  'write:invoices': 'invoices:write',
  'read:customers': 'customers:read',
  'write:customers':'customers:write',
  'read:leads':     'leads:read',
  'write:leads':    'leads:write',
  'read:time':      'time:read',
  'write:time':     'time:write',
  'read:events':    'events:read',   // required for /v1/webhooks subscribe/unsubscribe (REST hooks)
};
```
**Acceptance:**
- [ ] An OAuth access token granted `write:leads` authorizes `POST /v1/leads` and `GET /v1/leads`, and is rejected with `403 insufficient_scope` on `POST /v1/invoices`.
- [ ] A Freelancer-tenant token is rejected `403 { error: "tier_required" }` before any scope check, regardless of grant.

### Task 2: Add `serializeLead`, `serializeTimeEntry`, `serializeProject` serializers
**Blocks:** 3, 4, 6  ·  **Blocked by:** —
**Files:**
- Create/Modify: `packages/public-api/src/serializers/lead.ts`
- Create/Modify: `packages/public-api/src/serializers/time-entry.ts`
- Modify: `packages/public-api/src/serializers/project.ts` (add if `serializeProject` not already exported)
**Steps:**
- [ ] `serializeLead(row)` → snake_case JSON: `id`, `name`, `email`, `phone`, `company`, `stage`, `source`, `estimated_value`, `customer_id`, `assigned_to`, `created_at`. Never expose `tenant_id`, `source_metadata`, internal UTM unless requested.
- [ ] `serializeTimeEntry(row)` → snake_case JSON: `id`, `project_id`, `task_id`, `description`, `started_at`, `stopped_at`, `duration_minutes` (computed: `Math.round(duration_seconds / 60)`), `billable`, `source`, `created_at`.
- [ ] `serializeProject(row)` → snake_case JSON: `id`, `name`, `customer_id`, `status`, `created_at` (for the search module response).
- [ ] Follow the exact snake_case-in-JSON / camelCase-internally convention of `tenant-public-api`.
**Schema / Interfaces:**
```ts
// stage enum verbatim from leads table:
// 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'PROPOSAL' | 'WON' | 'LOST'
// source enum verbatim (includes 'zapier' | 'make'):
// 'manual'|'form'|'webhook'|'facebook'|'google'|'linkedin'|'instagram'|'zapier'|'make'|'referral'|'cold_outreach'
export interface LeadObject {
  id: string; name: string; email: string | null; phone: string | null;
  company: string | null; stage: string; source: string;
  estimated_value: string | null; customer_id: string | null;
  assigned_to: string | null; created_at: string;
}
export interface TimeEntryObject {
  id: string; project_id: string; task_id: string | null;
  description: string | null; started_at: string; stopped_at: string | null;
  duration_minutes: number | null; billable: boolean; source: string; created_at: string;
}
```
**Acceptance:**
- [ ] All serializer outputs are snake_case and exclude `tenant_id`.
- [ ] `duration_minutes` equals `round(duration_seconds/60)` and is `null` for a running timer.

### Task 3: Public-API leads endpoints (`GET/POST /v1/leads`, `PATCH /v1/leads/:id`)
**Blocks:** 9, 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/public-api/src/routes/leads.ts`
- Modify: `packages/public-api/src/app.ts` (mount route)
**Steps:**
- [ ] `GET /v1/leads` — scope `leads:read`; cursor-paginated via `buildPaginated`/`encodeCursor`/`decodeCursor`; tenant-scoped query (`tenantQuery`) on `leads`; optional `?email=` exact filter for the Make/Zapier "Get lead" use; exclude `archived_at IS NOT NULL` by default.
- [ ] `POST /v1/leads` — scope `leads:write`; Zod-validate body (`name` required; `email`, `phone`, `source` optional). Default `stage='NEW'`, compute `stage_position` as the next fractional index in the NEW column, `source` defaults to `'zapier'` or `'make'` when the request carries the corresponding OAuth client id, else `'manual'`. Insert into `leads`, return `201` with `serializeLead`.
- [ ] `PATCH /v1/leads/:id` — scope `leads:write`; Zod-validate `{ stage }` against the stage enum; on stage change update `leads.stage` + recompute `stage_position`, log a `lead_activities` row (`type='stage_changed'`), and emit the `lead.stage_updated` outbound webhook (reuse the white-label-api event-emit helper). Return `200` with `serializeLead`. `404 not_found` if the lead is outside tenant scope.
- [ ] Require Zod validation in routes (`require-zod-validation-in-routes`); use repository helpers, no raw Drizzle from routes (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
// POST /v1/leads body
const createLeadSchema = z.object({
  name: z.string().min(1),
  email: z.string().email().optional(),
  phone: z.string().optional(),
  source: z.enum(['manual','form','webhook','facebook','google','linkedin',
                  'instagram','zapier','make','referral','cold_outreach']).optional(),
  company: z.string().optional(),
  estimated_value: z.number().optional(),
});
// PATCH /v1/leads/:id body
const updateLeadStageSchema = z.object({
  stage: z.enum(['NEW','CONTACTED','QUALIFIED','PROPOSAL','WON','LOST']),
});
```
**Acceptance:**
- [ ] `POST /v1/leads` from a Make-client OAuth token creates a lead with `source='make'`, returns `201` + snake_case body.
- [ ] `PATCH /v1/leads/:id` to `WON` emits `lead.stage_updated` and writes a `lead_activities` row.
- [ ] `GET /v1/leads?email=x@y.com` returns the matching lead (search module) and respects cursor pagination.

### Task 4: Public-API time endpoint (`POST /v1/time`)
**Blocks:** 9, 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/public-api/src/routes/time.ts`
- Modify: `packages/public-api/src/app.ts`
**Steps:**
- [ ] `POST /v1/time` — scope `time:write`; Zod-validate `{ project_id (required), task_id?, description?, date?, duration_minutes (required) }`.
- [ ] Convert `duration_minutes` → `duration_seconds = duration_minutes * 60`. Set `started_at` to `date` (00:00 tenant-tz) when `date` given else `now()`, `stopped_at = started_at + duration_seconds`, `source='manual'`, `billable=true`, `user_id = tenant_api_keys.created_by` (or the OAuth token's `user_id`).
- [ ] Validate `project_id` belongs to the tenant (`404 not_found` otherwise). Insert into `time_entries`, return `201` with `serializeTimeEntry`.
**Schema / Interfaces:**
```ts
const createTimeEntrySchema = z.object({
  project_id: z.string().uuid(),
  task_id: z.string().uuid().optional(),
  description: z.string().optional(),
  date: z.string().optional(),            // ISO date; defaults to today
  duration_minutes: z.number().int().positive(),
});
```
**Acceptance:**
- [ ] `POST /v1/time` with `duration_minutes:90` stores `duration_seconds=5400` and the response `duration_minutes` round-trips to `90`.
- [ ] A `project_id` from another tenant yields `404 not_found`.

### Task 5: Public-API `POST /v1/invoices/:id/send`
**Blocks:** 9, 10  ·  **Blocked by:** 1
**Files:**
- Create/Modify: `packages/public-api/src/routes/invoices.ts`
- Modify: `packages/public-api/src/app.ts`
**Steps:**
- [ ] `POST /v1/invoices/:id/send` — scope `invoices:write`; tenant-scoped lookup of the invoice (`404 not_found` if absent/cross-tenant).
- [ ] Reuse the `invoices-core` DRAFT→SENT transition logic (assign proforma number atomically, stamp VAT rate from `vat_rates` at `issue_date`). Reject with `422 validation_error` if the invoice is not in `DRAFT`.
- [ ] Emit the `invoice.sent`/`invoice.issued` outbound webhook per the event catalog. Return `200` with `serializeInvoice`.
**Acceptance:**
- [ ] Sending a DRAFT invoice transitions it to SENT, assigns a proforma number, and emits `invoice.sent`.
- [ ] Sending an already-SENT invoice returns `422`.

### Task 6: Search query-params on customers / projects / invoices
**Blocks:** 9, 10  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/public-api/src/routes/customers.ts`
- Modify: `packages/public-api/src/routes/projects.ts` (create if absent)
- Modify: `packages/public-api/src/routes/invoices.ts`
**Steps:**
- [ ] `GET /v1/customers?email=` — scope `customers:read`; exact-match email filter (case-insensitive); returns the cursor-paginated list (one element when matched) for the "Get Customer by Email" search module.
- [ ] `GET /v1/projects?name=` — scope `tasks:read` (projects ride the tasks scope per public-API convention; if a `projects:*` scope does not exist, document the chosen scope explicitly here: use `tasks:read`); name substring filter; returns `serializeProject` list. Mount `/v1/projects` route if not already present.
- [ ] `GET /v1/invoices?number=` — scope `invoices:read`; exact-match on `invoice_number` OR `proforma_number`; returns matching invoice list for "Get Invoice by Number".
- [ ] All three remain cursor-paginated and tenant-scoped.
**Acceptance:**
- [ ] `GET /v1/customers?email=a@b.com` returns only the matching customer.
- [ ] `GET /v1/invoices?number=2026-0042` matches on either invoice or proforma number.
- [ ] `GET /v1/projects?name=Acme` returns projects whose name contains "Acme".

### Task 7: Webhook proxy endpoints for REST hooks (`/v1/webhooks`)
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/public-api/src/routes/webhooks.ts`
- Modify: `packages/public-api/src/app.ts`
**Steps:**
- [ ] `POST /v1/webhooks` — scope `events:read`; body `{ event, target_url, name }`. Proxy to the white-label-api endpoint logic: create a `webhook_endpoints` row for the tenant subscribed to the single `event`, `url = target_url`, `is_active = true`, auto-generate + AES-256-GCM-encrypt the signing secret via `INTEGRATION_ENCRYPTION_KEY`. Return `201 { id }`.
- [ ] `DELETE /v1/webhooks/:id` — scope `events:read`; delete the tenant's `webhook_endpoints` row (used by Zapier `unsubscribe`). `404 not_found` if cross-tenant.
- [ ] `GET /v1/webhooks` — scope `events:read`; list the tenant's endpoints (id, event, url, is_active) — the public projection of `webhook_endpoints`.
- [ ] Reuse white-label-api repository helpers; do not duplicate the `webhook_endpoints` schema (it is owned upstream).
**Schema / Interfaces:**
```ts
const createWebhookSchema = z.object({
  event: z.string().min(1),                  // must be a member of the white-label-api event catalog
  target_url: z.string().url(),              // HTTPS required
  name: z.string().min(1),
});
```
**Acceptance:**
- [ ] Zapier `subscribe` → `POST /v1/webhooks` creates an active `webhook_endpoints` row scoped to the tenant.
- [ ] Zapier `unsubscribe` → `DELETE /v1/webhooks/:id` removes only that tenant's endpoint; cross-tenant id → `404`.
- [ ] A non-catalog `event` string is rejected `422 validation_error`.

### Task 8: Seed `oauth_clients` rows for Zapier and Make
**Blocks:** 11  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/20260601120000_seed_zapier_make_oauth_clients.sql` (use the migration tool's timestamp prefix convention)
- Modify: `packages/db/src/seed.ts` (idempotent upsert)
**Steps:**
- [ ] Insert two rows into the upstream `oauth_clients` table (owned by spec 177 — this is a data migration, not DDL). `client_secret_hash = SHA-256(<plaintext from secret>)`; secrets come from `ZAPIER_OAUTH_CLIENT_SECRET` / `MAKE_OAUTH_CLIENT_SECRET` Worker secrets at seed time, never committed.
- [ ] `redirect_uris` (JSONB) holds Zapier's `https://zapier.com/dashboard/auth/oauth/return/App1234CLIAPI/` and Make's marketplace return URI (`https://www.make.com/oauth/cb/app`) respectively.
- [ ] `scopes` (JSONB) granted: `read:invoices`, `write:invoices`, `read:customers`, `write:customers`, `read:leads`, `write:leads`, `read:time`, `write:time`, `read:events` (the last authorizes REST-hook webhook subscribe/unsubscribe). This requires spec-177 to recognize `read:events` as an allowable OAuth scope; if absent, add it to the spec-177 OAuth scope list as a one-line delta noted in Task 8.
- [ ] `is_first_party = false` for both (third-party → consent screen is shown, not bypassed).
- [ ] Make the seed idempotent (`ON CONFLICT (client_id) DO UPDATE`).
**Schema / Interfaces:**
```sql
-- oauth_clients is OWNED by spec 177 (oauth-authorization-code). Seed data only:
INSERT INTO oauth_clients (client_id, client_secret_hash, name, redirect_uris, scopes, is_first_party, logo_url)
VALUES
  ('zapier_zync', encode(digest(:zapier_secret, 'sha256'), 'hex'), 'Zapier',
   '["https://zapier.com/dashboard/auth/oauth/return/App1234CLIAPI/"]'::jsonb,
   '["read:invoices","write:invoices","read:customers","write:customers","read:leads","write:leads","read:time","write:time","read:events"]'::jsonb,
   false, NULL),
  ('make_zync', encode(digest(:make_secret, 'sha256'), 'hex'), 'Make',
   '["https://www.make.com/oauth/cb/app"]'::jsonb,
   '["read:invoices","write:invoices","read:customers","write:customers","read:leads","write:leads","read:time","write:time","read:events"]'::jsonb,
   false, NULL)
ON CONFLICT (client_id) DO UPDATE
  SET redirect_uris = EXCLUDED.redirect_uris,
      scopes        = EXCLUDED.scopes,
      name          = EXCLUDED.name;
```
**Acceptance:**
- [ ] Both clients exist with `is_first_party = false` and the nine granted scopes (including `read:events`).
- [ ] Re-running the seed does not duplicate rows or change `client_secret_hash` unintentionally.

### Task 9: Zapier app bundle (`packages/zapier`)
**Blocks:** —  ·  **Blocked by:** 1, 3, 4, 5, 6, 7, 13
**Files:**
- Create: `packages/zapier/package.json`, `packages/zapier/index.js`, `packages/zapier/authentication.js`
- Create: `packages/zapier/triggers/{new-invoice,invoice-sent,invoice-paid,invoice-overdue,new-lead,lead-converted,ticket-created,time-entry-approved}.js` (display names; each maps to a catalog event per the table below)
- Create: `packages/zapier/creates/{create-customer,create-lead,update-lead-stage,create-invoice,send-invoice,add-time-entry,create-task}.js`
- Create: `packages/zapier/searches/{find-customer,find-project,find-invoice}.js`
**Steps:**
- [ ] `authentication.js`: OAuth 2.0 Authorization Code config pointing at `https://app.zync.is/oauth/authorize` and `https://api.zync.is/oauth/token` (spec 177). `client_id = 'zapier_zync'`, scope string `read:invoices write:invoices read:customers write:customers read:leads write:leads read:time write:time read:events`. Set `Authorization: Bearer {{bundle.authData.access_token}}` on every request; implement token refresh against `/oauth/token`.
- [ ] Triggers as REST hooks: each defines `subscribe` (`POST https://api.zync.is/v1/webhooks` with `{ event, target_url: bundle.targetUrl, name }`), `unsubscribe` (`DELETE /v1/webhooks/:id`), and `perform` (`return [bundle.cleanedRequest.body]`). The `event` value MUST be a member of the white-label-api Full Event Catalog (Task 7 validation rejects non-catalog events with `422`). Use this trigger-display → catalog-event map (catalog owned by white-label-api):

  | Zapier trigger (display) | Catalog event (subscribed) |
  |--------------------------|-----------------------------|
  | New invoice created      | `invoice.proforma_approved` |
  | Invoice sent             | `invoice.issued`            |
  | Invoice paid             | `invoice.paid`              |
  | Invoice overdue          | `invoice.overdue`           |
  | New lead                 | `lead.created`              |
  | Lead converted           | `lead.converted` (catalog delta — Task 13) |
  | New support ticket       | `ticket.created`            |
  | Time entry approved      | `time_entry.approved` (catalog delta — Task 13) |

  Provide `performList` sample payloads for Zapier's test step with the exact payload fields from the spec's trigger table.
- [ ] Creates → call the public API: Create customer `POST /v1/customers`; Create lead `POST /v1/leads`; Update lead stage `PATCH /v1/leads/:id`; Create invoice `POST /v1/invoices`; Send invoice `POST /v1/invoices/:id/send`; Add time entry `POST /v1/time`; Create task `POST /v1/tasks`. Field schemas match the spec's action table.
- [ ] Searches: Find customer `GET /v1/customers?email=`; Find project `GET /v1/projects?name=`; Find invoice `GET /v1/invoices?number=`.
- [ ] `index.js` wires `authentication`, `triggers`, `creates`, `searches` and `beforeRequest`/`afterResponse` (inject bearer token; surface `401`→reauth, `403 tier_required` as a clear user error).
**Acceptance:**
- [ ] `zapier validate` passes on the bundle; `zapier test` authenticates via OAuth and lists triggers/creates/searches.
- [ ] The `invoice.paid` trigger's `subscribe` creates a `webhook_endpoints` row and `perform` returns the delivered payload unchanged.
- [ ] Each create/search hits the corresponding `/v1/*` endpoint with the OAuth bearer token.

### Task 10: Make custom-app bundle (`packages/make`)
**Blocks:** —  ·  **Blocked by:** 1, 3, 4, 5, 6
**Files:**
- Create: `packages/make/app.json` (connection + base config)
- Create: `packages/make/modules/triggers/{watch-invoices,watch-leads,watch-tickets}.json`
- Create: `packages/make/modules/actions/{create-customer,create-invoice,create-lead,update-lead,create-task,log-time-entry,send-invoice}.json`
- Create: `packages/make/modules/searches/{get-customer-by-email,get-invoice-by-number,get-project-by-name}.json`
- Create: `packages/make/handlers/*.js`
**Steps:**
- [ ] `app.json`: OAuth 2.0 connection (same flow as Zapier), `client_id = 'make_zync'`, base URL `https://api.zync.is/v1`, common `Authorization: Bearer {{connection.accessToken}}` header, token refresh against `/oauth/token`.
- [ ] Triggers (Watch modules), instant-webhook-backed. Each `event` MUST be a white-label-api catalog member (Task 7 rejects non-catalog with `422`). Watch Invoices subscribes to `invoice.proforma_approved`/`invoice.issued`/`invoice.paid`; Watch Leads to `lead.created`/`lead.stage_updated`; Watch Tickets to `ticket.created`/`ticket.replied`. Each registers/deregisters a `webhook_endpoints` subscription via `POST`/`DELETE /v1/webhooks`.
- [ ] Actions: Create Customer `POST /v1/customers`; Create Invoice `POST /v1/invoices`; Create Lead `POST /v1/leads`; Update Lead `PATCH /v1/leads/:id`; Create Task `POST /v1/tasks`; Log Time Entry `POST /v1/time`; Send Invoice `POST /v1/invoices/:id/send`.
- [ ] Searches: Get Customer by Email `GET /v1/customers?email=`; Get Invoice by Number `GET /v1/invoices?number=`; Get Project by Name `GET /v1/projects?name=`.
- [ ] Each module JSON declares input/output field mappings in snake_case matching the public-API serializers.
**Acceptance:**
- [ ] The Make app passes the platform's module validation; the OAuth connection completes the spec-177 flow.
- [ ] Each Watch module registers a `webhook_endpoints` subscription on activation and removes it on deactivation.
- [ ] Action/search modules call the matching `/v1/*` endpoints with the bearer token.

### Task 11: `/settings/integrations` Zapier + Make cards
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Modify: `apps/zync-app/src/routes/settings/integrations.tsx` (the Integrations Hub from settings-module)
- Create: `apps/zync-app/src/components/settings/ZapierCard.tsx`, `apps/zync-app/src/components/settings/MakeCard.tsx`
**Steps:**
- [ ] Add Zapier and Make cards to the existing integrations grid (Productivity/Automation group). Each card: provider logo, name, tagline ("Automate Zync with 5,000+ apps."), connection status badge (Connected/Disconnected), last sync timestamp, active-Zaps/scenarios count.
- [ ] "Connect" → initiates the OAuth authorization flow at `https://app.zync.is/oauth/authorize?client_id=zapier_zync&response_type=code&redirect_uri=REGISTERED_URI&scope=read:invoices+write:invoices+read:customers+write:customers+read:leads+write:leads+read:time+write:time+read:events&state=CSRF_TOKEN` (or `client_id=make_zync`); `redirect_uri` is the client's registered URI from Task 8. On return, the spec-177 flow records an `oauth_connections` row; the card reflects Connected status by reading `GET /api/oauth/connections`.
- [ ] "View in Zapier"/"View in Make" → deep link to the respective app dashboard.
- [ ] "Disconnect" → `DELETE /api/oauth/connections/:clientId` (revokes all tokens for that client).
- [ ] Gate both cards behind Business+ tier (use `useTierGate`); show the upgrade affordance (`useUpgradeModal`) for Freelancer instead of "Connect".
- [ ] Cross-cutting: a11y — status badge has `role="status"` / accessible label; "Connect"/"Disconnect" buttons have discernible text; respect `prefers-reduced-motion` for any card transition; RTL-safe layout (logical properties, no hardcoded left/right). No hardcoded colors/spacing/radius (use design tokens).
**Acceptance:**
- [ ] Business+ tenant sees Connect; clicking runs the OAuth flow and the card shows Connected with the client's scopes.
- [ ] Disconnect revokes tokens (`oauth_connections` row removed) and the card returns to Disconnected.
- [ ] Freelancer tenant sees the upgrade affordance, not Connect.
- [ ] Cards pass a11y checks (axe: no violations) and render correctly in Hebrew/RTL.

### Task 12: Tier-gate and scope-limit verification
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 6, 7
**Files:**
- Create: `packages/public-api/test/zapier-make-scopes.test.ts`
**Steps:**
- [ ] Test that a Freelancer-tenant OAuth token is rejected `403 tier_required` on every new endpoint (`/v1/leads`, `/v1/time`, `/v1/invoices/:id/send`, `/v1/webhooks`) before scope evaluation.
- [ ] Test that a token granted only `read:customers` (→ `customers:read`) is rejected `403 insufficient_scope` on `POST /v1/leads` and `POST /v1/time`.
- [ ] Test that the OAuth→API scope mapping authorizes the right endpoints: `write:leads`→`POST/PATCH /v1/leads`; `write:time`→`POST /v1/time`; `write:invoices`→`POST /v1/invoices/:id/send`.
- [ ] Test that the tokens cannot reach admin/settings routes (scope-limited: no admin/settings scope exists in the grant), confirming the spec's "not admin/settings access" decision.
**Acceptance:**
- [ ] All scope/tier assertions pass; no Zapier/Make token can mutate settings or admin resources.

### Task 13: White-label-api event-catalog delta (`lead.converted`, `time_entry.approved`)
**Blocks:** 9  ·  **Blocked by:** —
**Files:**
- Modify: the white-label-api event-emit helper / catalog constant (the module that owns the Full Event Catalog and the `webhook.deliver` enqueue, per the white-label-api plan)
- Modify: the lead-conversion handler (marketing-leads-pipeline convert-to-customer flow)
- Modify: the time-entry approval handler (time-management / team-time-overview approval flow)
**Steps:**
- [ ] Add `lead.converted` and `time_entry.approved` to the white-label-api Full Event Catalog constant so Task 7 webhook-subscribe accepts them (otherwise these two Zapier triggers fail `422` at subscribe).
- [ ] Emit `lead.converted` from the lead → customer conversion flow with payload `{ leadId, customerId, customerName }`.
- [ ] Emit `time_entry.approved` from the time-entry approval action with payload `{ timeEntryId, userId, projectId, hours, date }`.
- [ ] Both emits enqueue `webhook.deliver` (the existing white-label-api async delivery path with HMAC signing); do not deliver synchronously.
**Acceptance:**
- [ ] `POST /v1/webhooks { event: "lead.converted" }` and `{ event: "time_entry.approved" }` succeed (no `422`).
- [ ] Converting a lead delivers a `lead.converted` webhook; approving a time entry delivers a `time_entry.approved` webhook, both HMAC-signed.
