# Lead Qualification Scoring — Implementation Plan

**Spec:** docs/specs/2026-05-31-lead-qualification-scoring.md  ·  **Slug:** lead-qualification-scoring  ·  **Wave:** 14
**Depends on:** foundation-auth-rbac, leads-detail-view, marketing-leads-pipeline

## Goal
Add an automatic 0–100 lead score derived from BANT-inspired, rule-based criteria and activity signals. The score is stored on each lead (not computed at query time), recalculated asynchronously when relevant lead facts change and nightly via cron, and surfaced on the Kanban card, the lead detail view, and the leads list (sort/filter/column). Scoring weights and the high-value threshold are tenant-configurable via `/settings/crm`. This makes pipeline prioritization fast and transparent without an ML model.

## Architecture
A pure, deterministic scoring function (`computeLeadScore`) takes a lead row plus three derived inputs — the most recent `lead_activities.created_at`, whether a linked `proposals` row has `status = 'VIEWED'`, and the tenant's scoring criteria — and returns `{ score, breakdown }`. The function is the single source of truth, shared by the synchronous recalculation endpoint, the async queue consumer, and the nightly cron sweep.

Storage:
- `leads` gains `score INTEGER` and `score_updated_at TIMESTAMPTZ` (Schema Delta in spec).
- Per-tenant weights + threshold live in the `tenant_settings.lead_scoring_criteria JSONB` column (per the spec's Schema Delta, L138). The `tenant_settings` base table (one row per tenant, `tenant_id` UNIQUE) is owned by `foundation-auth-rbac`; this plan only `ALTER ... ADD COLUMN IF NOT EXISTS lead_scoring_criteria` and carries the verbatim DEFAULT JSONB. `settings-crm` (spec 168) edits the same column via its own route — both agree on the column store.

Recalculation is event-driven. Existing handlers in `marketing-leads-pipeline` (stage change, activity create, estimated_value/company/phone update) and in `marketing-catalogs-campaigns` (proposal status → VIEWED) enqueue a `lead.score_recalc` message onto the shared `QUEUE` binding. A queue consumer recomputes and writes `leads.score` + `leads.score_updated_at` — non-blocking, so it never adds latency to the triggering action. A new `lead-score-refresh` cron enqueues recalc for every active lead whose `score_updated_at < now() - interval '24 hours'`.

Upstream consumed (exact names):
- Tables: `leads` (cols `tenant_id, stage, source, company, phone, estimated_value, score, score_updated_at`), `lead_activities` (`lead_id, created_at`), `proposals` (`lead_id, status`), `tenants`, `users`.
- Auth/RBAC: `authMiddleware`, `requirePermission` (`marketing:read`, `marketing:write`), `requireTier` / `meetsMinimumTier` (Business+ gate), `tenantQuery`, `buildPaginated`.
- Bindings: `QUEUE` (async dispatch), `DB`/`Db` (Drizzle over Hyperdrive).
- The lead serializer and `GET /api/leads` list handler are **established by marketing-leads-pipeline** (the spec did not name an exported serializer symbol); this plan extends them in place rather than asserting a new function name.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New routes under the existing leads router; new queue consumer registered on `QUEUE`; new scheduled (cron) handler. Drizzle ORM. Zod validation (`require-zod-validation-in-routes`). All DB access via `tenantQuery` (`no-raw-drizzle-from-routes`).
- **Shared logic:** pure scoring function + types in `packages/marketing` (the marketing domain package established by marketing-leads-pipeline) so API, consumer, and cron share one implementation. If that package path differs at build time, co-locate under the leads feature module established by marketing-leads-pipeline.
- **App UI:** `apps/zync-app` (Vite + React). Lead-card badge, detail-view score panel, list sort/filter/column, `/settings/crm` Lead Scoring config form. Uses `@zync/ui` (`Badge`, `Progress`, `Button`, `Card`, `Form`, `Input`, `Switch`, `useDirection`). React Query hooks.
- **Config:** `wrangler.toml` — add `lead-score-refresh` cron trigger; reuse existing `QUEUE` queue (new message type, no new binding).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 14a — schema | 1 | `packages/db` migrations + Drizzle schema | No (blocks all) |
| 14b — core logic | 2, 3 | `packages/marketing` scoring fn + settings accessors | After 1; 2 & 3 parallel |
| 14c — API | 4, 5, 6 | `apps/zync-api` leads routes | After 2,3; routes parallel |
| 14d — async | 7, 8 | `apps/zync-api` queue consumer + cron + trigger wiring | After 4 (uses recalc core) |
| 14e — UI | 9, 10, 11, 12 | `apps/zync-app` card, detail, list, settings | After 4,5,6; UI tasks parallel |

## Tasks

### Task 1: Schema delta — score columns, partial index, scoring-settings table
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_lead_qualification_scoring.sql`
- Modify: `packages/db/src/schema/leads.ts` (add columns to existing `leads` Drizzle table)
- Modify: `packages/db/src/schema/tenant-settings.ts` (add the `lead_scoring_criteria` column to the existing `tenant_settings` Drizzle table owned by `foundation-auth-rbac`)
**Steps:**
- [ ] Add `score` and `score_updated_at` columns to the existing `leads` table via ALTER.
- [ ] Create the partial index `idx_leads_score` on `(tenant_id, score DESC)` filtered to active stages.
- [ ] `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS lead_scoring_criteria` with the spec's verbatim DEFAULT JSONB (base table owned by `foundation-auth-rbac`; never re-create it here).
- [ ] Mirror the `leads` columns and the new `tenant_settings` column in the Drizzle schema (`integer`, `timestamp` with `withTimezone: true`, `jsonb`).
**Schema / Interfaces:**
```sql
ALTER TABLE leads ADD COLUMN score INTEGER NOT NULL DEFAULT 0
  CHECK (score >= 0 AND score <= 100);
ALTER TABLE leads ADD COLUMN score_updated_at TIMESTAMPTZ;

CREATE INDEX idx_leads_score ON leads(tenant_id, score DESC)
  WHERE stage NOT IN ('LOST', 'WON');

-- Weights live on the foundation-owned tenant_settings table (one row per tenant,
-- seeded at signup). This plan only adds its column idempotently.
ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS lead_scoring_criteria JSONB NOT NULL DEFAULT
  '{"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}'::jsonb;
```
```ts
// Criteria shape carried in tenant_settings.lead_scoring_criteria
export interface LeadScoringCriteria {
  estimated_value_set: number;   // default 10
  high_value: number;            // default 20
  stage_advancement: number;     // default 15
  recent_activity: number;       // default 20
  inbound_source: number;        // default 10
  company_present: number;       // default 5
  phone_present: number;         // default 5
  proposal_viewed: number;       // default 15
  high_value_threshold: number;  // default 20000 (₪)
}
export const DEFAULT_LEAD_SCORING_CRITERIA: LeadScoringCriteria = {
  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,
};
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon Postgres; `leads.score` rejects values outside 0–100.
- [ ] `idx_leads_score` exists and excludes `WON`/`LOST` rows.
- [ ] `tenant_settings.lead_scoring_criteria` exists with the exact default JSONB; the base table's UNIQUE(tenant_id) is unchanged.

### Task 2: Pure scoring function `computeLeadScore`
**Blocks:** 4, 5, 7, 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/marketing/src/scoring/compute-lead-score.ts`
- Modify: `packages/marketing/src/index.ts` (export `computeLeadScore`, `LeadScoreBreakdownItem`, `LeadScoreResult`)
**Steps:**
- [ ] Implement a pure function: no DB calls, takes pre-fetched inputs, returns score + breakdown.
- [ ] Apply each criterion per the spec's scoring table; sum and cap at 100.
- [ ] Estimated-value range: full `high_value` points at/above threshold, else proportional (`round(high_value * estimated_value / threshold)`), or 0 if value is null. This is an independent, additive criterion — never floor it at `estimated_value_set`; the two are separate breakdown rows.
- [ ] Stage advancement: CONTACTED→⅓, QUALIFIED→⅔, PROPOSAL→full of `stage_advancement` (5/10/15 at defaults); NEW/WON/LOST→0.
- [ ] Recent activity: last 7d→full; 8–30d→half; 31–90d→quarter (20/10/5 at defaults); none/>90d→0.
- [ ] Inbound source: `source IN ('form','zapier')` → `inbound_source` points.
- [ ] Emit a breakdown entry for every criterion (including misses, points 0) so the UI can render ✓/✗ rows.
**Schema / Interfaces:**
```ts
export interface LeadScoreInput {
  stage: string;                       // 'NEW'|'CONTACTED'|'QUALIFIED'|'PROPOSAL'|'WON'|'LOST'
  source: string;
  company: string | null;
  phone: string | null;
  estimatedValue: number | null;       // leads.estimated_value
  lastActivityAt: Date | null;         // MAX(lead_activities.created_at)
  hasViewedProposal: boolean;          // EXISTS proposal lead_id match with status='VIEWED'
  criteria: LeadScoringCriteria;
  now?: Date;                          // injectable clock for cron/tests
}
export interface LeadScoreBreakdownItem {
  criterion: string;                   // stable key, e.g. 'estimated_value_set'
  label: string;                       // human label for UI, e.g. 'Estimated value set'
  points: number;                      // awarded (0 if criterion not met)
  max: number;                         // configured max for this criterion
  met: boolean;
}
export interface LeadScoreResult {
  score: number;                       // 0..100 (capped)
  breakdown: LeadScoreBreakdownItem[];
}
export function computeLeadScore(input: LeadScoreInput): LeadScoreResult;
```
**Acceptance:**
- [ ] A lead at PROPOSAL with value ≥ threshold, viewed proposal, activity <7d, no phone/company → score 80 at defaults (10+20+15+15+20).
- [ ] Final score never exceeds 100 even if configured weights sum higher.
- [ ] Function is referentially transparent: same inputs → same output, no I/O.

### Task 3: Tenant scoring-settings accessors `getLeadScoringCriteria` / `upsertLeadScoringCriteria`
**Blocks:** 4, 5, 6, 7, 8, 12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/marketing/src/scoring/scoring-settings.ts`
- Modify: `packages/marketing/src/index.ts` (export accessors + `leadScoringCriteriaSchema`)
**Steps:**
- [ ] `getLeadScoringCriteria(db, tenantId)` — read `tenant_settings.lead_scoring_criteria` for the tenant via `tenantQuery` (a domain helper, NOT the AI-config `getTenantSettings`); if the row/column is null, return `DEFAULT_LEAD_SCORING_CRITERIA`.
- [ ] `upsertLeadScoringCriteria(db, tenantId, criteria)` — `UPDATE tenant_settings SET lead_scoring_criteria = :criteria, updated_at = now() WHERE tenant_id = :tenantId` via `tenantQuery` (the base row is seeded at signup); never write through `getTenantSettings`/`upsertTenantSettings` (AI-config table).
- [ ] Define a Zod schema validating every weight as a non-negative integer and `high_value_threshold` as a positive number; reject unknown keys.
**Schema / Interfaces:**
```ts
export const leadScoringCriteriaSchema: z.ZodType<LeadScoringCriteria>;
export function getLeadScoringCriteria(db: Db, tenantId: string): Promise<LeadScoringCriteria>;
export function upsertLeadScoringCriteria(db: Db, tenantId: string, criteria: LeadScoringCriteria): Promise<LeadScoringCriteria>;
```
**Acceptance:**
- [ ] Reading a tenant with no settings row returns the spec defaults without inserting.
- [ ] Upsert is idempotent and bumps `updated_at`.
- [ ] Zod rejects negative weights and non-positive thresholds.

### Task 4: Recalc core + `POST /api/leads/:id/score` (sync recalculate)
**Blocks:** 7, 8, 10  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/leads/recalculate-score.ts`
- Create: `apps/zync-api/src/services/leads/recalculate-lead-score.ts`
- Modify: `apps/zync-api/src/routes/leads/index.ts` (mount route)
**Steps:**
- [ ] Implement `recalculateLeadScore(db, tenantId, leadId)`: load the lead (tenant-scoped), `MAX(lead_activities.created_at)` for the lead, and `EXISTS` a `proposals` row with matching `lead_id` and `status = 'VIEWED'`; load criteria via `getLeadScoringCriteria`; call `computeLeadScore`; UPDATE `leads.score` + `leads.score_updated_at = now()`; return `LeadScoreResult`. This service is reused by the queue consumer and cron.
- [ ] Route: `authMiddleware` → `requirePermission('marketing:write')` → `requireTier`/`meetsMinimumTier` Business+ gate → call service → return `{ score, breakdown }`.
- [ ] 404 if lead not found in tenant; never leak cross-tenant leads.
**Schema / Interfaces:**
```ts
// POST /api/leads/:id/score  (Requires: marketing:write, Business+)
// Response: { score: number, breakdown: { criterion, points }[] }
export async function recalculateLeadScore(
  db: Db, tenantId: string, leadId: string,
): Promise<LeadScoreResult>;
```
**Acceptance:**
- [ ] POST returns the freshly computed score and full breakdown and persists `score` + `score_updated_at`.
- [ ] A `marketing:read`-only user gets 403; a non-Business tenant gets the tier-gate response.
- [ ] Querying a lead from another tenant returns 404.

### Task 5: `GET /api/leads/:id/score` (score + breakdown, read-only)
**Blocks:** 10  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/leads/get-score.ts`
- Modify: `apps/zync-api/src/routes/leads/index.ts` (mount route)
**Steps:**
- [ ] `authMiddleware` → `requirePermission('marketing:read')` → Business+ tier gate.
- [ ] Recompute the breakdown from current data (same inputs as Task 4) so ✓/✗ rows reflect live facts; return the stored `leads.score` if it matches, else the recomputed score (read endpoint must not write).
- [ ] Return `score`, `score_updated_at`, and `breakdown`.
**Schema / Interfaces:**
```ts
// GET /api/leads/:id/score  (Requires: marketing:read, Business+)
// Response: { score: number, score_updated_at: string | null, breakdown: LeadScoreBreakdownItem[] }
```
**Acceptance:**
- [ ] Returns score + per-criterion breakdown without mutating the row.
- [ ] 403 for users lacking `marketing:read`; tier-gate for non-Business.

### Task 6: Extend `GET /api/leads` list — score fields, `sort_by=score`, `min_score`
**Blocks:** 11  ·  **Blocked by:** 3
**Files:**
- Modify: the leads list route and lead serializer established by `marketing-leads-pipeline` (`apps/zync-api/src/routes/leads/list.ts` and its serializer)
**Steps:**
- [ ] Add `score` and `score_updated_at` to the serialized lead object returned by the list (and detail) handlers.
- [ ] Extend the list query schema with optional `sort_by: 'score'` and `min_score: number` (Zod; `min_score` clamped 0–100).
- [ ] When `sort_by='score'`, order by `score DESC` (uses `idx_leads_score`); preserve existing cursor pagination via `buildPaginated` and the 100-row max invariant.
- [ ] When `min_score` set, add `WHERE score >= :min_score` (drives the "Hot leads only" filter at min_score=70).
- [ ] Keep `requirePermission('marketing:read')`.
**Schema / Interfaces:**
```ts
// GET /api/leads  query (additive): { sort_by?: 'score'; min_score?: number }
// Serialized lead gains: score: number; score_updated_at: string | null
```
**Acceptance:**
- [ ] `?sort_by=score` returns leads high→low; `?min_score=70` returns only hot leads.
- [ ] List response includes `score` and `score_updated_at`; pagination still works.
- [ ] Combined `sort_by=score&min_score=70` behaves correctly.

### Task 7: Async recalc — `QUEUE` message type + consumer
**Blocks:** 8, 9, 10, 11  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/queue/lead-score-recalc.ts` (consumer handler + `enqueueLeadScoreRecalc` helper)
- Modify: `apps/zync-api/src/queue/index.ts` (route `lead.score_recalc` messages to the handler)
**Steps:**
- [ ] Define the message envelope `{ type: 'lead.score_recalc', tenantId, leadId }` on the shared `QUEUE` binding (no new binding).
- [ ] `enqueueLeadScoreRecalc(env, tenantId, leadId)` — fire-and-forget send to `QUEUE`.
- [ ] Consumer: for each message call `recalculateLeadScore` (Task 4 service); ack on success; rely on Queues retry/backoff on failure (follow the `webhook.deliver` consumer pattern).
- [ ] Dedupe-safe: recompute is idempotent, so duplicate messages are harmless.
**Schema / Interfaces:**
```ts
export interface LeadScoreRecalcMessage { type: 'lead.score_recalc'; tenantId: string; leadId: string; }
export function enqueueLeadScoreRecalc(env: Env, tenantId: string, leadId: string): Promise<void>;
```
**Acceptance:**
- [ ] Enqueuing a message results in `leads.score`/`score_updated_at` being updated by the consumer.
- [ ] A failing recalc is retried per Queues backoff, not silently dropped.

### Task 8: Recalc trigger wiring + nightly `lead-score-refresh` cron
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Modify: `apps/zync-api/src/routes/leads/*` stage-change, estimated_value/company/phone update, and activity-create handlers (established by `marketing-leads-pipeline`) — call `enqueueLeadScoreRecalc` after the write commits
- Modify: the proposal status-update handler established by `marketing-catalogs-campaigns` — when a proposal transitions to `status = 'VIEWED'` and has a non-null `lead_id`, call `enqueueLeadScoreRecalc`
- Create: `apps/zync-api/src/cron/lead-score-refresh.ts`
- Modify: `apps/zync-api/src/scheduled.ts` (dispatch `lead-score-refresh` cron) and `wrangler.toml` (add cron trigger)
**Steps:**
- [ ] After each triggering write (stage change; new `lead_activities`; `estimated_value`/`company`/`phone` update), enqueue a `lead.score_recalc` for the affected lead — outside the DB transaction, after commit, so a queue failure never rolls back the user action.
- [ ] In the proposal handler, enqueue recalc only on the DRAFT/SENT→VIEWED transition and only when `lead_id` is set.
- [ ] Cron handler: select active leads (`stage NOT IN ('WON','LOST')`) where `score_updated_at IS NULL OR score_updated_at < now() - interval '24 hours'`, batched, and enqueue a recalc per lead (do not recompute inline — keep cron lightweight).
- [ ] Register `lead-score-refresh` in `wrangler.toml` cron triggers (nightly) following the existing cron-table pattern in foundation-monorepo.
**Schema / Interfaces:**
```toml
# wrangler.toml — additive cron trigger
[triggers]
crons = [ "0 3 * * *" ]  # lead-score-refresh nightly (merge with existing crons array)
```
```sql
-- Cron sweep selection
SELECT id, tenant_id FROM leads
WHERE stage NOT IN ('WON','LOST')
  AND (score_updated_at IS NULL OR score_updated_at < now() - interval '24 hours');
```
**Acceptance:**
- [ ] Changing a lead's stage, adding an activity, or editing value/company/phone enqueues a recalc; the score updates shortly after.
- [ ] Marking a linked proposal VIEWED enqueues a recalc for its lead.
- [ ] The nightly cron enqueues recalcs only for stale active leads; WON/LOST are skipped.

### Task 9: Lead-card score badge (Kanban + list-card)
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: the lead Kanban/list card component established by `marketing-leads-pipeline` (`apps/zync-app/src/features/leads/components/LeadCard.tsx`)
- Create: `apps/zync-app/src/features/leads/components/LeadScoreBadge.tsx`
**Steps:**
- [ ] `LeadScoreBadge` renders the score in a `@zync/ui` `Badge` with a flame glyph; color by band: 70–100 green (hot), 40–69 amber (warm), 0–39 muted (cold).
- [ ] Hide the badge when `score === 0` AND the lead has no activity yet (newly created) — per spec. Use the serialized fields as the proxy: hide when `score === 0 && score_updated_at == null` (never recalculated = newly created; no separate activity-count field is needed or serialized). Note: after the nightly `lead-score-refresh` cron stamps `score_updated_at` on an untouched lead, the badge appears as "0 / cold" — consistent with the spec.
- [ ] Give the badge an accessible label (e.g. `aria-label="Lead score 82 of 100, hot"`); color is not the sole signal.
- [ ] Place the badge top-right of the card; ensure RTL placement flips via `useDirection` / logical properties.
**Acceptance:**
- [ ] Hot/warm/cold colors map to the spec bands; badge has a text/aria label, not color alone.
- [ ] A brand-new zero-score lead with no activity shows no badge.
- [ ] Badge sits on the inline (trailing) edge in both LTR and RTL.

### Task 10: Lead detail view — score panel, breakdown, Recalculate
**Blocks:** —  ·  **Blocked by:** 4, 5, 7
**Files:**
- Create: `apps/zync-app/src/features/leads/components/LeadScorePanel.tsx`
- Modify: the lead detail view established by `leads-detail-view` (`apps/zync-app/src/features/leads/LeadDetail.tsx`)
- Create: `apps/zync-app/src/features/leads/hooks/useLeadScore.ts`
**Steps:**
- [ ] `useLeadScore(leadId)` — React Query GET `/api/leads/:id/score`; `useRecalculateLeadScore(leadId)` — mutation POST `/api/leads/:id/score`, invalidates the score query and the lead detail/list on success.
- [ ] Render header `Lead Score: {score}/100` with band label (Hot/Warm/Cold).
- [ ] Progress bar via `@zync/ui` `Progress` showing `score%`: set `role="progressbar"`, `aria-valuenow={score}`, `aria-valuemin=0`, `aria-valuemax=100`, `aria-label`. Wrap any fill transition in `@media (prefers-reduced-motion: reduce)` to disable animation.
- [ ] Breakdown list: ✓/✗ row per `breakdown[]` item with label and `+points` (use met/points from API); threshold label shows the configured ₪ amount with locale-aware/RTL number formatting.
- [ ] `[Recalculate]` button triggers the mutation; show loading state; gate on `marketing:write` (hide/disable for read-only users).
**Acceptance:**
- [ ] Panel shows score, band, accessible progress bar, and a ✓/✗ breakdown row per criterion.
- [ ] Recalculate updates the displayed score and breakdown without a full reload.
- [ ] With `prefers-reduced-motion`, the bar does not animate; RTL renders the ₪ threshold and breakdown correctly.

### Task 11: Lead list — score column, sort-by-score, hot-leads filter
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: the leads list view + filter bar established by `marketing-leads-pipeline` (`apps/zync-app/src/features/leads/LeadList.tsx`, `apps/zync-app/src/features/leads/components/LeadFilterBar.tsx`)
- Modify: `apps/zync-app/src/features/leads/hooks/useLeadList.ts` (pass `sort_by`/`min_score` query params)
**Steps:**
- [ ] Add a Score column to the list table rendering `LeadScoreBadge` (or numeric value).
- [ ] Add a "Sort by score (high→low)" option that sets `sort_by=score`.
- [ ] Add a "Hot leads only" toggle that sets `min_score=70`; reflect both in URL-synced filter state (consistent with the pipeline's existing URL-sync filter pattern).
- [ ] Ensure the Score column header and toggle are keyboard-operable and labeled.
**Acceptance:**
- [ ] Score column renders per row; sorting and the hot-leads toggle drive the correct API params and update results.
- [ ] Filter state is URL-synced and survives reload.

### Task 12: `/settings/crm` — Lead Scoring configuration UI + persistence
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/settings/crm/LeadScoringSettings.tsx`
- Modify: the `/settings/crm` page shell (owned by `marketing-catalogs-campaigns` / settings-module; mount this section)
- Create: `apps/zync-app/src/features/settings/crm/useLeadScoringSettings.ts`
- Create: `apps/zync-api/src/routes/settings/crm/lead-scoring.ts` (GET + PUT) and mount it
**Steps:**
- [ ] API: `GET /api/settings/crm/lead-scoring` (`marketing:read`, Business+) → `getLeadScoringCriteria`; `PUT /api/settings/crm/lead-scoring` (`marketing:write`, Business+) → validate with `leadScoringCriteriaSchema` → `upsertLeadScoringCriteria`.
- [ ] UI form: high-value threshold input (₪, locale/RTL-aware) and a numeric weight input per criterion, using `@zync/ui` `Form`/`Input`.
- [ ] `[Save]` persists via PUT; `[Reset to defaults]` repopulates the form with `DEFAULT_LEAD_SCORING_CRITERIA` (saved only on explicit Save).
- [ ] Show success toast via `toast`; hide/disable Save for read-only users.
- [ ] Note in the UI that weight changes take effect on next recalculation (event-driven or nightly), not retroactively-instant.
**Schema / Interfaces:**
```ts
// GET /api/settings/crm/lead-scoring  (marketing:read, Business+) -> LeadScoringCriteria
// PUT /api/settings/crm/lead-scoring   (marketing:write, Business+) body: LeadScoringCriteria -> LeadScoringCriteria
```
**Acceptance:**
- [ ] Saving valid weights persists to `tenant_settings.lead_scoring_criteria`; reload shows saved values.
- [ ] Reset-to-defaults restores spec defaults; invalid input (negative weight, non-positive threshold) is rejected client- and server-side.
- [ ] Non-Business tenant or `marketing:read`-only user cannot save (tier gate / 403).

## Cross-Cutting Compliance
- **Security:** all endpoints behind `authMiddleware` + `requirePermission` (`marketing:read`/`marketing:write` verbatim) and the Business+ `requireTier` gate; tenant isolation via `tenantQuery`; Zod validation on every body/query; no raw Drizzle in routes.
- **A11y:** score progress bar uses `role="progressbar"` + `aria-value*`; badge carries an aria/text label (never color-only); column header + toggles keyboard-operable.
- **i18n/RTL:** ₪ threshold and breakdown points use locale-aware number formatting; badge and panel respect `useDirection` / logical properties for RTL/Hebrew.
- **Performance:** stored score (not query-time) with partial `idx_leads_score`; recalc is async via `QUEUE` so it never adds latency to triggering writes; reduced-motion disables the progress-bar animation.
