# Contracts & E-Signature — Implementation Plan

**Spec:** docs/specs/2026-05-31-contracts-esignature.md  ·  **Slug:** contracts-esignature  ·  **Wave:** 8
**Depends on:** customers-module, foundation-auth-rbac, invoices-core, kb-module, marketing-leads-pipeline

## Goal
Deliver a self-hosted contract and e-signature module so tenants can author contracts (from reusable Tiptap templates or blank), send them to up to three signatories for legally-valid electronic signature (typed or drawn name + timestamp + IP, per Israeli Electronic Signature Law 5761-2001), and collect signatures through an unauthenticated public signing page with no third-party SaaS. On completion the system renders a signed PDF to R2, emails all parties, and exposes hand-off hooks to leads (lead → contract) and invoices (contract → invoice). It sits in the workflow between winning a lead and issuing an invoice.

## Architecture
- **Backend package `@zync/contracts`** (`packages/contracts`) holds Drizzle schema, Zod validators, query helpers, the contract lifecycle state machine, the variable-substitution engine, the Tiptap content validator, and the PDF/email completion flow. It is consumed by the API worker.
- **API routes** live in the Hono worker (`apps/zync-api`) under two route groups: authenticated `/api/contracts*` + `/api/contract-templates*` (guarded by `authMiddleware`, `requireModuleEnabled('contracts')`, `requirePermission`), and **public** `/api/sign/:token` (no session; rate-limited via existing `RATE_LIMITER_AUTH`).
- **Frontend** (`apps/zync-app`, Vite+React) provides the staff screens (`/contracts`, `/contracts/new`, `/contracts/:id`, `/contracts/templates*`) and a standalone public signing route (`/sign/:token`). The rich editor reuses the existing Tiptap component and `extensions` from `packages/ui/src/components/rich-editor` (already pulled in by the KB module — no new editor package).
- **Upstream tables consumed (do NOT recreate):** `tenants(id)`, `users(id)`, `customers(id)`, `invoices(id)`, `leads(id)`, `permissions`, `role_permissions`, `roles`. `leads.contract_id UUID REFERENCES contracts(id)` is **already declared in the marketing-leads-pipeline spec** — this plan does NOT re-add it; it only adds `invoices.contract_id`.
- **Upstream exports consumed:** `authMiddleware`, `requirePermission`, `requireModuleEnabled`, `tenantQuery`, `systemQuery`, `buildPaginated`, `clampLimit`, `rateLimit`, `sendEmail` / `@zync/notifications`, `seedPermissions`, `MODULE_MANIFEST` / `MODULE_BY_ID`, `generateOpaqueToken`, `cn`, UI primitives (`Button`, `Badge`, `DataTable`, `Dialog`, `Sheet`, `Form`, `FormField`, `Input`, `Tabs`, `EmptyState`, `Toaster`/`toast`), and the rich-editor `extensions`.
- **Data flow (happy path):** staff creates contract (variables substituted server-side into a content snapshot) → `POST /api/contracts/:id/send` freezes content, sets `SENT`, mints per-signatory UUID tokens (`token_expires_at = sent_at + 30d`), emails the lowest-order signatory → signatory opens `/sign/:token` (`GET /api/sign/:token` records `viewed_at`, flips contract to `VIEWED`) → `POST /api/sign/:token` records signature; if all signed, the same DB transaction sets `SIGNED` + `signed_at`, enqueues/executes PDF render to R2 (`contracts/{tenant_id}/{contract_id}/signed.pdf`), emails all signatories + tenant OWNER, writes a `signed` audit row, and fires `contract.signed`.

## Tech Stack
- **Packages:** new `packages/contracts` (Drizzle schema + service layer); reuses `@zync/db`, `@zync/auth`, `@zync/types`, `@zync/notifications`, `@zync/ui`.
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-app` (Vite + React).
- **Libraries:** Drizzle ORM, Zod, `@tiptap/html` (`generateHTML`), `@tiptap/extension-text-direction` (`Direction`), `dompurify`, `signature_pad` (MIT, draw capture), Dancing Script web font (typed-signature rendering). PDF via external HTML-to-PDF service (`https://api.html-to-pdf.zync.is`), same constraint/approach as invoices-core (Workers cannot run a headless browser).
- **Cloudflare bindings (all existing — none new):** `DB` (Hyperdrive→Neon Postgres), `R2` (signed PDF storage), `RATE_LIMITER_AUTH` (public signing rate limit), `RESEND_API_KEY` secret (emails). No new cron, no new secrets, no new binding.
- **Database:** Neon Postgres via Hyperdrive. UUID PKs, UUID→UUID FKs, `TIMESTAMPTZ`, `BOOLEAN`, `JSONB`, inline `CHECK` enums.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a — schema & seeds | 1, 2, 3 | `packages/contracts/src/schema.ts`, migrations, `packages/auth` seeds, `MODULE_MANIFEST` | 1 first; 2,3 parallel after 1 |
| 8b — service layer | 4, 5, 6, 7 | `packages/contracts/src/{validators,variables,content-security,state-machine,queries,pdf,email,audit}.ts` | parallel after 1 |
| 8c — authed API | 8, 9 | `apps/zync-api/src/routes/contracts.ts`, `contract-templates.ts` | parallel after 4–7 |
| 8d — public signing API | 10 | `apps/zync-api/src/routes/sign.ts` | after 4–7 |
| 8e — staff UI | 11, 12, 13, 14 | `apps/zync-app/src/pages/contracts/*`, hooks | after 8–9 |
| 8f — public signing UI | 15 | `apps/zync-app/src/pages/sign/*` | after 10 |
| 8g — integration hooks & tests | 16, 17, 18 | invoices/leads hooks, e2e | after 11–15 |

## Tasks

### Task 1: Database schema — contracts core tables
**Blocks:** 2,3,4,5,6,7,8,9,10  ·  **Blocked by:** —
**Files:**
- Create: `packages/contracts/src/schema.ts`
- Create: `packages/contracts/migrations/0001_contracts_init.sql`
- Modify: `packages/contracts/package.json` (new package manifest, deps: `drizzle-orm`, `zod`, `@zync/db`, `@zync/types`)
- Modify: `packages/db/src/schema/index.ts` (re-export contracts tables for migration aggregation)
**Steps:**
- [ ] Define the four tables in Drizzle matching the DDL below verbatim (UUID PKs, UUID FKs, JSONB, TIMESTAMPTZ, inline CHECK enums).
- [ ] Emit raw SQL migration `0001_contracts_init.sql` with the exact DDL + indexes below.
- [ ] Note: `contract_signatories."order"` is a reserved word — quote it in Drizzle column config (`order: integer('order')`) and in SQL.
- [ ] `token` is stored **plaintext** (it is the capability in the signing URL) — do NOT hash it, unlike invitation/refresh tokens. Add `UNIQUE` and a lookup index.
**Schema / Interfaces:**
```sql
CREATE TABLE contract_templates (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  content     JSONB NOT NULL,                         -- Tiptap JSON document
  variables   JSONB NOT NULL DEFAULT '[]'::jsonb,     -- array of { key, label, type, required }
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ                             -- soft delete
);
CREATE INDEX idx_contract_templates_tenant ON contract_templates(tenant_id) WHERE deleted_at IS NULL;

CREATE TABLE contracts (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id         UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id       UUID REFERENCES customers(id) ON DELETE SET NULL,
  template_id       UUID REFERENCES contract_templates(id) ON DELETE SET NULL,
  title             TEXT NOT NULL,
  content           JSONB NOT NULL,                   -- resolved Tiptap JSON (variables substituted)
  status            TEXT NOT NULL DEFAULT 'DRAFT'
                      CHECK (status IN ('DRAFT', 'SENT', 'VIEWED', 'SIGNED', 'VOIDED')),
  signed_pdf_r2_key TEXT,                             -- R2 object key of final signed PDF
  created_by        UUID NOT NULL REFERENCES users(id),
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  sent_at           TIMESTAMPTZ,
  signed_at         TIMESTAMPTZ,                      -- set when all signatories complete
  voided_at         TIMESTAMPTZ,
  voided_by         UUID REFERENCES users(id),
  void_reason       TEXT
);
CREATE INDEX idx_contracts_tenant_status ON contracts(tenant_id, status);
CREATE INDEX idx_contracts_customer ON contracts(customer_id);

CREATE TABLE contract_signatories (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contract_id      UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
  name             TEXT NOT NULL,
  email            TEXT NOT NULL,
  "order"          INTEGER NOT NULL DEFAULT 1,        -- signing order (1 = first)
  token            TEXT NOT NULL UNIQUE,              -- UUID v4 plaintext (used in URL, not hashed)
  token_expires_at TIMESTAMPTZ NOT NULL,             -- 30 days from sent_at
  viewed_at        TIMESTAMPTZ,
  signed_at        TIMESTAMPTZ,
  signature_data   TEXT,                              -- base64 PNG (drawn or typed-rendered)
  signature_type   TEXT CHECK (signature_type IN ('drawn', 'typed')),
  ip_address       TEXT,
  user_agent       TEXT,
  declined_at      TIMESTAMPTZ,
  decline_reason   TEXT,
  UNIQUE (contract_id, email)
);
CREATE INDEX idx_signatories_token ON contract_signatories(token);
CREATE INDEX idx_signatories_contract ON contract_signatories(contract_id);

CREATE TABLE contract_audit_log (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
  event       TEXT NOT NULL,                          -- created|sent|viewed|signed|voided|downloaded|declined
  actor_type  TEXT NOT NULL CHECK (actor_type IN ('user', 'signatory', 'system')),
  actor_id    TEXT,                                   -- user_id (UUID) or signatory email
  actor_name  TEXT,
  ip_address  TEXT,
  user_agent  TEXT,
  metadata    JSONB NOT NULL DEFAULT '{}'::jsonb,
  timestamp   TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_contract_audit_contract ON contract_audit_log(contract_id);
```
**Acceptance:**
- [ ] `drizzle-kit` generates no diff against the migration; all four tables created in a Neon branch.
- [ ] FKs are UUID→UUID; `status`, `signature_type`, `actor_type` enforce their CHECK sets; `"order"` is quoted everywhere.
- [ ] `token` column is plaintext with `UNIQUE` + index `idx_signatories_token`.

### Task 2: Schema deltas on existing tables (invoices link)
**Blocks:** 16  ·  **Blocked by:** 1
**Files:**
- Create: `packages/contracts/migrations/0002_invoice_contract_link.sql`
- Modify: `packages/db/src/schema/invoices.ts` (add `contract_id` column to the invoices Drizzle model)
**Steps:**
- [ ] Add `invoices.contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL`.
- [ ] Do **NOT** add `leads.contract_id` — it is already declared in the marketing-leads-pipeline spec/schema. If a defensive guard is desired, use `ADD COLUMN IF NOT EXISTS` but assume it exists.
- [ ] Run migration after `0001` so the `contracts(id)` target exists.
**Schema / Interfaces:**
```sql
-- invoices: link back to originating contract
ALTER TABLE invoices ADD COLUMN contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL;
-- leads.contract_id already exists (declared in marketing-leads-pipeline); do not duplicate.
```
**Acceptance:**
- [ ] `invoices.contract_id` exists as UUID FK→`contracts(id)`, nullable, `ON DELETE SET NULL`.
- [ ] Migration applies cleanly on a branch that already has the leads table (no duplicate-column error).

### Task 3: Permission seeds, role assignments & module registration
**Blocks:** 8,9,10  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/seeds/permissions.ts` (add three permission keys to `seedPermissions`)
- Modify: `packages/auth/src/seeds/role-permissions.ts` (default role → permission mapping)
- Modify: `packages/config/src/modules/manifest.ts` (register `contracts` in `MODULE_MANIFEST` / `MODULE_BY_ID`)
**Steps:**
- [ ] Append `contracts:read`, `contracts:write`, `contracts:delete` to the permission seed (idempotent upsert by `key`).
- [ ] Assign defaults: OWNER & ADMIN → all three; MEMBER → read+write; VIEWER → read; CONTRACTOR → none.
- [ ] Register a `contracts` module entry in `MODULE_MANIFEST` (id `contracts`, toggleable, depends on `customers`, `invoices`, `kb`) so `requireModuleEnabled('contracts')` resolves.
**Schema / Interfaces:**
```sql
INSERT INTO permissions (id, key, description) VALUES
  (gen_random_uuid(), 'contracts:read',   'View contracts and templates'),
  (gen_random_uuid(), 'contracts:write',  'Create, edit, send, and void contracts'),
  (gen_random_uuid(), 'contracts:delete', 'Delete draft contracts')
ON CONFLICT (key) DO NOTHING;
```
```ts
// role default map fragment
const CONTRACTS_ROLE_PERMISSIONS = {
  OWNER:      ['contracts:read', 'contracts:write', 'contracts:delete'],
  ADMIN:      ['contracts:read', 'contracts:write', 'contracts:delete'],
  MEMBER:     ['contracts:read', 'contracts:write'],
  VIEWER:     ['contracts:read'],
  CONTRACTOR: [],
} as const
```
**Acceptance:**
- [ ] After re-seeding, the three permission keys exist exactly once each; role_permissions reflect the table above.
- [ ] `requireModuleEnabled('contracts')` and `requirePermission('contracts:read')` are resolvable in the API worker.

### Task 4: Zod validators & shared types
**Blocks:** 8,9,10  ·  **Blocked by:** 1
**Files:**
- Create: `packages/contracts/src/validators.ts`
- Create: `packages/contracts/src/types.ts`
- Modify: `packages/contracts/src/index.ts` (barrel export)
**Steps:**
- [ ] Define `tiptapJsonSchema` (object with `type: 'doc'`) and reuse it for template/contract content.
- [ ] Define `createContractSchema`, `updateContractSchema`, `createContractTemplateSchema`, `updateContractTemplateSchema`, `sendContractSchema`, `voidContractSchema`, `signatoryInputSchema` (max 3), `signSubmissionSchema`, `declineSchema`.
- [ ] Export `ContractStatus`, `SignatureType`, `ContractAuditEvent`, `ContractVariable`, `ContractObject`, `ContractSignatoryObject`, `SignaturePagePayload` TS types.
- [ ] Enforce: signatories array length 1–3; each `{ name, email, order }`; `email` valid; `order` integer ≥ 1.
**Schema / Interfaces:**
```ts
export type ContractStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'SIGNED' | 'VOIDED';
export type SignatureType = 'drawn' | 'typed';
export type ContractAuditEvent =
  | 'created' | 'sent' | 'viewed' | 'signed' | 'voided' | 'downloaded' | 'declined';

export interface ContractVariable {
  key: string; label: string;
  type: 'text' | 'currency' | 'date' | 'number';
  required: boolean;
}

export const signatoryInputSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  order: z.number().int().min(1).default(1),
});

export const createContractSchema = z.object({
  title: z.string().min(1),
  customer_id: z.string().uuid().nullable().optional(),
  template_id: z.string().uuid().nullable().optional(),
  content: tiptapJsonSchema,
  variables: z.record(z.string(), z.unknown()).default({}),   // values to substitute
  signatories: z.array(signatoryInputSchema).min(1).max(3),
});

export const signSubmissionSchema = z.object({
  signature_data: z.string().regex(/^data:image\/png;base64,/),
  signature_type: z.enum(['drawn', 'typed']),
  name: z.string().min(1),
  email: z.string().email(),
  agreed: z.literal(true),
});

export const declineSchema = z.object({ reason: z.string().min(1).max(2000) });
```
**Acceptance:**
- [ ] Invalid payloads (4+ signatories, bad email, non-PNG signature, `agreed !== true`) fail validation; valid ones pass.

### Task 5: Tiptap content security (server validate + render/sanitize)
**Blocks:** 8,9,10,15  ·  **Blocked by:** 1
**Files:**
- Create: `packages/contracts/src/content-security.ts`
**Steps:**
- [ ] Implement `validateContractContent(content)` walking node types; reject any type not in `ALLOWED_NODE_TYPES` (same set as kb-article-editor spec 101). Throw → API returns **422** on unknown node.
- [ ] Implement `renderContractHTML(content)` using `@tiptap/html` `generateHTML(content, extensions)` then `DOMPurify.sanitize` with the allow-lists below.
- [ ] Add the DOMPurify hook restricting `img src` to the tenant's R2 domain (mirror spec 101 hook).
- [ ] Used by: server send-time content freeze, PDF render, staff preview, and the public signing page island.
**Schema / Interfaces:**
```ts
const ALLOWED_NODE_TYPES = new Set([
  'doc', 'paragraph', 'heading', 'text', 'hardBreak',
  'bold', 'italic', 'underline', 'strike', 'link', 'code',
  'bulletList', 'orderedList', 'listItem', 'blockquote',
  'codeBlock', 'image', 'table', 'tableRow', 'tableCell', 'tableHeader',
]);

export function renderContractHTML(content: unknown): string {
  const html = generateHTML(content, extensions);   // from @zync/ui rich-editor
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p','br','strong','em','u','s','h1','h2','h3',
                   'ul','ol','li','blockquote','code','pre','a','img',
                   'table','thead','tbody','tr','th','td'],
    ALLOWED_ATTR: ['href','src','alt','target','rel','class'],
    FORBID_ATTR: ['onerror','onload','onclick'],
  });
}
```
**Acceptance:**
- [ ] Content containing a node type outside the allow-set throws and the route returns 422.
- [ ] Rendered HTML strips `onerror`/`onload`/`onclick` and any `img src` outside the tenant R2 domain.

### Task 6: Variable substitution engine
**Blocks:** 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/contracts/src/variables.ts`
**Steps:**
- [ ] Implement `substituteVariables(content, values, ctx)` replacing `{{key}}` text occurrences in Tiptap text nodes with provided/auto-filled values, producing a **content snapshot** (substitution happens server-side at creation time).
- [ ] Auto-fill built-ins from customer/tenant context: `customer_name`, `customer_email`, `customer_company`, `tenant_name`, `tenant_email`, `date` (today, IL `DD/MM/YYYY`).
- [ ] Validate all `required` declared variables resolve to a non-empty value; otherwise return a 422 list of missing keys.
- [ ] Format `currency` values to ILS and `date` to IL format.
**Schema / Interfaces:**
```ts
interface SubstitutionContext {
  customer?: { name: string; email: string; company?: string };
  tenant: { name: string; email: string };
  locale: 'he-IL' | 'en-US';
}
export function substituteVariables(
  content: unknown,
  values: Record<string, unknown>,
  ctx: SubstitutionContext,
): unknown;   // returns resolved Tiptap JSON snapshot
```
**Acceptance:**
- [ ] `{{customer_name}}`/`{{date}}` resolve from context; declared required vars missing → 422 listing keys.
- [ ] Output contains no residual `{{...}}` for declared variables.

### Task 7: Query helpers, lifecycle state machine, audit, PDF & email
**Blocks:** 8,9,10  ·  **Blocked by:** 1,4,5,6
**Files:**
- Create: `packages/contracts/src/queries.ts`
- Create: `packages/contracts/src/state-machine.ts`
- Create: `packages/contracts/src/audit.ts`
- Create: `packages/contracts/src/pdf.ts`
- Create: `packages/contracts/src/email.ts`
- Create: `packages/contracts/src/completion.ts`
**Steps:**
- [ ] `queries.ts`: tenant-scoped CRUD via `tenantQuery` — `listContracts` (paginated via `buildPaginated`/`clampLimit`, filter by status/customer/date), `getContractWithSignatories`, `createContract`, `updateDraftContract`, `deleteDraftContract`, `voidContract`, `listTemplates`, `getTemplate`, `createTemplate`, `updateTemplate`, `softDeleteTemplate`, signatory helpers (`getSignatoryByToken`, `markSignatoryViewed`, `recordSignature`, `declineSignatory`).
- [ ] `state-machine.ts`: `assertTransition(from, to)` enforcing `DRAFT→SENT`, `SENT→VIEWED`, `SENT|VIEWED→SIGNED`, `SENT|VIEWED→VOIDED`. Reject illegal transitions (e.g. void of DRAFT is delete instead; sign before SENT). `canDelete` only when `DRAFT`.
- [ ] `audit.ts`: `appendContractAudit(db, { contractId, event, actorType, actorId, actorName, ip, userAgent, metadata })`.
- [ ] `pdf.ts`: `generateSignedPdf(contract, signatories)` — render `renderContractHTML` + signature blocks, POST to `https://api.html-to-pdf.zync.is`, store result in R2 at `contracts/{tenant_id}/{contract_id}/signed.pdf`, return the key.
- [ ] `email.ts`: `sendSignatureRequest(signatory, contract, tenant)`, `sendSignedCopies(contract, signatories, ownerEmail)`, `sendDeclinedNotice(contract, signatory, tenant)`, `sendReminder(signatory, contract)` — all via `sendEmail` / `RESEND_API_KEY`.
- [ ] `completion.ts`: `completeIfAllSigned(tx, contractId)` — run **inside** the final-signature DB transaction: set `SIGNED` + `signed_at`, generate PDF → set `signed_pdf_r2_key`, write `signed` audit row (metadata = all signatory IPs + timestamps), email all signatories + tenant OWNER, fire `contract.signed` event if linked to a lead. Signing-order: after each signature, notify next lowest-order unsigned signatory.
**Schema / Interfaces:**
```ts
const TRANSITIONS: Record<ContractStatus, ContractStatus[]> = {
  DRAFT:  ['SENT'],
  SENT:   ['VIEWED', 'SIGNED', 'VOIDED'],
  VIEWED: ['SIGNED', 'VOIDED'],
  SIGNED: [],
  VOIDED: [],
};
export function assertTransition(from: ContractStatus, to: ContractStatus): void; // throws 422 if invalid
export function activeSignatoryOrder(signatories): number; // lowest order with no signed_at/declined_at
```
**Acceptance:**
- [ ] Illegal transitions throw; deleting a non-DRAFT contract is rejected.
- [ ] On all-signed, completion sets `SIGNED`, writes PDF to the exact R2 key, emails parties, and the `signed` audit row contains every signatory IP+timestamp.
- [ ] Only the lowest-order unsigned signatory has an actionable link; next signatory emailed after each signature.

### Task 8: Authenticated contracts API routes
**Blocks:** 11,12,13,16  ·  **Blocked by:** 3,4,5,6,7
**Files:**
- Create: `apps/zync-api/src/routes/contracts.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router under `/api/contracts`)
**Steps:**
- [ ] Apply `authMiddleware`, `requireModuleEnabled('contracts')`, and per-route `requirePermission`.
- [ ] `GET /api/contracts` (`contracts:read`) — paginated, filter by status/customer/date.
- [ ] `POST /api/contracts` (`contracts:write`) — validate, substitute variables, `validateContractContent` (422 on bad node), insert DRAFT, write `created` audit; accept `?lead_id`/`?customer_id` context for auto-fill.
- [ ] `GET /api/contracts/:id` (`contracts:read`) — detail + signatories + audit timeline.
- [ ] `PATCH /api/contracts/:id` (`contracts:write`) — only when `DRAFT`; 422 otherwise.
- [ ] `DELETE /api/contracts/:id` (`contracts:delete`) — hard delete only when `DRAFT`; **422** for any other status.
- [ ] `POST /api/contracts/:id/send` (`contracts:write`) — assert `DRAFT→SENT`, freeze content snapshot, mint UUID tokens with `token_expires_at = now()+30d`, set `sent_at`, email lowest-order signatory, write `sent` audit.
- [ ] `POST /api/contracts/:id/void` (`contracts:write`) — assert status ≥ SENT, set `VOIDED`/`voided_at`/`voided_by`/`void_reason`, invalidate links (treat tokens as dead via status check), write `voided` audit.
- [ ] `POST /api/contracts/:id/resend/:signatoryId` (`contracts:write`) — re-send reminder email.
- [ ] `GET /api/contracts/:id/pdf` (`contracts:read`) — return a short-lived signed R2 URL for `signed_pdf_r2_key`; write `downloaded` audit.
**Acceptance:**
- [ ] Each route enforces its permission and module guard; unauthorized → 403, module-disabled → handled by guard.
- [ ] Sending transitions DRAFT→SENT, freezes content, and creates one token per signatory with a 30-day expiry.
- [ ] `DELETE` on a SENT/SIGNED/VOIDED contract returns 422.

### Task 9: Contract templates API routes (+ system template seeding)
**Blocks:** 14  ·  **Blocked by:** 3,4,5
**Files:**
- Create: `apps/zync-api/src/routes/contract-templates.ts`
- Create: `packages/contracts/src/seed-templates.ts`
- Modify: `apps/zync-api/src/index.ts` (mount under `/api/contract-templates`)
**Steps:**
- [ ] CRUD routes: `GET /api/contract-templates` (`contracts:read`), `POST` (`contracts:write`), `GET /:id` (`contracts:read`), `PATCH /:id` (`contracts:write`), `DELETE /:id` (`contracts:write`, soft-delete via `deleted_at`).
- [ ] Validate template `content` with `validateContractContent` (422 on bad node) and `variables` shape on write.
- [ ] `seedTenantContractTemplates(db, tenantId, userId)` — seed 3 starter templates (Service Agreement IL, NDA IL, Fixed-Price Project IL) on tenant creation; wire into the tenant-provisioning hook.
**Acceptance:**
- [ ] Template CRUD works; delete is soft (row retained, excluded by `deleted_at IS NULL` index).
- [ ] A newly provisioned tenant receives exactly the 3 system templates.

### Task 10: Public signing API routes
**Blocks:** 15  ·  **Blocked by:** 4,5,7
**Files:**
- Create: `apps/zync-api/src/routes/sign.ts`
- Modify: `apps/zync-api/src/index.ts` (mount under `/api/sign`, **outside** auth middleware)
**Steps:**
- [ ] **No session.** Apply `rateLimit` via `RATE_LIMITER_AUTH` — **10 requests/min per IP**.
- [ ] `GET /api/sign/:token` — look up signatory by plaintext token; if `token_expires_at < now()` return **410 Gone**; if contract `VOIDED` return 410; if this signatory's turn hasn't arrived (higher order) return payload flagged "waiting for {name}"; else record `viewed_at`, flip contract `SENT→VIEWED` (first view), write `viewed` audit, return `SignaturePagePayload` (sanitized contract HTML via `renderContractHTML`, tenant name/logo, title, expiry, signatory name/email).
- [ ] `POST /api/sign/:token` — validate `signSubmissionSchema`; enforce token validity/expiry/turn; record signature (`signature_data`, `signature_type`, `ip_address` from CF header, `user_agent`, `signed_at`); within the SAME transaction call `completeIfAllSigned`; write `signed` audit; respond with confirmation + timestamp.
- [ ] `POST /api/sign/:token/decline` — validate `declineSchema`; set `declined_at`/`decline_reason`; email tenant; write `declined` audit.
- [ ] Never leak whether a token is merely expired vs nonexistent beyond the 410/404 needed; capture IP from `CF-Connecting-IP`.
**Acceptance:**
- [ ] Expired token → 410 Gone; 11th request within a minute from one IP → 429.
- [ ] Submitting the final signature flips contract to SIGNED and runs completion in one transaction.
- [ ] Out-of-turn signatory sees a "waiting" payload and cannot submit.

### Task 11: Frontend — contracts list + data hooks
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/pages/contracts/ContractListPage.tsx`
- Create: `apps/zync-app/src/hooks/useContracts.ts` (`useContractList`, `useContract`, mutations: send/void/resend/delete)
- Modify: `apps/zync-app/src/router.tsx` (route `/contracts`)
**Steps:**
- [ ] `DataTable` columns: Title, Customer, Status badge (DRAFT/SENT/VIEWED/SIGNED/VOIDED), Signatories "X of Y signed", Created/Sent date, Actions (View, Resend, Void, Download PDF).
- [ ] Filters: status, date range, customer. React-query hooks wrap the API.
- [ ] Status badge colors via design tokens (no hardcoded colors); RTL-aware layout via `useDirection`.
**Acceptance:**
- [ ] List paginates and filters by status/customer/date; row actions invoke the correct endpoints and invalidate the query.

### Task 12: Frontend — contract detail page
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/pages/contracts/ContractDetailPage.tsx`
- Create: `apps/zync-app/src/pages/contracts/ContractAuditTab.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/contracts/:id`)
**Steps:**
- [ ] Left panel: read-only sanitized contract HTML (`renderContractHTML`, DOMPurify before `dangerouslySetInnerHTML`).
- [ ] Right panel: status timeline (created→sent→viewed→signed), signatory list (name/email/status + signed_at/"Waiting"/"Declined"), per-signatory resend button, audit log tab (chronological events + timestamps + IPs).
- [ ] Action bar by status: DRAFT → Edit/Send/Delete; SENT|VIEWED → Void/Resend All/View; SIGNED → Download PDF/Create Invoice from Contract; VOIDED → View (read-only).
**Acceptance:**
- [ ] Action bar shows only valid actions for the current status; preview HTML is sanitized; audit tab lists events with IPs.

### Task 13: Frontend — contract creation wizard
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/pages/contracts/ContractCreatePage.tsx`
- Create: `apps/zync-app/src/pages/contracts/ContractEditor.tsx` (wraps `@zync/ui` rich-editor)
- Create: `apps/zync-app/src/pages/contracts/SignatoryEditor.tsx`
- Modify: `apps/zync-app/src/router.tsx` (routes `/contracts/new`, `/contracts/:id/edit`)
**Steps:**
- [ ] Step 1 Source: "From Template" (template picker cards) or "Blank" (editor).
- [ ] Step 2 Content: template → variable fill form auto-populated from `?customer_id=` / `?lead_id=`; editor highlights `{{variables}}` (yellow background).
- [ ] Step 3 Signatories: add up to 3 (name+email), drag-to-reorder (sets `order`), "add self as signatory" option.
- [ ] Step 4 Preview: full sanitized HTML preview, Edit back-link, "Send Now" or "Save as Draft".
- [ ] Editor a11y/RTL: editor `role="textbox" aria-multiline="true" aria-label="Contract body editor"`; toolbar `role="toolbar" aria-label="Text formatting"` with `aria-pressed` toggles; `⌘B/⌘I/⌘U` not overridden; Tab enters / Escape exits editor; variable picker `role="listbox"` arrow/Enter/Escape; variable spans `aria-label="Variable: {key}"`. Configure Tiptap `Direction` with `defaultDirection` = `rtl` for `he-IL`, persisting `dir` on paragraph nodes.
**Acceptance:**
- [ ] Creating from template substitutes variables and saves DRAFT or sends; max 3 signatories enforced in UI; editor exposes required ARIA roles and respects RTL.

### Task 14: Frontend — templates management
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/pages/contracts/TemplateListPage.tsx`
- Create: `apps/zync-app/src/pages/contracts/TemplateEditorPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (routes `/contracts/templates`, `/contracts/templates/new`, `/contracts/templates/:id/edit`)
**Steps:**
- [ ] Templates list with name + preview; create/edit using the rich editor + variable declaration UI (`{ key, label, type, required }`).
- [ ] Variable insertion: toolbar button → picker of declared variables → inserts `{{key}}`.
- [ ] Template preview renders with sample values.
**Acceptance:**
- [ ] Templates can be created, edited (variables declared), previewed with sample values, and soft-deleted.

### Task 15: Frontend — public signing page (`/sign/:token`)
**Blocks:** 18  ·  **Blocked by:** 10
**Files:**
- Create: `apps/zync-app/src/pages/sign/SignPage.tsx`
- Create: `apps/zync-app/src/pages/sign/ContractSigningIsland.tsx`
- Create: `apps/zync-app/src/pages/sign/SignaturePad.tsx`
- Modify: `apps/zync-app/src/router.tsx` (public route `/sign/:token`, no auth guard)
**Steps:**
- [ ] Standalone layout (tenant logo, title, "Requested by {tenant} · Expires {date}"), scrollable sanitized contract HTML (DOMPurify before render).
- [ ] Signature capture with Draw / Type tabs: Draw = `signature_pad` on a 400×150 canvas with touch support + Clear; Type = name rendered in Dancing Script font, rasterized to canvas PNG on submit.
- [ ] Fields: Full name, Email, agreement checkbox. Client validates checkbox ticked + name filled + signature present before enabling Submit.
- [ ] Submit → `POST /api/sign/:token` with `{ signature_data, signature_type, name, email, agreed }`; on success show "Thank you! Your signature has been recorded." + timestamp.
- [ ] Decline → modal for reason → `POST /api/sign/:token/decline`.
- [ ] Handle 410 (expired/voided) and "waiting for {name}" states with clear messaging. Respect browser locale with fallback to tenant locale (Hebrew + English); honor `prefers-reduced-motion`.
**Acceptance:**
- [ ] Both draw and typed signatures produce a base64 PNG accepted by the API; Submit disabled until agreement+name+signature present.
- [ ] Expired token shows a Gone state; out-of-turn signatory shows the waiting message.

### Task 16: Integration hook — Contract → Invoice
**Blocks:** —  ·  **Blocked by:** 2,8,12
**Files:**
- Modify: `apps/zync-app/src/pages/contracts/ContractDetailPage.tsx` ("Create Invoice from Contract" on SIGNED)
- Modify: `apps/zync-api/src/routes/invoices.ts` (accept `?contract_id=` to pre-fill + set `invoices.contract_id`)
**Steps:**
- [ ] On a SIGNED contract, "Create Invoice from Contract" links to `/invoices/new?contract_id={id}`.
- [ ] Pre-fill customer, line-item description (contract title), amount (from `{{amount}}` variable if present); persist `invoices.contract_id` on the created invoice.
**Acceptance:**
- [ ] Creating an invoice from a signed contract sets `invoices.contract_id` and pre-fills customer + line item.

### Task 17: Integration hook — Lead → Contract
**Blocks:** —  ·  **Blocked by:** 8,13
**Files:**
- Modify: `apps/zync-app/src/pages/leads/LeadDetailPage.tsx` ("Create Contract" when stage = WON)
- Modify: `apps/zync-api/src/routes/contracts.ts` (on create with `lead_id`, log lead activity; on `contract.signed`, log + optional stage advance)
**Steps:**
- [ ] "Create Contract" on a WON lead → `/contracts/new?lead_id={id}&customer_id={customerId}`, auto-filling name/email/company/date.
- [ ] After creation: append `lead_activities` "Contract created" (`type='contract_linked'`); set `leads.contract_id` (column already exists).
- [ ] On `contract.signed`: append "Contract signed" activity; optionally auto-advance `leads.stage` (configurable).
**Acceptance:**
- [ ] Creating a contract from a WON lead links it (`leads.contract_id`) and logs the activity; signing logs a second activity.

### Task 18: End-to-end & security tests
**Blocks:** —  ·  **Blocked by:** 10,15
**Files:**
- Create: `apps/zync-api/test/contracts.e2e.test.ts`
- Create: `apps/zync-api/test/sign-public.e2e.test.ts`
**Steps:**
- [ ] Full lifecycle: create DRAFT → send → view → sign (all signatories) → assert SIGNED, R2 key set, emails sent, audit rows present.
- [ ] Security: token brute-force is rate-limited (429 after 10/min); expired token → 410; content with a disallowed Tiptap node → 422; sanitized HTML strips `onerror`/script.
- [ ] State machine: DELETE non-DRAFT → 422; void of DRAFT rejected; out-of-turn sign blocked.
- [ ] Permissions: VIEWER cannot write/delete; CONTRACTOR has no access.
**Acceptance:**
- [ ] All lifecycle, security, state-machine, and permission tests pass against a Neon test branch.
