# Notification Preferences — Implementation Plan

**Spec:** docs/specs/2026-05-31-notification-preferences.md  ·  **Slug:** notification-preferences  ·  **Wave:** 9
**Depends on:** contracts-esignature, foundation-auth-rbac, project-hourly-budget, system-communications-notifications, tasks-board-engine

## Goal
Deliver the complete per-user notification preferences surface: the full `NotificationType` taxonomy (canonical source consumed by `system-communications-notifications`), a per-type / per-channel opt-in UI at `/profile/notifications`, sensible defaults seeded at user creation, an optional daily email digest, and the additional trigger types (`task_due_soon`, `task_overdue`, `project_budget_alert`, `contract_all_signed`, `lead_reengagement_due`) with their cron jobs. In-app delivery is always on (the non-disableable fallback); email and Telegram are per-type opt-in.

## Architecture
- **Storage** reuses the existing upstream column `user_preferences.notification_channels` JSONB (`{ email: NotificationType[], telegram: NotificationType[] }`, foundation-auth-rbac, composite PK `(user_id, tenant_id)`). This plan adds two columns to `user_preferences`: `email_digest_mode BOOLEAN` and `email_digest_hour INTEGER`. It does **not** add `telegram_chat_id` — that column (`BIGINT`) is owned by `telegram-bot` (P081, wave 8, builds before this) and is referenced read-only to decide whether the Telegram column appears.
- **Taxonomy** lives in `@zync/types` as the canonical `NotificationType` union (already an exported name; this spec is its canonical source — `system-communications-notifications` imports it via `import type { NotificationType } from '@zync/types'`). This plan extends the union to the full taxonomy and adds `NotificationCategory` plus a `NOTIFICATION_TYPE_CATALOG` describing category grouping and per-channel defaults for the UI.
- **Delivery** is unchanged: `deliverNotification(userId, DeliverableNotification)` (system-communications-notifications) fans out across `EmailNotificationAdapter` / `TelegramNotificationAdapter` / `WebPushNotificationAdapter`, each gating on `notification_channels`. In-app rows are created via `createNotification` (locked export) which writes the `notifications` table (`title_key`, `body_key`, `params`, `entity_type`, `entity_id`, `type`).
- **New triggers** consume upstream tables: `tasks` (+ `task_statuses.is_terminal`) from tasks-board-engine; `projects.billing_config` + a new `projects.budget_alerted_at` guard from project-hourly-budget; `contracts` / `contract_signatories` from contracts-esignature. The `project_budget_alert` and `contract_all_signed` emissions are **Modify** tasks inside those modules' existing handlers. The `lead_reengagement_due` cron itself is owned by spec 111; this plan only adds the type + default + i18n keys.
- **API**: two new routes `GET /api/profile/notifications` and `PUT /api/profile/notifications` on the Hono API in `apps/zync-api`, distinct from the existing `PATCH /api/user/preferences`.
- **UI**: `/profile/notifications` page in `apps/zync-app` (Vite+React), built from `@zync/ui` primitives (`Checkbox`, `Switch`, `Select`, `Card`, `Button`, `toast`).

## Tech Stack
- Packages: `@zync/types` (taxonomy + catalog), `@zync/notifications` (digest builder + cron handlers), `@zync/db` (Drizzle schema + queries).
- Apps: `apps/zync-api` (Hono routes + scheduled handlers), `apps/zync-app` (React preferences page + hook).
- Cloudflare: Workers Cron Triggers (`wrangler.toml`), Neon Postgres via Hyperdrive, Drizzle ORM.
- Libraries: `zod` (route validation), `react-i18next` (notification + UI strings), `Intl.DateTimeFormat` (DST-correct Israel hour resolution).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & types | 1, 2 | `@zync/db` schema + migration, `@zync/types` taxonomy/catalog | Task 1 & 2 parallel |
| B — defaults & queries | 3, 4 | `@zync/db` queries, auth signup path | After A |
| C — API & UI | 5, 6, 7 | Hono routes, React page, `usePreferences` hook | 5 then 6/7; 6 & 7 parallel after 5 |
| D — triggers & crons | 8, 9, 10, 11 | task crons, budget handler, contract handler, lead type | 8/9/10/11 parallel after B |
| E — digest & i18n | 12, 13 | digest cron + builder, i18n locale keys | 12 after A/B; 13 after 2 |

## Tasks

### Task 1: Extend `user_preferences` with digest columns + budget guard column migration
**Blocks:** 3, 4, 5, 12  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/user-preferences.ts`
- Modify: `packages/db/src/schema/projects.ts`
- Create: `packages/db/migrations/00XX_notification_preferences.sql`
**Steps:**
- [ ] Add `email_digest_mode` and `email_digest_hour` columns to the `user_preferences` Drizzle table definition.
- [ ] Add `budget_alerted_at` column to the `projects` Drizzle table (one-time guard for `project_budget_alert`; reset to NULL whenever `budget_hours` is raised above current usage — enforced in Task 9).
- [ ] Write the forward migration SQL below. Do NOT add `telegram_chat_id` — it is owned by `telegram-bot`.
- [ ] Confirm `notification_channels` JSONB already exists (foundation-auth-rbac); do not recreate it.
**Schema / Interfaces:**
```sql
-- packages/db/migrations/00XX_notification_preferences.sql
ALTER TABLE user_preferences
  ADD COLUMN email_digest_mode BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE user_preferences
  ADD COLUMN email_digest_hour INTEGER NOT NULL DEFAULT 8
    CHECK (email_digest_hour BETWEEN 0 AND 23);
-- email_digest_hour is Israel local time (Asia/Jerusalem), resolved DST-correct at send time.

-- One-time guard for project_budget_alert (spec 139 defines "fires once" but no persisted column).
ALTER TABLE projects
  ADD COLUMN budget_alerted_at TIMESTAMPTZ;
```
```ts
// packages/db/src/schema/user-preferences.ts (additions)
emailDigestMode: boolean('email_digest_mode').notNull().default(false),
emailDigestHour: integer('email_digest_hour').notNull().default(8),
// packages/db/src/schema/projects.ts (addition)
budgetAlertedAt: timestamp('budget_alerted_at', { withTimezone: true }),
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; `user_preferences` has both new columns with correct defaults and the `email_digest_hour` CHECK; `projects` has `budget_alerted_at`.
- [ ] No `telegram_chat_id` is added by this migration.

### Task 2: Define the canonical `NotificationType` taxonomy + `NotificationCategory` + catalog in `@zync/types`
**Blocks:** 5, 6, 8, 9, 10, 11, 13  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/notifications.ts`
- Modify: `packages/types/src/index.ts` (ensure exports)
**Steps:**
- [ ] Extend the existing exported `NotificationType` union to the full taxonomy below (this is the canonical source `system-communications-notifications` imports — do not create a parallel type or new package).
- [ ] Add the `NotificationCategory` union.
- [ ] Add `NOTIFICATION_TYPE_CATALOG`: ordered metadata driving the UI (category grouping, label i18n key, and per-channel default opt-in) and used by Task 3's default seeding.
- [ ] Re-export `NotificationType`, `NotificationCategory`, `NOTIFICATION_TYPE_CATALOG`, and the existing `NotificationPreferences` from the package index.
**Schema / Interfaces:**
```ts
// packages/types/src/notifications.ts
export type NotificationCategory =
  | 'invoices' | 'payments' | 'contracts' | 'proposals' | 'leads'
  | 'tickets' | 'expenses' | 'time' | 'team' | 'system'
  | 'tasks' | 'projects' | 'collaboration'

export type NotificationType =
  // invoices
  | 'invoice_paid' | 'invoice_overdue' | 'invoice_approved' | 'invoice_rejected'
  // payments
  | 'payment_received' | 'dunning_step_reached'
  // contracts
  | 'contract_signed' | 'contract_expiring' | 'contract_voided' | 'contract_all_signed'
  // proposals
  | 'proposal_viewed' | 'proposal_accepted' | 'proposal_rejected' | 'proposal_expiring'
  // leads
  | 'lead_created' | 'lead_assigned' | 'lead_stage_updated' | 'lead_converted'
  | 'lead_reengagement_due'
  // tickets
  | 'ticket_created' | 'ticket_assigned' | 'ticket_replied' | 'ticket_sla_breach'
  // expenses
  | 'expense_submitted' | 'expense_approved' | 'expense_rejected'
  // time
  | 'time_entry_approved' | 'time_period_locked'
  // team / users
  | 'member_invited' | 'role_changed' | 'user_approved' | 'user_frozen'
  // system
  | 'trial_expiring' | 'quota_warning' | 'import_complete'
  | 'bulk_action_complete' | 'exchange_rates_updated'
  // tasks
  | 'task_assigned' | 'task_updated' | 'task_comment'
  | 'task_due_soon' | 'task_overdue'
  // collaboration
  | 'mention'
  // projects
  | 'project_budget_alert'

export interface NotificationPreferences {
  email: NotificationType[]
  telegram: NotificationType[]
}

export interface NotificationTypeMeta {
  type: NotificationType
  category: NotificationCategory
  labelKey: string          // i18n key for the row label
  defaultEmail: boolean     // seeded into notification_channels.email at user creation
  defaultTelegram: boolean  // seeded into notification_channels.telegram (always false at creation)
}

// Ordered; drives both the /profile/notifications UI grouping and DEFAULT_NOTIFICATION_CHANNELS.
export const NOTIFICATION_TYPE_CATALOG: readonly NotificationTypeMeta[] = [/* one entry per NotificationType, in category order */]
```
**Acceptance:**
- [ ] `NotificationType` includes every value listed in the spec taxonomy (invoices→projects), with `task_*`, `project_budget_alert`, `contract_all_signed`, `lead_reengagement_due` present.
- [ ] `NOTIFICATION_TYPE_CATALOG` has exactly one entry per `NotificationType` value; `tsc` enforces exhaustiveness (a `Record<NotificationType, …>` derivation or compile-time check fails if a type is missing).
- [ ] `import type { NotificationType } from '@zync/types'` in `system-communications-notifications` still type-checks.

### Task 3: `DEFAULT_NOTIFICATION_CHANNELS` constant + seed at user creation
**Blocks:** 5  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/notification-defaults.ts`
- Modify: `apps/zync-api/src/routes/auth.ts` (signup handler — user-preferences insert)
- Modify: `packages/db/src/queries/user-preferences.ts`
**Steps:**
- [ ] Define `DEFAULT_NOTIFICATION_CHANNELS` derived from `NOTIFICATION_TYPE_CATALOG` (`email` = types with `defaultEmail: true`; `telegram` = `[]`). Verify it matches the spec's defaults list verbatim.
- [ ] On user creation (signup / invitation acceptance), insert the `user_preferences` row with `notification_channels = DEFAULT_NOTIFICATION_CHANNELS` instead of relying on the empty column default `'{"email":[],"telegram":[]}'`.
- [ ] `email_digest_mode` defaults to false, `email_digest_hour` to 8 (column defaults; no override needed at creation).
**Schema / Interfaces:**
```ts
// packages/db/src/notification-defaults.ts
import { NOTIFICATION_TYPE_CATALOG } from '@zync/types'
import type { NotificationPreferences } from '@zync/types'

export const DEFAULT_NOTIFICATION_CHANNELS: NotificationPreferences = {
  email: [
    'invoice_paid', 'invoice_overdue', 'payment_received',
    'contract_signed', 'proposal_accepted',
    'lead_created', 'ticket_created',
    'trial_expiring', 'quota_warning',
  ],
  telegram: [],
}
// NOTE: the array above is the spec-authoritative default; NOTIFICATION_TYPE_CATALOG.defaultEmail
// flags MUST be authored to produce exactly this set (assert equality in a build-time check).
```
**Acceptance:**
- [ ] A newly created user's `notification_channels` equals `DEFAULT_NOTIFICATION_CHANNELS` (9 email types, empty telegram).
- [ ] `DEFAULT_NOTIFICATION_CHANNELS.email` derived from the catalog equals the literal list above.

### Task 4: Preference read/write queries in `@zync/db`
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/queries/user-preferences.ts`
**Steps:**
- [ ] Add `getNotificationPreferences(db, userId, tenantId)` returning `{ channels, digestMode, digestHour }`, reading `notification_channels`, `email_digest_mode`, `email_digest_hour`. Use `tenantQuery` scoping.
- [ ] Add `updateNotificationPreferences(db, userId, tenantId, patch)` performing a partial update of `notification_channels` (merge of `email`/`telegram` arrays), `email_digest_mode`, `email_digest_hour`; sets `updated_at = now()`.
- [ ] Validate every entry in `channels.email` / `channels.telegram` is a known `NotificationType` (reject unknown strings).
**Schema / Interfaces:**
```ts
export interface NotificationPrefsView {
  channels: { email: NotificationType[]; telegram: NotificationType[] }
  digestMode: boolean
  digestHour: number
}
export function getNotificationPreferences(db: Db, userId: string, tenantId: string): Promise<NotificationPrefsView>
export function updateNotificationPreferences(
  db: Db, userId: string, tenantId: string,
  patch: { channels?: { email?: NotificationType[]; telegram?: NotificationType[] }; digestMode?: boolean; digestHour?: number },
): Promise<NotificationPrefsView>
```
**Acceptance:**
- [ ] `getNotificationPreferences` returns the stored channels, digest mode, and hour for the scoped user/tenant.
- [ ] `updateNotificationPreferences` merges only the provided keys; unknown `NotificationType` strings are rejected.

### Task 5: API routes `GET` / `PUT /api/profile/notifications`
**Blocks:** 6  ·  **Blocked by:** 2, 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/profile-notifications.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Implement `GET /api/profile/notifications` (auth required via `authMiddleware`): returns `{ channels: { email, telegram }, digestMode, digestHour }` from `getNotificationPreferences`.
- [ ] Implement `PUT /api/profile/notifications` (auth required): zod-validate body, call `updateNotificationPreferences`, return the updated view. Enforce `digestHour` ∈ 0..23 and each channel entry ∈ `NotificationType`.
- [ ] Use `requireZodValidationInRoutes` pattern; do not issue raw Drizzle from the route — call `@zync/db` queries only.
- [ ] Scope reads/writes to the session's `userId` + `tenantId`; never accept a target user id from the body.
**Schema / Interfaces:**
```ts
const updateNotificationPrefsSchema = z.object({
  channels: z.object({
    email: z.array(z.string()).optional(),
    telegram: z.array(z.string()).optional(),
  }).optional(),
  digestMode: z.boolean().optional(),
  digestHour: z.number().int().min(0).max(23).optional(),
})
// GET /api/profile/notifications -> NotificationPrefsView
// PUT /api/profile/notifications  body: z.infer<typeof updateNotificationPrefsSchema> -> NotificationPrefsView
```
**Acceptance:**
- [ ] Unauthenticated requests get 401.
- [ ] `GET` returns the caller's current preferences; `PUT` persists a partial update and echoes the new state.
- [ ] `PUT` with `digestHour: 24` or an unknown type string returns 400.

### Task 6: `/profile/notifications` preferences page (React)
**Blocks:** —  ·  **Blocked by:** 2, 5, 7
**Files:**
- Create: `apps/zync-app/src/pages/profile/NotificationsPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route registration)
**Steps:**
- [ ] Render a per-type grid grouped by `NotificationCategory` using `NOTIFICATION_TYPE_CATALOG` order: columns In-app / Email / Telegram.
- [ ] In-app column: a `Checkbox` rendered checked + disabled with the localized "(always)" hint — in-app cannot be disabled (it is the fallback).
- [ ] Email and Telegram columns: editable `Checkbox` per row reflecting membership in `channels.email` / `channels.telegram`.
- [ ] Render the Telegram column **only if** `user_preferences.telegram_chat_id` is set for the user (surfaced by the `GET` route / `usePreferences`); otherwise hide the whole column.
- [ ] Email delivery mode block: a radio/`Switch` pair — "Individual emails (immediate)" vs "Daily digest at [hour ▾] (Israel time)" bound to `digestMode` + `digestHour` (`Select` of 0..23).
- [ ] "Save preferences" `Button` → `PUT /api/profile/notifications`; show `toast` on success/failure.
- [ ] a11y: each checkbox has an associated label and `aria-label`; the grid uses a proper table semantics (`role`/`<th>`) so screen readers announce row+column; the disabled in-app boxes expose `aria-disabled`.
- [ ] i18n/RTL: all labels via `react-i18next` keys; layout uses logical properties so it mirrors correctly under `dir="rtl"` (Hebrew). Honor `prefers-reduced-motion` for any save-state transition.
**Acceptance:**
- [ ] In-app boxes are always checked and not editable.
- [ ] Toggling an email/telegram box and saving persists via `PUT`; reload reflects the change.
- [ ] Telegram column is absent when the user has no `telegram_chat_id`.
- [ ] Page renders correctly RTL in Hebrew and passes an axe check (labels, table semantics).

### Task 7: `usePreferences` data hook
**Blocks:** 6  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/hooks/useNotificationPreferences.ts`
**Steps:**
- [ ] `useNotificationPreferences()` query hook wrapping `GET /api/profile/notifications` (react-query), returning `{ channels, digestMode, digestHour, telegramConnected }`.
- [ ] `useUpdateNotificationPreferences()` mutation wrapping `PUT`, invalidating the query on success.
- [ ] Surface `telegramConnected` (boolean) so the page can decide whether to show the Telegram column. Source it from the route response (route adds `telegramConnected: telegram_chat_id IS NOT NULL`).
**Schema / Interfaces:**
```ts
export function useNotificationPreferences(): {
  data?: { channels: { email: NotificationType[]; telegram: NotificationType[] }; digestMode: boolean; digestHour: number; telegramConnected: boolean }
  isLoading: boolean
}
export function useUpdateNotificationPreferences(): { mutate: (patch: UpdateNotificationPrefsInput) => void; isPending: boolean }
```
**Acceptance:**
- [ ] Hook returns server state; mutation updates and refetches.
- [ ] `GET` route includes `telegramConnected` (add this field to the route response in Task 5's handler).

### Task 8: `task_due_soon` + `task_overdue` cron handlers + triggers
**Blocks:** —  ·  **Blocked by:** 2, 3
**Files:**
- Create: `packages/notifications/src/cron/task-reminders.ts`
- Modify: `apps/zync-api/src/scheduled.ts` (cron dispatch by scheduled time)
- Modify: `apps/zync-api/wrangler.toml` (cron triggers)
**Steps:**
- [ ] Add the two task crons to `wrangler.toml` `[triggers]` (verbatim times below; these are UTC and intentionally fixed — DST dynamic resolution applies only to the digest hour in Task 12, not to these triggers).
- [ ] In `scheduled.ts`, branch on `event.cron`: `"0 6 * * *"` → `runTaskDueSoon`, `"0 7 * * *"` → `runTaskOverdue`.
- [ ] `runTaskDueSoon`: select tasks where `due_date = CURRENT_DATE + 1`, `assignee_id IS NOT NULL`, and the task's status is non-terminal (join `task_statuses` on `tasks.status_id`, `is_terminal = false`). For each, `createNotification(type='task_due_soon', recipient=assignee_id, entity_type='task', entity_id=task.id, title_key/body_key/params)` then `deliverNotification`. Default channels: in-app only (the catalog default for `task_due_soon` has `defaultEmail:false`).
- [ ] `runTaskOverdue`: select tasks where `due_date < CURRENT_DATE`, non-terminal, `assignee_id IS NOT NULL`, with the 24h dedup `NOT EXISTS` guard below. Emit `task_overdue` (default in-app + email).
- [ ] All selects are tenant-scoped; iterate per tenant. Use `createNotification` / `deliverNotification` (locked exports) — no direct adapter calls.
**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml
[triggers]
crons = [
  "0 6 * * *",   # task-due-soon  — 08:00 IL (UTC+2)
  "0 7 * * *",   # task-overdue   — 09:00 IL (UTC+2)
  "0 * * * *",   # user-notification-digest — hourly (Task 12)
]
```
```sql
-- task_overdue dedup (per candidate task t):
AND NOT EXISTS (
  SELECT 1 FROM notifications n
  WHERE n.type = 'task_overdue'
    AND n.entity_id = t.id
    AND n.created_at > now() - interval '24 hours'
)
```
**Acceptance:**
- [ ] A task due tomorrow with a non-terminal status and an assignee yields one `task_due_soon` in-app notification to the assignee; no email unless the user opted in.
- [ ] An overdue non-terminal assigned task yields a `task_overdue` notification (in-app + email by default) at most once per 24h (dedup verified).
- [ ] Tasks in terminal statuses or with no assignee produce no reminder.

### Task 9: `project_budget_alert` trigger (Modify spec-139 budget-check handler)
**Blocks:** —  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/routes/time-entries.ts` (the time-logging path that recomputes `logged_hours`, owned by project-hourly-budget)
- Modify: `packages/db/src/queries/projects.ts` (budget guard helpers)
**Steps:**
- [ ] After a time entry changes a project's `logged_hours`, compute `logged_hours / budget_hours` for hourly projects with a `budget_hours` set in `billing_config`.
- [ ] If `logged_hours / budget_hours >= budget_alert_pct / 100` AND `projects.budget_alerted_at IS NULL`: set `budget_alerted_at = now()`, then emit `project_budget_alert` to all tenant OWNER + ADMIN via `createNotification` + `deliverNotification`. Default channels in-app + email.
- [ ] Body params: `{ name, pct, used, budget }` rendering "Project '{name}' has used {pct}% of its hourly budget ({used}h of {budget}h)."
- [ ] Reset guard: when `budget_hours` is updated above current usage (in the project-update handler), set `budget_alerted_at = NULL` so a future crossing re-alerts (matches spec 139 "alert resets").
- [ ] Recipients resolved from `tenant_memberships` where role ∈ {OWNER, ADMIN} for the project's tenant.
**Schema / Interfaces:**
```ts
// packages/db/src/queries/projects.ts
// Returns true and stamps budget_alerted_at iff threshold newly crossed (atomic guard).
export function tryClaimBudgetAlert(db: Db, projectId: string, tenantId: string): Promise<boolean>
// Clears the guard when budget raised above usage.
export function resetBudgetAlert(db: Db, projectId: string, tenantId: string): Promise<void>
```
**Acceptance:**
- [ ] Crossing the threshold for the first time fires exactly one `project_budget_alert` to every OWNER/ADMIN; subsequent time entries above threshold do not re-fire.
- [ ] Raising `budget_hours` above usage clears `budget_alerted_at`; a later re-crossing fires again.

### Task 10: `contract_all_signed` trigger (Modify spec-48 sign handler)
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts` (`POST /api/contracts/:id/sign/:token` handler)
**Steps:**
- [ ] In the sign handler, after recording a signatory's `signed_at`, check whether every `contract_signatories` row for the contract now has `signed_at` set.
- [ ] If all signed (the transition that sets `contracts.status = 'SIGNED'` and `contracts.signed_at = now()`): emit `contract_all_signed` once.
- [ ] Recipients: contract `created_by` plus all tenant OWNER/ADMIN (dedup the recipient set so `created_by` isn't notified twice).
- [ ] Body params `{ title }` rendering "Contract '{title}' has been signed by all parties." Default channels in-app + email.
- [ ] Use `createNotification` (entity_type='contract', entity_id=contract.id) + `deliverNotification`. Guard against double-emit: only emit on the same status transition that flips `status` to `SIGNED` (i.e., when it was not already `SIGNED`).
**Acceptance:**
- [ ] When the final signatory signs, `contract_all_signed` is delivered once to `created_by` + OWNER/ADMIN (no duplicate to `created_by`).
- [ ] Signing a non-final signatory produces no `contract_all_signed`.

### Task 11: `lead_reengagement_due` type + default (no new cron here)
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/types/src/notifications.ts` (already includes the type via Task 2 — confirm catalog entry)
- Modify: `packages/ui/src/locales/en.json`, `packages/ui/src/locales/he.json` (covered in Task 13)
**Steps:**
- [ ] Ensure `lead_reengagement_due` exists in the union and `NOTIFICATION_TYPE_CATALOG` with category `leads`, default in-app + email (`defaultEmail: true`).
- [ ] Do **not** add a cron for this type — its cron (`0 4 * * *`, 07:00 UTC, firing on `leads.reengagement_at <= now()` AND `reengagement_notified_at IS NULL`, recipients `leads.assigned_to`) is owned by spec 111 (`lead-lost-re-engagement`). This plan only contributes the type, default, and i18n keys so spec 111's emission routes correctly through `deliverNotification`.
- [ ] Document the body template params `{ lead_name }` → "Re-engage {lead_name} — cool-down period ended." for the i18n keys in Task 13.
**Acceptance:**
- [ ] `lead_reengagement_due` is a valid `NotificationType` with a catalog entry (category `leads`, default email on).
- [ ] No `lead_reengagement_due` cron is added in `wrangler.toml` by this plan.

### Task 12: `user-notification-digest` hourly cron + digest builder
**Blocks:** —  ·  **Blocked by:** 1, 3
**Files:**
- Create: `packages/notifications/src/cron/digest.ts`
- Modify: `apps/zync-api/src/scheduled.ts` (dispatch `"0 * * * *"` → `runNotificationDigest`)
**Steps:**
- [ ] On each hourly run, resolve the current Israel local hour DST-correctly via `Intl.DateTimeFormat('en', { timeZone: 'Asia/Jerusalem', hour: 'numeric', hour12: false })` (do NOT hardcode a UTC offset).
- [ ] Select users where `email_digest_mode = true` AND `email_digest_hour = israelHour`.
- [ ] For each such user, gather all `notifications` rows from the last 24h whose `type` is in the user's `notification_channels.email` (i.e., the types email would have fired for). Build one digest email containing those items.
- [ ] Send via the email path (`sendEmail` with the user's resolved `locale`, RTL `dir="rtl"` for Hebrew). Skip users with zero qualifying notifications (no empty digest).
- [ ] When `email_digest_mode = true`, per-event email delivery for that user is suppressed by the existing `EmailNotificationAdapter.canDeliver` path — confirm the adapter consults `email_digest_mode` so events are batched, not double-sent. If the adapter does not yet check it, add that check in `packages/notifications/src/adapters/email.ts` (digest mode ⇒ `canDeliver` returns false for individual email, digest cron sends the batch).
**Schema / Interfaces:**
```ts
// packages/notifications/src/cron/digest.ts
export async function runNotificationDigest(env: Env): Promise<void>
// Current Israel local hour, DST-correct:
const israelHour = Number(
  new Intl.DateTimeFormat('en', { timeZone: 'Asia/Jerusalem', hour: 'numeric', hour12: false })
    .format(new Date()),
)
```
**Acceptance:**
- [ ] A user with `email_digest_mode = true`, `email_digest_hour = H`, receives exactly one digest email in the hourly run when Israel local hour equals H, containing their last-24h email-eligible notifications.
- [ ] Users in digest mode do not receive per-event emails (no double-send).
- [ ] Israel hour resolution is DST-correct (uses `Asia/Jerusalem`, not a fixed offset).

### Task 13: i18n keys for all notification types + preferences UI strings
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/ui/src/locales/en.json`
- Modify: `packages/ui/src/locales/he.json`
**Steps:**
- [ ] Add `notification.<type>.title` and `notification.<type>.body` keys (English + Hebrew) for every `NotificationType` value, including the new `task_due_soon`, `task_overdue`, `project_budget_alert`, `contract_all_signed`, `lead_reengagement_due` (spec 11 requires all 30+ types have matching keys in both `en.json` and `he.json`).
- [ ] Add the `/profile/notifications` UI strings: page title, category labels (one per `NotificationCategory`), column headers (In-app / Email / Telegram), the "(always)" in-app hint, delivery-mode labels ("Individual emails (immediate)", "Daily digest at {hour} (Israel time)"), and the Save button — in both locales.
- [ ] Body templates use the interpolation params defined by each trigger (e.g. `project_budget_alert`: `{name}`/`{pct}`/`{used}`/`{budget}`; `contract_all_signed`: `{title}`; `lead_reengagement_due`: `{lead_name}`).
- [ ] Hebrew strings authored for RTL; verify no key is missing between the two files.
**Acceptance:**
- [ ] Every `NotificationType` has both a `.title` and (where applicable) `.body` key in `en.json` and `he.json`; key-parity check passes.
- [ ] The preferences page renders all labels localized in both English and Hebrew with no missing-key fallbacks.
