# System: Communications, Notifications & Webhook Gateway — Implementation Plan

**Spec:** docs/specs/2026-05-30-system-communications-notifications.md  ·  **Slug:** system-communications-notifications  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac, foundation-monorepo

## Goal
Deliver the platform-wide communications backbone: pluggable communications adapters (Email/Resend+SMTP, Telegram, Slack, WhatsApp, Web Push), a per-user in-app notification inbox with locale-deferred rendering and multi-channel fanout delivery, inbound webhook receivers that route messages through a Cloudflare Queue, and a KV/native rate-limiting middleware consumed by auth and webhook routes. This is a foundation task: ~10 downstream specs lock to the tables (`push_subscriptions`, `notifications`, `adapter_credentials`), the `@zync/notifications` package, the `NotificationAdapter` seam, and the `/api/notifications`, `/api/push`, `/api/webhooks` routes defined here.

## Architecture
- **Adapter seam.** `@zync/notifications` exposes `NotificationAdapter` (delivery-only, typed to `NotificationType`) and a `deliverNotification(userId, notification)` fanout that runs `Promise.allSettled` across `[emailAdapter, telegramAdapter, webPushAdapter]`. Adding WhatsApp/Slack = implement the interface, no pipeline change. `CommsAdapter` (in `@zync/types`) is the broader send/receive interface for inbound-capable bot adapters.
- **Upstream consumed.** FKs target `users(id)` and `tenants(id)` from foundation-auth-rbac. `canDeliver()` reads `user_preferences.notification_channels` (JSONB `{"email":[],"telegram":[]}`, owned by foundation-auth-rbac). Email locale defaults to `tenants.locale`. Tier gating uses `requireTier(minimum: TenantTier)` from `@zync/auth` (`packages/auth/src/entitlements.ts`). DB access via `getDb(env)` Drizzle client over Hyperdrive and the `tenantQuery(db, tenantId)` factory from foundation-monorepo (`packages/db`).
- **NotificationType.** Imported from `@zync/types`; the canonical union is owned by spec 97 (`notification-preferences`, a later wave). This spec does NOT redefine it and does NOT add a DB CHECK on `notifications.type` — it dispatches every value.
- **Webhook gateway scope.** Inbound receivers (`/api/webhooks/telegram/{tenantId}`, `/slack`, `/whatsapp/{tenantId}`) are owned here and feed the `comms.inbound` queue. OUTBOUND webhook delivery/retry/schema is owned by `white-label-api` (spec 27) and is explicitly out of scope.
- **WebSocket push.** Best-effort: on notification create, look up `ws_session:{userId}:{tenantId}` in KV; if present, push to the tenant Durable Object. The DO itself is owned by `tasks-detail-communication` (later wave) — this spec only builds the KV-lookup seam and degrades to 30s polling when no DO/session exists.

## Tech Stack
- **Packages:** `@zync/notifications` (new — adapters, templates, delivery, rate-limit helpers re-export), `@zync/types` (extend with comms/notification interfaces), `@zync/db` (schema + Drizzle queries + migrations), `@zync/auth` (rate-limit middleware lives here per spec).
- **Apps:** `apps/zync-api` (Hono routes + queue consumer + middleware), `apps/zync-app` (ServiceWorker `public/sw.js`, notification dropdown UI, push opt-in).
- **Libraries:** Resend REST API (system email), MJML→HTML email templates, `web-push`-style VAPID signing implemented for Workers (RFC 8030/8292), `react-i18next` (client-side notification rendering).
- **Cloudflare bindings:** `DB` (Hyperdrive→Neon), `KV` (ws session map, push opt-in cache is client-side), `QUEUE` producer + `comms.inbound` consumer, `RATE_LIMITER_AUTH` + `RATE_LIMITER_WEBHOOK` (native ratelimit bindings).
- **Secrets:** `RESEND_API_KEY`, `INTEGRATION_ENCRYPTION_KEY` (AES-256-GCM for `adapter_credentials`), `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Types & schema | 1, 2 | `packages/types/src/*`, `packages/db/src/schema/*`, `packages/db/migrations/*` | Task 1 ‖ Task 2 |
| B — Crypto, rate-limit, email core | 3, 4, 5 | `packages/auth/src/crypto.ts`, `packages/auth/src/rate-limit.ts`, `packages/notifications/src/email/*`, `templates/*` | Task 3 ‖ 4 ‖ 5 after A |
| C — Adapters & delivery | 6, 7, 8, 9 | `packages/notifications/src/adapters/*`, `deliver.ts` | 6,7,8 ‖ then 9 |
| D — Push API + ServiceWorker | 10, 11 | `apps/zync-api/.../push.ts`, `apps/zync-app/public/sw.js` | 10 ‖ 11 |
| E — In-app notifications API + UI | 12, 13 | `apps/zync-api/.../notifications.ts`, `apps/zync-app/.../NotificationDropdown.tsx` | 12 then 13 |
| F — Inbound webhooks + queue + routing | 14, 15, 16 | `apps/zync-api/.../webhooks/*`, queue consumer, `packages/db/src/queries/inbound.ts` | 14 then 15,16 |
| G — Wiring, secrets, i18n keys | 17, 18 | `wrangler.toml`, `packages/ui/src/locales/*` | 17 ‖ 18 |

## Tasks

### Task 1: Comms & notification TypeScript interfaces in `@zync/types`
**Blocks:** 5, 6, 7, 8, 9, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/comms-adapter.ts`
- Create: `packages/types/src/notification.ts`
- Modify: `packages/types/src/index.ts` (re-export both)
**Steps:**
- [ ] Define `CommsAdapter`, `OutboundMessage`, `InboundMessage`, `Attachment` in `comms-adapter.ts`.
- [ ] Define `NotificationAdapter`, `DeliverableNotification`, `ActionButton`, `DeliveryResult`, `SendEmailOptions`, `NotificationPreferences` in `notification.ts`.
- [ ] Re-export `NotificationType` from the canonical module (`./notification-type`) owned by spec 97 (`notification-preferences`); this package imports the union and never redefines its value set. Add a `// canonical union owned by notification-preferences (spec 97)` comment at the re-export.
- [ ] Export all from `index.ts`.
**Schema / Interfaces:**
```ts
// packages/types/src/comms-adapter.ts
export interface Attachment { filename: string; contentType: string; content: ArrayBuffer | string; }
export interface OutboundMessage {
  to: string; subject?: string; body: string; html?: string; attachments?: Attachment[];
}
export interface InboundMessage {
  from: string; text: string; chatId: string; metadata: Record<string, unknown>;
}
export interface CommsAdapter {
  id: string; name: string;
  send(message: OutboundMessage): Promise<void>;
  receive?(payload: unknown): InboundMessage | null;
}

// packages/types/src/notification.ts
// NotificationType canonical union owned by spec 97 (notification-preferences).
export type { NotificationType } from './notification-type'; // re-export from spec-97-owned module
export interface ActionButton { label: string; url?: string; callbackAction?: string; }
export interface DeliverableNotification {
  type: NotificationType; title: string; body: string;
  entityType?: string; entityId?: string; actionButtons?: ActionButton[];
}
export interface DeliveryResult { delivered: boolean; error?: string; }
export interface NotificationAdapter {
  readonly id: 'email' | 'telegram' | 'push';
  canDeliver(userId: string, type: NotificationType): Promise<boolean>;
  deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult>;
}
export interface SendEmailOptions {
  to: string; templateKey: string; vars: Record<string, string>; locale: 'he-IL' | 'en-US';
}
export interface NotificationPreferences {
  email: NotificationType[];
  telegram: NotificationType[];
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types build` type-checks with no errors.
- [ ] `NotificationType` is imported, never redefined as a value set in this package.

### Task 2: Database schema & migration — `push_subscriptions`, `notifications`, `adapter_credentials`
**Blocks:** 6, 7, 8, 10, 12, 14, 16  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/communications.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Create: `packages/db/migrations/0010_communications.sql` (drizzle-kit generated; name per next sequence number)
**Steps:**
- [ ] Define the three tables in Drizzle (`pgTable`) matching the DDL below exactly.
- [ ] `adapter_credentials` stores AES-256-GCM blobs: ciphertext + IV/nonce + auth tag as separate columns (a single TEXT cannot round-trip GCM cleanly). `adapter_id` is a CHECK over the known adapter set.
- [ ] `notifications.type` is `TEXT NOT NULL` with NO CHECK — the union lives in spec 97.
- [ ] Add the `notifications_inbox` and `idx_push_subs_user` indexes verbatim; add a GIN index is NOT required here (no JSONB WHERE filter on these tables).
- [ ] Generate and commit the SQL migration via `drizzle-kit generate`.
**Schema / Interfaces:**
```sql
CREATE TABLE push_subscriptions (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  endpoint     TEXT NOT NULL UNIQUE,          -- browser push endpoint URL
  p256dh       TEXT NOT NULL,                 -- client public key (base64url)
  auth         TEXT NOT NULL,                 -- client auth secret (base64url)
  user_agent   TEXT,                          -- for display in preferences UI
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_push_subs_user ON push_subscriptions (user_id, tenant_id);

CREATE TABLE notifications (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  type        TEXT NOT NULL,                  -- NotificationType union owned by spec 97; NO CHECK here
  title_key   TEXT NOT NULL,                  -- i18n key, rendered client-side at read time
  body_key    TEXT,                           -- i18n key, nullable (title-only notifications)
  params      JSONB NOT NULL DEFAULT '{}',    -- interpolation params for the keys
  entity_type TEXT,                           -- 'task' | 'invoice' | 'ticket' | ...
  entity_id   UUID,
  read_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX notifications_inbox ON notifications (tenant_id, user_id, read_at, created_at DESC);

CREATE TABLE adapter_credentials (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  adapter_id      TEXT NOT NULL CHECK (adapter_id IN ('gmail','outlook','telegram','slack','whatsapp','smtp')),
  -- AES-256-GCM encrypted credential blob (token/refresh-token/password JSON), keyed by INTEGRATION_ENCRYPTION_KEY:
  ciphertext      TEXT NOT NULL,              -- base64
  iv              TEXT NOT NULL,              -- base64, 12-byte GCM nonce
  auth_tag        TEXT NOT NULL,              -- base64, 16-byte GCM auth tag
  metadata        JSONB NOT NULL DEFAULT '{}',-- non-secret display fields (bot username, from-address, account email)
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, adapter_id)
);
CREATE INDEX idx_adapter_credentials_tenant ON adapter_credentials (tenant_id);
```
**Acceptance:**
- [ ] Migration applies cleanly to a fresh Neon branch (`pnpm db:migrate`).
- [ ] All FKs are UUID→UUID; `notifications.type` has no CHECK; `adapter_credentials` has `UNIQUE(tenant_id, adapter_id)` and three separate GCM columns.

### Task 3: AES-256-GCM credential crypto helpers
**Blocks:** 7, 14  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/auth/src/crypto.ts` (shared helper module already designated for crypto)
- Create: `packages/notifications/src/credentials.ts`
**Steps:**
- [ ] Implement `encryptCredential(plaintext: string, key: string): { ciphertext: string; iv: string; authTag: string }` using WebCrypto `AES-GCM` with a random 12-byte IV, importing `INTEGRATION_ENCRYPTION_KEY` (base64) as a 256-bit key.
- [ ] Implement `decryptCredential(blob, key): string`.
- [ ] In `credentials.ts`, add `saveAdapterCredential(db, tenantId, adapterId, secretJson, metadata)` and `loadAdapterCredential(db, tenantId, adapterId)` that wrap the table with encrypt/decrypt and `tenantQuery`.
**Schema / Interfaces:**
```ts
// packages/auth/src/crypto.ts
export function encryptCredential(plaintext: string, keyB64: string): Promise<{ ciphertext: string; iv: string; authTag: string }>;
export function decryptCredential(blob: { ciphertext: string; iv: string; authTag: string }, keyB64: string): Promise<string>;
```
**Acceptance:**
- [ ] Round-trip test: `decryptCredential(await encryptCredential(s, k), k) === s` for multi-line JSON `s`.
- [ ] Distinct IV per call (two encryptions of the same plaintext produce different ciphertext).

### Task 4: Native rate-limiting middleware
**Blocks:** 14  ·  **Blocked by:** —
**Files:**
- Create: `packages/auth/src/rate-limit.ts`
- Modify: `packages/auth/src/index.ts` (export `rateLimit`)
**Steps:**
- [ ] Implement `rateLimit(binding, key)` returning a Hono middleware that calls `binding.limit({ key })` and returns `429 { error: 'Too many requests' }` on `!success`.
- [ ] Document the four application points (login/signup/reset-password/webhooks) as comments; actual wiring on auth routes is foundation-auth-rbac's; webhook wiring is Task 14.
**Schema / Interfaces:**
```ts
// packages/auth/src/rate-limit.ts
import type { Context, Next } from 'hono';
export function rateLimit(binding: RateLimit, key: string) {
  return async (c: Context, next: Next) => {
    const { success } = await binding.limit({ key });
    if (!success) return c.json({ error: 'Too many requests' }, 429);
    await next();
  };
}
```
**Acceptance:**
- [ ] Middleware short-circuits with 429 when `binding.limit` returns `{ success: false }`.
- [ ] Calls `next()` on success.

### Task 5: Email core — Resend client, MJML templates, locale-aware `sendEmail`
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/notifications/src/email/resend.ts`
- Create: `packages/notifications/src/email/send-email.ts`
- Create: `packages/notifications/src/email/render.ts`
- Create: `packages/notifications/src/templates/invoice-sent.he.mjml`
- Create: `packages/notifications/src/templates/invoice-sent.en.mjml`
- Create: `packages/notifications/src/templates/verification.he.mjml`
- Create: `packages/notifications/src/templates/verification.en.mjml`
- Create: `packages/notifications/src/templates/invitation.he.mjml`
- Create: `packages/notifications/src/templates/invitation.en.mjml`
- Create: `packages/notifications/src/templates/password-reset.he.mjml`
- Create: `packages/notifications/src/templates/password-reset.en.mjml`
**Steps:**
- [ ] `resend.ts`: POST to Resend `/emails` REST API with `RESEND_API_KEY` (no SMTP for system mail).
- [ ] `send-email.ts`: implement `sendEmail(opts: SendEmailOptions, env)`. Default `locale` to tenant `settings.locale` when caller omits it; NEVER default to `en-US` (IL-first → Hebrew fallback `he`).
- [ ] `render.ts`: compile MJML→HTML, interpolate `vars`, and set `<html lang>` + `<body dir>` from `locale` independently of the template-key lookup. For `he`/`he-IL`: `dir="rtl" lang="he"`; for `en`/`en-US`: `dir="ltr" lang="en"`.
- [ ] One template file per locale per type, selected by `(templateKey, locale)`.
**Schema / Interfaces:**
```ts
// packages/notifications/src/email/send-email.ts
import type { SendEmailOptions } from '@zync/types';
export async function sendEmail(opts: SendEmailOptions, env: Env): Promise<void>;
// He template <body> MUST render: <body dir="rtl" lang="he" style="font-family:'Heebo',Arial,sans-serif;">
```
**Acceptance:**
- [ ] `sendEmail` with `locale: 'he-IL'` produces HTML whose `<body>` carries `dir="rtl"` and `lang="he"`.
- [ ] Omitting `locale` resolves to tenant locale, never hard-coded `en-US`.
- [ ] Resend request includes the rendered `html` and a plain-text `body` fallback.

### Task 6: `EmailNotificationAdapter`
**Blocks:** 9  ·  **Blocked by:** 2, 5
**Files:**
- Create: `packages/notifications/src/adapters/email.ts`
**Steps:**
- [ ] Implement `NotificationAdapter` with `id: 'email'`.
- [ ] `canDeliver(userId, type)`: read `user_preferences.notification_channels.email` via `tenantQuery`; return true iff `type ∈ email[]`.
- [ ] `deliver`: resolve recipient email + locale (staff→tenant locale per the locale-resolution table), map `DeliverableNotification` → `SendEmailOptions` (templateKey by type), call `sendEmail`. Render `actionButtons` as email link buttons (drop `callbackAction`).
**Schema / Interfaces:**
```ts
export class EmailNotificationAdapter implements NotificationAdapter {
  readonly id = 'email';
  canDeliver(userId: string, type: NotificationType): Promise<boolean>;
  deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult>;
}
```
**Acceptance:**
- [ ] `canDeliver` returns false when the type is absent from `notification_channels.email`.
- [ ] `deliver` is a silent no-op (no throw) when `canDeliver` would be false.

### Task 7: `TelegramNotificationAdapter` + Telegram comms adapter
**Blocks:** 9, 14  ·  **Blocked by:** 2, 3
**Files:**
- Create: `packages/notifications/src/adapters/telegram.ts`
- Create: `packages/notifications/src/comms/telegram.ts`
**Steps:**
- [ ] `telegram.ts` (notification): `id: 'telegram'`; `canDeliver` reads `notification_channels.telegram` AND requires a linked Telegram chat for the user; loads the tenant bot token via `loadAdapterCredential(db, tenantId, 'telegram')`.
- [ ] `deliver`: Telegram Bot API `sendMessage` (HTML parse mode) / `sendDocument`. Render `actionButtons`: `url` → deep link (group/list), `callbackAction` → inline keyboard (DM only).
- [ ] `comms/telegram.ts` (CommsAdapter): `send` (outbound `sendMessage`/`sendDocument`), `receive(payload)` parsing Telegram update → `InboundMessage`. Helpers `setTelegramWebhook(token, tenantId)` (`setWebhook` to `https://zync.is/api/webhooks/telegram/{tenantId}`), `deleteTelegramWebhook(token)`, `validateBotToken(token)` (`getMe`).
- [ ] Tier gate noted: Telegram requires `requireTier('business')` at the settings/route layer.
**Acceptance:**
- [ ] `validateBotToken` returns the bot identity on a valid token, throws on invalid.
- [ ] Outbound `sendMessage` uses the tenant's own decrypted bot token, not a global token.

### Task 8: `WebPushNotificationAdapter` + VAPID signing
**Blocks:** 9, 10  ·  **Blocked by:** 2
**Files:**
- Create: `packages/notifications/src/adapters/web-push.ts`
- Create: `packages/notifications/src/web-push/vapid.ts`
- Create: `packages/notifications/src/web-push/send.ts`
**Steps:**
- [ ] `vapid.ts`: implement RFC 8292 VAPID JWT signing (ES256 over P-256) and RFC 8030 payload encryption (aes128gcm) using WebCrypto; keys from `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY`.
- [ ] `send.ts`: `sendWebPush(sub, notification, vapidPriv, vapidPub)` POSTs the encrypted payload to `sub.endpoint`. Returns status; surface `410 Gone`.
- [ ] `web-push.ts`: `id: 'push'`; `canDeliver` returns true iff ≥1 `push_subscriptions` row for the user (Drizzle count). `deliver` loads subs, `Promise.allSettled(sendWebPush)`, prunes rows that returned 410 (`pruneExpiredSubscriptions`), returns `{ delivered: results.some(fulfilled) }`.
- [ ] Build the JSON payload `{ title, body, url, tag, type }`.
**Schema / Interfaces:**
```ts
// payload sent to the browser push endpoint:
// { "title": string, "body": string, "url": string, "tag": "<type>_<entityId>", "type": NotificationType }
export class WebPushNotificationAdapter implements NotificationAdapter {
  readonly id = 'push';
  canDeliver(userId: string, type: NotificationType): Promise<boolean>;
  deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult>;
}
```
**Acceptance:**
- [ ] A subscription endpoint returning 410 is deleted from `push_subscriptions` after `deliver`.
- [ ] `canDeliver` is false when the user has zero subscriptions.

### Task 9: Delivery fanout pipeline + WebSocket push seam
**Blocks:** 12  ·  **Blocked by:** 6, 7, 8
**Files:**
- Create: `packages/notifications/src/deliver.ts`
- Create: `packages/notifications/src/ws-push.ts`
- Modify: `packages/notifications/src/index.ts` (export `deliverNotification`, adapters, `sendEmail`)
**Steps:**
- [ ] `deliver.ts`: `const ADAPTERS = [emailAdapter, telegramAdapter, webPushAdapter]`; `deliverNotification(userId, notification)` runs `Promise.allSettled` over adapters guarded by `canDeliver`.
- [ ] `ws-push.ts`: `pushOverWebSocket(userId, tenantId, notification, env)` — look up `ws_session:{userId}:{tenantId}` in KV; if a DO id is present, forward to that Durable Object (owned by tasks-detail-communication); if absent, no-op (client polls every 30s). Best-effort, never throws.
- [ ] Export the package public surface from `index.ts`.
**Acceptance:**
- [ ] One adapter throwing does not prevent the others from delivering (allSettled).
- [ ] `pushOverWebSocket` returns silently when no `ws_session` KV key exists.

### Task 10: Web Push subscription API
**Blocks:** 13  ·  **Blocked by:** 2, 8
**Files:**
- Create: `apps/zync-api/src/routes/push.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `/api/push`)
**Steps:**
- [ ] `GET /api/push/vapid-public-key` → `{ publicKey: env.VAPID_PUBLIC_KEY }` (public; no auth).
- [ ] `POST /api/push/subscribe` (session auth): upsert `push_subscriptions` keyed on unique `endpoint` (re-subscribe updates `p256dh`/`auth`/`user_agent`/`last_used_at`); respond 201.
- [ ] `DELETE /api/push/subscribe` (session auth): delete the row matching `{ endpoint }`; respond 204.
- [ ] Apply CSP/security-headers middleware already present in `zync-api`.
**Schema / Interfaces:**
```
GET    /api/push/vapid-public-key → 200 { publicKey: string }
POST   /api/push/subscribe        → 201   body { endpoint, keys:{ p256dh, auth }, userAgent? }
DELETE /api/push/subscribe        → 204   body { endpoint }
```
**Acceptance:**
- [ ] Re-subscribing with an existing endpoint updates rather than duplicates (UNIQUE upsert).
- [ ] vapid-public-key endpoint requires no session.

### Task 11: ServiceWorker push handling + opt-in UX
**Blocks:** —  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/public/sw.js`
- Create: `apps/zync-app/src/push/usePushOptIn.ts`
- Create: `apps/zync-app/src/push/PushSoftPrompt.tsx`
**Steps:**
- [ ] `sw.js`: `push` listener → `showNotification(data.title, { body, icon:'/icons/zync-192.png', badge:'/icons/badge-72.png', data:{url}, tag })`; `notificationclick` listener → close + `clients.openWindow(data.url)`.
- [ ] `usePushOptIn`: register SW, fetch VAPID public key, `Notification.requestPermission()`, `pushManager.subscribe`, then `POST /api/push/subscribe`. Cache permission state in `localStorage`; never re-prompt if denied.
- [ ] `PushSoftPrompt`: after 30s on first login, show soft prompt "Enable desktop notifications for invoices and tasks?" → [Enable]/[Not now]. Respect `prefers-reduced-motion` for any prompt animation; correct aria roles on the dialog; RTL-aware layout.
**Acceptance:**
- [ ] Clicking a delivered push opens the `data.url` tab.
- [ ] Denied permission is cached and the soft prompt does not reappear.
- [ ] Soft prompt dialog has `role="dialog"`/`aria-modal` and honors reduced-motion.

### Task 12: In-app notifications API + create helper
**Blocks:** 13  ·  **Blocked by:** 2, 9
**Files:**
- Create: `apps/zync-api/src/routes/notifications.ts`
- Create: `packages/db/src/queries/notifications.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `/api/notifications`)
**Steps:**
- [ ] `packages/db/src/queries/notifications.ts`: `createNotification(db, { tenantId, userId, type, titleKey, bodyKey?, params?, entityType?, entityId? })` inserts a row (NO pre-rendered text — keys + params only), then synchronously calls `pushOverWebSocket` and fire-and-forget `deliverNotification` fanout.
- [ ] `GET /api/notifications`: list unread + last 20 read for the session user/tenant, ordered via `notifications_inbox` index.
- [ ] `POST /api/notifications/read-all`: set `read_at = now()` for all unread of the user/tenant.
- [ ] `PATCH /api/notifications/:id/read`: set `read_at` for one (scoped to the session user/tenant).
**Schema / Interfaces:**
```
GET   /api/notifications           → 200 { unread: Notification[], read: Notification[] }  // read capped at 20
POST  /api/notifications/read-all  → 200
PATCH /api/notifications/:id/read  → 200
// createNotification(db, input): Promise<{ id: string }>  — synchronous insert + WS push + adapter fanout
```
**Acceptance:**
- [ ] Stored rows contain `title_key`/`body_key`/`params`, never rendered strings.
- [ ] `read-all` zeroes the unread count for that user/tenant only (tenant-isolated).

### Task 13: Notification dropdown UI
**Blocks:** —  ·  **Blocked by:** 10, 12
**Files:**
- Create: `apps/zync-app/src/components/NotificationDropdown.tsx`
- Create: `apps/zync-app/src/hooks/useNotifications.ts`
**Steps:**
- [ ] Bell icon in Header with unread badge count; `aria-label` localized; badge has accessible text.
- [ ] Dropdown shows last 20, grouped Today / Earlier; each item: type icon, `t(title_key, params)`, `t(body_key, params)` (skip body if null), entity link, relative timestamp.
- [ ] "Mark all read" action → `POST /api/notifications/read-all`.
- [ ] Unread count refresh: 30s polling fallback + live WebSocket push merge; reconcile so WS-delivered items dedupe against polled list by `id`.
- [ ] RTL-aware alignment; reduced-motion-safe open/close.
**Acceptance:**
- [ ] Titles/bodies render via `react-i18next` from keys+params (locale switch re-renders without re-fetch).
- [ ] Dropdown is keyboard-navigable with correct aria roles; RTL layout correct in `he`.

### Task 14: Inbound webhook receivers (Telegram / Slack / WhatsApp)
**Blocks:** 15  ·  **Blocked by:** 3, 4, 7
**Files:**
- Create: `apps/zync-api/src/routes/webhooks/telegram.ts`
- Create: `apps/zync-api/src/routes/webhooks/slack.ts`
- Create: `apps/zync-api/src/routes/webhooks/whatsapp.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `/api/webhooks/*` with `rateLimit(RATE_LIMITER_WEBHOOK, ip)`)
**Steps:**
- [ ] `POST /api/webhooks/telegram/:tenantId`: look up tenant bot token via `loadAdapterCredential(tenantId,'telegram')`; parse update → `InboundMessage`; enqueue `{ tenantId, message }` to the `comms.inbound` queue. Verify Telegram secret token header if configured.
- [ ] `POST /api/webhooks/slack`: handle URL-verification challenge, verify Slack signing secret (timing-safe HMAC equality), parse event/slash command → enqueue.
- [ ] `POST /api/webhooks/whatsapp/:tenantId`: Meta verification GET challenge + POST inbound; gate `requireTier('enterprise')`; verify signature timing-safe; enqueue.
- [ ] Apply `RATE_LIMITER_WEBHOOK` (100/min/IP) to all webhook routes.
**Acceptance:**
- [ ] Slack/WhatsApp signature checks use timing-safe equality (no early-return string compare).
- [ ] WhatsApp routes return 403 below Enterprise tier.
- [ ] Each accepted inbound enqueues exactly one `comms.inbound` message and returns 200 fast (processing is async).

### Task 15: Inbound queue consumer + routing
**Blocks:** —  ·  **Blocked by:** 14, 16
**Files:**
- Create: `apps/zync-api/src/queues/comms-inbound.ts`
- Modify: `apps/zync-api/src/index.ts` (register queue consumer for `comms.inbound`)
**Steps:**
- [ ] Consumer decodes each message, loads `TenantCommsConfig`, calls `routeInboundMessage(msg, config)`.
- [ ] Ack on success; rely on Queue retry/backoff on throw.
**Acceptance:**
- [ ] A message with `autoCreateTickets=true` results in a `createSupportTicket` call (mockable seam).
- [ ] Consumer is idempotent enough that a retried message does not double-create (dedupe by provider message id).

### Task 16: Inbound routing logic
**Blocks:** 15  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/queries/inbound.ts`
**Steps:**
- [ ] Implement `routeInboundMessage(msg, tenantConfig)`: if `tenantConfig.autoCreateTickets` → `createSupportTicket(msg)`; if `tenantConfig.aiAssistantEnabled` → `dispatchToAiAssistant(msg)`. Both targets are seams owned by crm-support-center / ai-assistant — define typed function stubs that those specs implement, wired via dependency injection, with no placeholder bodies that swallow errors.
- [ ] Define `TenantCommsConfig` type (`autoCreateTickets: boolean; aiAssistantEnabled: boolean`).
**Schema / Interfaces:**
```ts
export interface TenantCommsConfig { autoCreateTickets: boolean; aiAssistantEnabled: boolean; }
export async function routeInboundMessage(msg: InboundMessage, cfg: TenantCommsConfig): Promise<void>;
```
**Acceptance:**
- [ ] Routing respects both config flags independently (both can fire for one message).

### Task 17: Wrangler bindings, queue & secrets wiring
**Blocks:** —  ·  **Blocked by:** 4, 14, 15
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
**Steps:**
- [ ] Add native rate-limit bindings `RATE_LIMITER_AUTH` (`limit=10, period=60`) and `RATE_LIMITER_WEBHOOK` (`limit=100, period=60`).
- [ ] Declare the `comms.inbound` queue producer binding + consumer config (this spec owns it; name matches dotted convention `webhook.deliver`-style).
- [ ] Register secrets `RESEND_API_KEY`, `INTEGRATION_ENCRYPTION_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY` (via `wrangler secret put`, documented in the deploy runbook).
**Schema / Interfaces:**
```toml
[[unsafe.bindings]]
name = "RATE_LIMITER_AUTH"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 10, period = 60 }

[[unsafe.bindings]]
name = "RATE_LIMITER_WEBHOOK"
type = "ratelimit"
namespace_id = "1002"
simple = { limit = 100, period = 60 }

[[queues.producers]]
binding = "QUEUE"
queue = "comms-inbound"

[[queues.consumers]]
queue = "comms-inbound"
max_batch_size = 10
max_retries = 5
```
**Acceptance:**
- [ ] `wrangler deploy --dry-run` validates the config with both rate limiters and the queue.

### Task 18: i18n keys for all NotificationType values
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/ui/src/locales/en.json`
- Modify: `packages/ui/src/locales/he.json`
**Steps:**
- [ ] For every `NotificationType` value (30+, including `task_assigned`, `task_comment`, `user_approved`, `user_frozen`, `invoice_paid`, …), add `notification.<type>.title` and `notification.<type>.body` keys to both `en.json` and `he.json` with interpolation placeholders matching `params` (e.g. `{{invoiceNumber}}`, `{{amount}}`).
- [ ] Keep the two files key-aligned (no missing keys in either locale).
**Acceptance:**
- [ ] Every NotificationType has matching `title`/`body` keys in both `en.json` and `he.json`.
- [ ] A lint/test asserting key-parity between the two locale files passes.
