# Notification Preferences

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 97  
**Tier:** All tiers  
**Depends on:** `system-communications-notifications`, `tasks-board-engine`, `project-hourly-budget`, `contracts-esignature`, `foundation-auth-rbac`  
**Referenced by:** `system-communications-notifications`  
**Consolidates:** spec 158 (`notification-trigger-gaps`) — retired; 4 new types and cron jobs folded in here

---

## Overview

Spec 11 (`system-communications-notifications`) defines `user_preferences.notification_channels` as a JSONB field `{ email: NotificationType[], telegram: NotificationType[] }` and mentions "User-configurable from Profile → Alert settings." This spec defines the complete preferences UI, the full notification type taxonomy, and the per-channel opt-in/opt-out logic.

---

## Notification Type Taxonomy

```ts
type NotificationCategory =
  | 'invoices'      // invoice status changes, payment received, overdue
  | 'payments'      // payment recorded, dunning step reached
  | 'contracts'     // contract sent, signed, voided, expiring
  | 'proposals'     // proposal viewed, accepted, rejected, expiring
  | 'leads'         // new lead, stage change, assignment
  | 'tickets'       // new ticket, assigned, replied, SLA breach
  | 'expenses'      // expense submitted, approved, rejected
  | 'time'          // time approval, period lock
  | 'team'          // member invited, role changed
  | 'system'        // trial expiry, quota warning, import complete

type NotificationType =
  // invoices
  | 'invoice_paid' | 'invoice_overdue' | 'invoice_approved' | 'invoice_rejected'
  // payments
  | 'payment_received' | 'dunning_step_reached'
  // contracts
  | 'contract_signed' | 'contract_expiring' | 'contract_voided'
  // proposals
  | 'proposal_viewed' | 'proposal_accepted' | 'proposal_rejected' | 'proposal_expiring'
  // leads
  | 'lead_created' | 'lead_assigned' | 'lead_stage_updated' | 'lead_converted'
  // 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'
  // contracts
  | 'contract_all_signed'
  // leads
  | 'lead_reengagement_due'
```

---

## Preferences UI: Profile → Notifications

`/profile/notifications`:

```
┌──────────────────────────────────────────────────────────────┐
│  Notification preferences                                    │
│                                                              │
│  Notification               In-app    Email    Telegram      │
│  ──────────────────────────────────────────────────────────  │
│  ── Invoices ──────────────────────────────────────────────  │
│  Invoice paid               ☑ (always) ☑        ☐           │
│  Invoice overdue            ☑ (always) ☑        ☑           │
│  Invoice approved           ☑ (always) ☐        ☐           │
│  Invoice rejected           ☑ (always) ☑        ☐           │
│                                                              │
│  ── Payments ──────────────────────────────────────────────  │
│  Payment received           ☑ (always) ☑        ☑           │
│  Dunning step reached       ☑ (always) ☑        ☐           │
│                                                              │
│  ── Contracts ─────────────────────────────────────────────  │
│  Contract signed            ☑ (always) ☑        ☐           │
│  Contract expiring          ☑ (always) ☑        ☐           │
│  Contract voided            ☑ (always) ☑        ☐           │
│                                                              │
│  [... all categories collapsed similarly ...]               │
│                                                              │
│  ── Tasks ───────────────────────────────────────────────────  │
  Task due soon               ☑ (always) ☐        ☐           │
  Task overdue                ☑ (always) ☑        ☐           │
                                                              │
  ── Projects ────────────────────────────────────────────────  │
  Project budget alert        ☑ (always) ☑        ☐           │
                                                              │
  [Save preferences]                                          │
└──────────────────────────────────────────────────────────────┘
```

**In-app** column: always checked, not editable (in-app cannot be disabled; it's the fallback).

**Email** and **Telegram** columns: per-type opt-in. Telegram column only appears if `user_preferences.telegram_chat_id` is set (from Telegram bot flow in spec 11).

---

## Storage

`user_preferences.notification_channels` JSONB (spec 11 existing column):

```json
{
  "email": ["invoice_paid", "invoice_overdue", "payment_received", "contract_signed"],
  "telegram": ["invoice_paid", "payment_received"]
}
```

Empty array = no email/telegram notifications (only in-app). Spec 11's `NotificationAdapter` already reads this field to route to email/telegram.

---

## Defaults

On user creation, `notification_channels` seeded with sensible defaults:

```json
{
  "email": [
    "invoice_paid", "invoice_overdue", "payment_received",
    "contract_signed", "proposal_accepted",
    "lead_created", "ticket_created",
    "trial_expiring", "quota_warning"
  ],
  "telegram": []
}
```

---

## Additional Trigger Types

### `task_due_soon`

**Trigger:** Cron — daily at 08:00 IL time (UTC+2). `tasks.due_date = CURRENT_DATE + 1` AND task status not terminal AND `assignee_id IS NOT NULL`.  
**Recipients:** `tasks.assignee_id`.  
**Default:** in-app only.

### `task_overdue`

**Trigger:** Cron — daily at 09:00 IL time (UTC+2). `tasks.due_date < CURRENT_DATE` AND status not terminal AND no `task_overdue` notification sent for this task in the past 24 hours (dedup via `notifications` table).  
**Recipients:** `tasks.assignee_id`.  
**Default:** in-app + email.

Dedup query:
```sql
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'
)
```

### `project_budget_alert`

**Trigger:** Real-time, fired when `logged_hours / budget_hours >= budget_alert_pct / 100` for the first time (spec 139 one-time guard).  
**Recipients:** All OWNER + ADMIN of the tenant.  
**Body:** "Project '{name}' has used {pct}% of its hourly budget ({used}h of {budget}h)."  
**Default:** in-app + email.

### `contract_all_signed`

**Trigger:** Real-time — fired when the last `contract_signatories` row for a contract gets `signed_at` set (in `POST /api/contracts/:id/sign/:token` handler, spec 48). Contract status transitions to `SIGNED`.  
**Recipients:** Contract `created_by` + all OWNER/ADMIN.  
**Body:** "Contract '{title}' has been signed by all parties."  
**Default:** in-app + email.

### `lead_reengagement_due`

**Trigger:** Cron — daily at 07:00 UTC. Fires when `leads.reengagement_at <= now()` AND `reengagement_notified_at IS NULL`. (Spec 111 `lead-lost-re-engagement`.)  
**Recipients:** `leads.assigned_to`.  
**Body:** "Re-engage {lead_name} — cool-down period ended."  
**Default:** in-app + email.

---

## Cron Jobs

Two Cloudflare Cron Triggers added to `wrangler.toml`:

```toml
[triggers]
crons = [
  "0 6 * * *",   # task-due-soon  — 08:00 IL (UTC+2)
  "0 7 * * *",   # task-overdue   — 09:00 IL (UTC+2)
]
```

Worker distinguishes handlers by scheduled time. Lead re-engagement cron (`0 4 * * *` = 07:00 UTC) is defined in spec 111.

---

## Digest Mode (Email Only)

Optional email digest: instead of per-event emails, user can receive a daily digest:

```
┌──────────────────────────────────────────────────────────────┐
│  Email delivery mode                                         │
│  ● Individual emails (immediate)                             │
│  ○ Daily digest at  [08:00 ▾] (Israel time)                 │
└──────────────────────────────────────────────────────────────┘
```

```sql
ALTER TABLE user_preferences ADD COLUMN email_digest_mode BOOLEAN DEFAULT false;
ALTER TABLE user_preferences ADD COLUMN email_digest_hour INTEGER DEFAULT 8;
-- Hour in Israel local time. Digest contains all notifications from last 24h.
```

> **Israel timezone / DST:** Israel observes UTC+2 (IST) from the last Sunday of October to the last Friday before the last Sunday of March, and UTC+3 (IDT) otherwise. Quiet-hours and digest-hour enforcement **must** resolve the current Israel local hour dynamically via the `Intl.DateTimeFormat` API with timezone `'Asia/Jerusalem'` — do **not** hardcode a UTC offset.
>
> ```ts
> // Current hour in Israel local time, DST-correct:
> const israelHour = Number(
>   new Intl.DateTimeFormat('en', {
>     timeZone: 'Asia/Jerusalem', hour: 'numeric', hour12: false
>   }).format(new Date())
> )
> ```

Digest cron: `user-notification-digest` — hourly. For each user with `email_digest_mode = true` where `israelHour` (resolved as above) = `email_digest_hour`, send digest email of all notifications from last 24h where email channel would have fired.

---

## API

```
GET /api/profile/notifications
    → current notification preferences
      Returns: { channels: { email: string[], telegram: string[] }, digestMode, digestHour }
      Requires: authenticated user

PUT /api/profile/notifications
    → update preferences
      body: { channels?: { email?: string[], telegram?: string[] }, digestMode?: boolean, digestHour?: number }
      Requires: authenticated user
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| In-app always on | Not per-type | In-app is the fallback; disabling it would leave users with no notification at all; also simplifies delivery logic |
| JSONB array of type strings | Not bit-mask | Readable, forward-compatible (add new types without migration), directly matches TS union type |
| Per-type granularity | Not per-category | Different users care about different events; category-level is too coarse for advanced users |
| Daily digest option | Not configurable frequency | Most digest use cases are "morning summary"; hourly/weekly adds complexity without clear need |
