# CRM Settings — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-crm.md  ·  **Slug:** settings-crm  ·  **Wave:** 15
**Depends on:** lead-qualification-scoring, marketing-leads-pipeline, revenue-forecasting

## Goal
Deliver the full `/settings/crm` page that consolidates three per-tenant CRM configuration areas into one form: **Lead Scoring** (criteria weights + enable toggle), **Pipeline Stages** (Business+, stage label/win-probability/order with terminal-stage protection), and **Lost Reasons** (the dropdown options shown when marking a lead lost). All three areas persist to columns on the upstream `tenant_settings` table via a single GET/PATCH API pair. This page is the missing configuration surface referenced — but not specified — by spec 118 (lead scoring) and spec 116 (revenue forecast stage probabilities).

## Architecture
- **Storage:** All state lives on the upstream `tenant_settings` table (owned outside this chain). This spec ALTERs it to add two columns: `pipeline_stages JSONB DEFAULT NULL` and `lead_scoring_enabled BOOLEAN NOT NULL DEFAULT true`. It REFERENCES (does not redefine) three columns added by deps: `lead_scoring_criteria JSONB` (spec 118 / lead-qualification-scoring), `lead_lost_reasons JSONB` (spec 111 / lead-lost-re-engagement), and `lead_stage_probabilities JSONB` (spec 116 / revenue-forecasting).
- **Consumers drive the field shapes (critical):**
  - The Lead Scoring section edits `lead_scoring_criteria`. Spec 118's nightly scoring cron reads the EXACT keys of that column. Therefore the editable weight fields MUST be 118's keys (`estimated_value_set, high_value, stage_advancement, recent_activity, inbound_source, company_present, phone_present, proposal_viewed`) plus the non-weight `high_value_threshold`. The BANT labels (Budget/Authority/Need/Timeline) in spec 164's ASCII mockup are an illustrative sketch only — encoding them would break 118's cron. See Task 4 decision note.
  - `lead_scoring_enabled = false` ⇒ spec 118's `lead-score-refresh` cron skips this tenant and `leads.score` reads as `NULL`/stale (we do not delete `lead_scoring_criteria`, preserving weights for re-enable).
  - `pipeline_stages[].win_probability` is the per-tenant source spec 116's revenue forecast should weight projected pipeline value by. This plan owns `pipeline_stages`; it does not redesign 116.
- **Data flow:** React settings page (apps/zync-app) → `useCrmSettings` query hook → `GET /api/settings/crm` (Hono, apps/zync-api). Save → `PATCH /api/settings/crm` with a partial body; server validates and writes. Tier gating for the Pipeline Stages section uses upstream `useTierGate`/`meetsMinimumTier`/`requireTier`.
- **Upstream tables/exports consumed:** `tenant_settings`, `leads` (to check stage occupancy before deleting a pipeline stage), `tenants` (tier), permissions `settings:read`/`settings:write`, `authMiddleware`, `requirePermission`, `requireTier`, `tenantQuery`, `useTierGate`, `meetsMinimumTier`, `Card`, `Switch`, `Input`, `Button`, `Alert`, `Form`, `FormField`, `FormLabel`, `FormError`, `toast`, `useDirection`.

## Tech Stack
- **apps/zync-api** — Hono routes on Cloudflare Workers; Drizzle ORM over Neon Postgres via Hyperdrive; Zod validation; `@zync/db`, `@zync/auth`, `@zync/types`.
- **apps/zync-app** — Vite + React; TanStack Query; `@zync/ui` primitives; `@zync/config` for tier gating; i18n via `@zync/types` translations + `useDirection` for RTL/Hebrew.
- **packages/db** — Drizzle schema (`tenantSettings` table object gets two new columns) + migration SQL.
- No new Cloudflare bindings. No new tables.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | packages/db migration + schema | No (blocks all) |
| B — server | 2, 3 | apps/zync-api routes, validators, serializers | After A; 2→3 sequential |
| C — client data | 4 | apps/zync-app query/mutation hook + i18n strings | After B |
| D — UI sections | 5, 6, 7 | apps/zync-app `/settings/crm` page + section components | After C; 5/6/7 parallel |
| E — assembly | 8 | apps/zync-app page wiring, route registration | After D |

## Tasks

### Task 1: Schema delta — add CRM settings columns to `tenant_settings`
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_settings_crm.sql`
- Modify: `packages/db/src/schema/tenant-settings.ts` (Drizzle table object — add two columns)
**Steps:**
- [ ] Write the migration adding the two new columns, idempotent per the spec (`IF NOT EXISTS`).
- [ ] Do NOT (re)declare `lead_scoring_criteria`, `lead_lost_reasons`, or `lead_stage_probabilities` — they are owned by deps 118/111/116; reference them by name only.
- [ ] Add the matching Drizzle column definitions to the existing `tenantSettings` table object: `pipelineStages: jsonb('pipeline_stages')` (nullable) and `leadScoringEnabled: boolean('lead_scoring_enabled').notNull().default(true)`.
- [ ] Run `pnpm --filter @zync/db migrate` against the dev branch to confirm it applies cleanly.
**Schema / Interfaces:**
```sql
-- lead_scoring_criteria already added by spec 118 (lead-qualification-scoring)
-- lead_lost_reasons already added by spec 111 (lead-lost-re-engagement)
-- lead_stage_probabilities already added by spec 116 (revenue-forecasting)
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS pipeline_stages JSONB DEFAULT NULL,
  ADD COLUMN IF NOT EXISTS lead_scoring_enabled BOOLEAN NOT NULL DEFAULT true;
```
`pipeline_stages = NULL` means "use the system default stage array" (see Task 6). Lazily populated on first custom save.
**Acceptance:**
- [ ] Migration applies with no error on a branch that already has the three dep columns present.
- [ ] `tenantSettings` Drizzle object exports `pipelineStages` and `leadScoringEnabled`; `tsc` passes.

### Task 2: `GET /api/settings/crm` — read CRM settings
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/settings/crm.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount the router)
**Steps:**
- [ ] Define a Hono router; apply `authMiddleware` then `requirePermission('settings:read')`.
- [ ] Load the tenant's settings row via `tenantQuery` (never raw Drizzle from the route — obey `no-raw-drizzle-from-routes`).
- [ ] Return the four CRM fields. For `pipeline_stages`, if the column is `NULL`, return the system default array (Task 6 `DEFAULT_PIPELINE_STAGES`) so the client always has stages to render. For `lead_lost_reasons`, if `NULL`/empty return the default reasons array.
- [ ] Serialize through a `serializeCrmSettings(row)` helper (no raw row leakage).
**Schema / Interfaces:**
```ts
// GET /api/settings/crm  → 200
interface CrmSettingsResponse {
  lead_scoring_enabled: boolean
  lead_scoring_criteria: {
    estimated_value_set: number
    high_value: number
    stage_advancement: number
    recent_activity: number
    inbound_source: number
    company_present: number
    phone_present: number
    proposal_viewed: number
    high_value_threshold: number
  }
  pipeline_stages: PipelineStage[]   // resolved (defaults applied if column NULL)
  lead_lost_reasons: string[]        // resolved (defaults applied if NULL/empty)
}
interface PipelineStage {
  id: string
  label: string
  win_probability: number  // 0..100
  position: number         // 1-based
  terminal: boolean
}
```
**Acceptance:**
- [ ] Authenticated user with `settings:read` gets all four fields; missing permission → 403.
- [ ] When `pipeline_stages` column is `NULL`, response contains the 6 default stages.

### Task 3: `PATCH /api/settings/crm` — update CRM settings with full validation
**Blocks:** 4  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/settings/crm.ts`
- Create: `apps/zync-api/src/routes/settings/crm.validators.ts`
**Steps:**
- [ ] Add `PATCH` handler: `authMiddleware` → `requirePermission('settings:write')` → Zod body parse (`require-zod-validation-in-routes`).
- [ ] Body is a partial: any subset of `{ lead_scoring_enabled?, lead_scoring_criteria?, pipeline_stages?, lead_lost_reasons? }`.
- [ ] Validation — `lead_scoring_criteria`: the eight weight keys (all except `high_value_threshold`) must sum to exactly 100; reject 422 otherwise. `high_value_threshold` is excluded from the sum and must be a non-negative integer.
- [ ] Validation — `pipeline_stages`: at least 2 stages; exactly the stages flagged `terminal: true` in the prior/default set must still be present (terminal stages are not removable); `win_probability` each in 0..100; `position` values form a contiguous 1..N ordering; `id` and `label` non-empty and `id` unique.
- [ ] Validation — when deleting a non-terminal stage (a stage `id` present in stored stages but absent from the body), reject 409 if any `leads` row in this tenant currently has `stage = <that id mapped to leads.stage value>`. Use `tenantQuery` to count occupancy.
- [ ] Validation — `lead_lost_reasons`: at least 1 reason; each non-empty trimmed string; de-duplicate.
- [ ] Persist only the provided fields via `tenantQuery` update; return the full resolved settings (reuse `serializeCrmSettings`).
- [ ] Note: writing `lead_scoring_enabled = false` does NOT clear `lead_scoring_criteria`; the cron-skip behavior is owned by spec 118 reading this flag.
**Schema / Interfaces:**
```ts
// crm.validators.ts
import { z } from 'zod'

export const pipelineStageSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  win_probability: z.number().int().min(0).max(100),
  position: z.number().int().min(1),
  terminal: z.boolean(),
})

export const leadScoringCriteriaSchema = z.object({
  estimated_value_set: z.number().int().min(0).max(100),
  high_value: z.number().int().min(0).max(100),
  stage_advancement: z.number().int().min(0).max(100),
  recent_activity: z.number().int().min(0).max(100),
  inbound_source: z.number().int().min(0).max(100),
  company_present: z.number().int().min(0).max(100),
  phone_present: z.number().int().min(0).max(100),
  proposal_viewed: z.number().int().min(0).max(100),
  high_value_threshold: z.number().int().min(0),
}).refine((c) =>
  c.estimated_value_set + c.high_value + c.stage_advancement +
  c.recent_activity + c.inbound_source + c.company_present +
  c.phone_present + c.proposal_viewed === 100,
  { message: 'Scoring weights must sum to 100' })

export const updateCrmSettingsSchema = z.object({
  lead_scoring_enabled: z.boolean().optional(),
  lead_scoring_criteria: leadScoringCriteriaSchema.optional(),
  pipeline_stages: z.array(pipelineStageSchema).min(2).optional(),
  lead_lost_reasons: z.array(z.string().trim().min(1)).min(1).optional(),
})
export type UpdateCrmSettings = z.infer<typeof updateCrmSettingsSchema>
```
**Acceptance:**
- [ ] Weights not summing to 100 → 422 with the validation message.
- [ ] Removing a terminal stage → 422; removing a non-terminal stage that has leads → 409; removing an empty non-terminal stage → 200.
- [ ] Empty `lead_lost_reasons` array → 422.
- [ ] Missing `settings:write` → 403.

### Task 4: Client data hook + i18n strings
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/features/settings/crm/useCrmSettings.ts`
- Modify: `packages/types/src/i18n/en.ts`, `packages/types/src/i18n/he.ts` (settings.crm namespace)
**Steps:**
- [ ] `useCrmSettings()` — TanStack Query reading `GET /api/settings/crm`, typed `CrmSettingsResponse`.
- [ ] `useUpdateCrmSettings()` — mutation calling `PATCH /api/settings/crm` with a partial `UpdateCrmSettings` body; on success invalidate the query and `toast` success; on 422/409 surface the server message inline.
- [ ] Add English + Hebrew strings for every label in the three sections (section titles, weight field labels, threshold, stage table headers, add/remove buttons, lost-reason editor, save button, validation messages). Document decision note (below) as a code comment in the hook file.
- [ ] **Decision note (transcribe verbatim into code comment):** "The Lead Scoring editable fields are spec 118's `lead_scoring_criteria` keys, NOT the BANT labels in spec 164's ASCII mockup. Spec 118's nightly `lead-score-refresh` cron reads these exact keys; using BANT keys would break it. The BANT mockup is illustrative only."
**Schema / Interfaces:**
```ts
export function useCrmSettings(): UseQueryResult<CrmSettingsResponse>
export function useUpdateCrmSettings(): UseMutationResult<CrmSettingsResponse, ApiError, UpdateCrmSettings>
```
**Acceptance:**
- [ ] Hook returns typed data; mutation invalidates the query on success.
- [ ] No untranslated raw strings; Hebrew namespace has parity with English.

### Task 5: Lead Scoring section component
**Blocks:** 8  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/crm/LeadScoringSection.tsx`
**Steps:**
- [ ] Render a `Card` titled "Lead Scoring" with a `Switch` bound to `lead_scoring_enabled` (on = BANT/criteria active, off = Disabled). Show helper text: when disabled, scoring stops and scores show as empty.
- [ ] Render numeric `Input` fields for the eight weight keys + the `high_value_threshold` field, each with an `FormLabel` (use translated labels).
- [ ] Client-side: live sum of the eight weights with an inline `Alert`/`FormError` when ≠ 100 ("Weights must sum to 100"); disable Save (lift validity to parent) while invalid. `high_value_threshold` excluded from the sum.
- [ ] Provide a "Reset to defaults" action restoring spec 118's default criteria object.
- [ ] Inputs labeled and keyboard-operable; honor `useDirection()` for RTL so the ₪ threshold and numeric fields lay out correctly in Hebrew.
**Schema / Interfaces:**
```ts
const DEFAULT_LEAD_SCORING_CRITERIA = {
  estimated_value_set: 10, high_value: 20, stage_advancement: 15,
  recent_activity: 20, inbound_source: 10, company_present: 5,
  phone_present: 5, proposal_viewed: 15, high_value_threshold: 20000,
} as const
// sum of the 8 weights = 100
```
**Acceptance:**
- [ ] Editing weights so they don't sum to 100 shows the inline error and blocks save.
- [ ] Toggling the switch off renders the "scoring disabled" helper; weights remain editable and preserved.

### Task 6: Pipeline Stages section component (Business+ gated)
**Blocks:** 8  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/crm/PipelineStagesSection.tsx`
- Create: `apps/zync-app/src/features/settings/crm/pipelineDefaults.ts`
**Steps:**
- [ ] Gate with `useTierGate`/`meetsMinimumTier`: Business+ tenants get the editable table; lower tiers see an upsell-only panel (use the upstream upgrade pattern, not a custom one).
- [ ] Render a table: columns Stage label (`Input`), Win probability % (`Input`, 0..100), Position (with `↑`/`↓` reorder buttons), and a remove `✕` button.
- [ ] Reorder controls are real keyboard-operable `<button>`s (Up/Down), not drag-only — moving a row swaps `position` values; recompute contiguous 1..N positions on each change.
- [ ] Terminal stages (`terminal: true`, i.e. Closed Won / Closed Lost) render "—" instead of remove/reorder controls and cannot be deleted client-side.
- [ ] "+ Add stage" appends a new non-terminal stage with a generated `id`, blank label, default `win_probability: 0`, next position.
- [ ] Deleting a non-terminal stage is optimistic; the server enforces the leads-occupancy 409 (Task 3) — surface that error and revert on failure.
- [ ] If incoming `pipeline_stages` is the resolved default set, edits begin from `DEFAULT_PIPELINE_STAGES`.
**Schema / Interfaces:**
```ts
// pipelineDefaults.ts — system default when tenant_settings.pipeline_stages IS NULL
export const DEFAULT_PIPELINE_STAGES = [
  { id: 'new',         label: 'New Lead',      win_probability: 5,   position: 1, terminal: false },
  { id: 'qualified',   label: 'Qualified',     win_probability: 20,  position: 2, terminal: false },
  { id: 'proposal',    label: 'Proposal Sent', win_probability: 40,  position: 3, terminal: false },
  { id: 'negotiation', label: 'Negotiation',   win_probability: 70,  position: 4, terminal: false },
  { id: 'won',         label: 'Closed Won',    win_probability: 100, position: 5, terminal: true  },
  { id: 'lost',        label: 'Closed Lost',   win_probability: 0,   position: 6, terminal: true  },
] as const
```
**Acceptance:**
- [ ] Non-Business+ tenant sees upsell, not the editor.
- [ ] Terminal stages cannot be removed/reordered (no controls rendered for them).
- [ ] `↑`/`↓` buttons reorder rows via keyboard (Tab to focus, Enter/Space to activate) and renumber positions.
- [ ] Server 409 on deleting an occupied stage is shown and the row reverts.

### Task 7: Lost Reasons section component
**Blocks:** 8  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/crm/LostReasonsSection.tsx`
**Steps:**
- [ ] Render a `Card` titled "Lead Statuses" listing each lost reason as an editable `Input` row with a `✕` remove button.
- [ ] "+ Add reason" appends a blank reason row.
- [ ] Client guard: block removing the last remaining reason (server also enforces ≥1).
- [ ] Default reasons pre-populate when the resolved list is empty: `['Price too high','No budget','Chose competitor','No response','Project cancelled']`.
- [ ] Rows labeled and keyboard-operable; RTL-aware via `useDirection()`.
**Acceptance:**
- [ ] Cannot delete the final reason (button disabled + server 422 fallback).
- [ ] Adding/renaming/removing reasons round-trips through PATCH and persists.

### Task 8: `/settings/crm` page assembly + route registration
**Blocks:** —  ·  **Blocked by:** 5, 6, 7
**Files:**
- Create: `apps/zync-app/src/features/settings/crm/CrmSettingsPage.tsx`
- Modify: `apps/zync-app/src/routes.tsx` (register `/settings/crm`, guarded by `settings:write`)
**Steps:**
- [ ] Compose the page: Settings breadcrumb ("Settings > CRM"), then `LeadScoringSection`, `PipelineStagesSection`, `LostReasonsSection`, and a single "Save changes" button.
- [ ] Use `useCrmSettings` for initial data and `useUpdateCrmSettings` for the save; assemble a partial body containing only changed sections.
- [ ] Disable "Save changes" while any section is client-invalid (weights ≠ 100, <2 pipeline stages, <1 lost reason).
- [ ] Register the route under the app shell settings area; require `settings:write` (route guard) so unauthorized users cannot open it.
- [ ] Page heading uses a single `<h1>`/section `<h2>` hierarchy; ensure the form is fully keyboard navigable and RTL-correct in Hebrew.
**Acceptance:**
- [ ] Navigating to `/settings/crm` without `settings:write` is blocked.
- [ ] A full edit across all three sections saves in one PATCH and reflects after refetch.
- [ ] `pnpm -w typecheck` and `pnpm -w lint` pass for the touched packages.
