# Notification Center

**Date:** 2026-05-31
**Status:** Draft
**Spec:** 35
**Depends on:** `system-communications-notifications`, `app-shell`, `foundation-design-system`, `foundation-auth-rbac`, `error-empty-states`
**Referenced by:** `app-shell` ("View all" link from NotificationDropdown), `settings-module` (notification preferences link target)

---

## Overview

`/notifications` is the full notification history page. It extends the header dropdown (`NotificationDropdown` in `app-shell`) into a paginated, filterable archive of all notifications for the current user within the current tenant.

The dropdown already handles the real-time ambient case (last 20, WebSocket-pushed, unread badge). This page handles the archival case: "show me everything, let me filter it, let me find that notification from last week."

All roles (OWNER, ADMIN, MEMBER, VIEWER, CONTRACTOR) may view their own notifications. No cross-user access — the API enforces `user_id = auth.user_id`.

---

## Route

```
/notifications
```

No sub-routes. No URL params required — filters live in query string for shareability.

**Query string params (all optional):**

| Param | Values | Default |
|-------|--------|---------|
| `type` | `tasks` \| `invoices` \| `tickets` \| `mentions` \| `system` | (none — all) |
| `status` | `unread` | (none — all) |
| `cursor` | opaque cursor string | (none — first page) |

Example: `/notifications?type=tasks&status=unread`

---

## ASCII Wireframe

```
┌─────────────────────────────────────────────────────────────────────────┐
│  [App Shell Header — Bell, Avatar, Nav]              dir="rtl"          │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │  התראות                            [סמן הכל כנקרא]  [הגדרות ▸]  │  │
│  ├────────────────────────┬──────────────────────────────────────────┤  │
│  │  סינון                 │                                          │  │
│  │  ┌──────────────────┐  │  ● (unread) משימה הוקצתה לך             │  │
│  │  │ ▼ סוג: הכל       │  │    "בדוק הצעת מחיר ל-Acme"             │  │
│  │  └──────────────────┘  │    לפני 10 דקות ← tasks link           │  │
│  │  ┌──────────────────┐  │  ─────────────────────────────────────  │  │
│  │  │ ▼ סטטוס: הכל     │  │  ○ (read) תגובה על כרטיס תמיכה        │  │
│  │  └──────────────────┘  │    "לקוח X השיב לפנייה #1042"          │  │
│  │                        │    לפני שעתיים ← ticket link           │  │
│  │  ─────────────────     │  ─────────────────────────────────────  │  │
│  │  50 התראות             │  ○ (read) חשבונית שולמה                 │  │
│  │                        │    "קיבלת תשלום ₪4,500 מ-Corp Ltd"     │  │
│  │                        │    אתמול ← invoice link                │  │
│  │                        │  ─────────────────────────────────────  │  │
│  │                        │  ... (more rows) ...                   │  │
│  │                        │                                          │  │
│  │                        │  ┌──────────────────────────────────┐   │  │
│  │                        │  │         [ טען עוד ]              │   │  │
│  │                        │  └──────────────────────────────────┘   │  │
│  └────────────────────────┴──────────────────────────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
```

**Layout notes:**
- Two-column card: narrow filter sidebar (200px fixed) on the right (RTL: inline-start), list column fills remaining width.
- On viewport < 768px: filters collapse to a top filter bar (row of Select components), sidebar hidden.
- Filter sidebar and list share a single card with a vertical divider.

---

## Component Breakdown

### Page component

`apps/zync-app/src/pages/notifications/NotificationsPage.tsx`

Responsibilities:
- Reads `type`, `status`, `cursor` from `useSearchParams()`
- Renders `NotificationsFilterSidebar` + `NotificationsList`
- Provides `useNotificationsQuery` (TanStack Query, infinite query)
- `markAllRead` mutation → `POST /api/notifications/read-all` → invalidate query

### NotificationsFilterSidebar

`apps/zync-app/src/pages/notifications/NotificationsFilterSidebar.tsx`

Two `Select` primitives (from `packages/ui`):

**Type filter:**
```
Select options:
  value=""     label="כל הסוגים"
  value="tasks"    label="משימות"
  value="invoices" label="חשבוניות"
  value="tickets"  label="כרטיסי תמיכה"
  value="mentions" label="אזכורים"
  value="system"   label="מערכת"
```

**Status filter:**
```
Select options:
  value=""       label="כל הסטטוסים"
  value="unread" label="לא נקראו"
```

On change: update query string via `setSearchParams()`. Changing a filter resets `cursor` param.

Below filters: total count label (`{n} התראות`) — shows filtered count from API response `meta.total`.

### NotificationsList

`apps/zync-app/src/pages/notifications/NotificationsList.tsx`

Renders the list of notification rows + loading skeleton + empty state + "Load more" button.

Props:
```ts
interface NotificationsListProps {
  items: NotificationRow[]
  isLoading: boolean
  isFetchingNextPage: boolean
  hasNextPage: boolean
  onLoadMore: () => void
  onMarkRead: (id: string) => void
}
```

### NotificationRow

`apps/zync-app/src/pages/notifications/NotificationRow.tsx`

Single row. Props:

```ts
interface NotificationRowProps {
  notification: NotificationRow
  onMarkRead: (id: string) => void
}
```

Layout (RTL row, `dir="rtl"`):
```
[unread dot | type icon] [title + body]                       [relative time]
```

- **Unread dot:** 8px circle `bg-accent` visible when `read_at` is null. Hidden (invisible, preserves spacing) when read — keeps rows aligned.
- **Type icon:** 20px inline SVG, color `--ink-faint`. One icon per category (see type-to-icon map below).
- **Title:** `text-sm font-medium text-ink`. One line, truncated.
- **Body:** `text-xs text-ink-faint`. One line, truncated. Hidden if null.
- **Relative time:** `text-xs text-ink-faint`, `white-space: nowrap`, pushed to inline-end.

Row background: `--surface` when unread, `--bg` when read.

Click anywhere on row:
1. If `read_at` is null → fire `PATCH /api/notifications/:id/read` (optimistic: mark read immediately in local cache).
2. Navigate to entity using `resolveEntityHref(notification)` (same function used by `NotificationDropdown` in app-shell).

`resolveEntityHref`:
```ts
function resolveEntityHref(n: NotificationRow): string | null {
  // Type-level deep-links: these notifications carry no entity_type/entity_id
  // (bulk_action_complete is keyed on jobId; exchange_rates_updated is tenant-wide),
  // so they are resolved by notification type before the entity guard below.
  const typeLinks: Record<string, string> = {
    bulk_action_complete:  '/settings/data',      // bulk job reuses import_jobs; view result there (spec 42)
    exchange_rates_updated: '/settings/business',  // confirm/apply suggested rates (spec, multi-currency)
  }
  if (typeLinks[n.type]) return typeLinks[n.type]

  if (!n.entity_type || !n.entity_id) return null
  const map: Record<string, string> = {
    // Core
    task:        `/tasks/${n.entity_id}`,
    invoice:     `/invoices/${n.entity_id}`,
    ticket:      `/support/${n.entity_id}`,
    project:     `/projects/${n.entity_id}`,
    // Extended entity types
    lead:        `/crm/leads/${n.entity_id}`,
    proposal:    `/proposals/${n.entity_id}`,
    contract:    `/contracts/${n.entity_id}`,
    expense:     `/expenses/${n.entity_id}`,
    time_entry:  `/time/${n.entity_id}`,
    payment:     `/invoices?highlight=${n.entity_id}`,   // payment → parent invoice list
    customer:    `/customers/${n.entity_id}`,
    user:        `/settings/users`,                       // user notifications → users settings page
    member:      `/settings/users`,                       // member invite/approval → users page
    kb_article:  `/kb/${n.entity_id}`,
    import:      `/settings/data`,                        // import_complete → data settings
    api_usage:   `/settings/api`,                         // quota_warning → API usage settings
    subscription: `/settings/plan`,                       // trial_expiring → plan page
  }
  return map[n.entity_type] ?? null
}
```

If `resolveEntityHref` returns null (e.g. `system` type with no entity): click only marks read, no navigation.

### Type-to-icon map

| NotificationType group | Icon |
|------------------------|------|
| `task_assigned`, `task_updated`, `task_comment`, `task_due_soon`, `task_overdue` | CheckSquare |
| `invoice_paid`, `invoice_overdue`, `invoice_approved`, `invoice_rejected` | FileText |
| `ticket_created`, `ticket_assigned`, `ticket_replied`, `ticket_sla_breach` | MessageSquare |
| `mention` | AtSign |
| `payment_received`, `dunning_step_reached` | CreditCard |
| `contract_signed`, `contract_expiring`, `contract_voided`, `contract_all_signed` | FileSignature |
| `proposal_viewed`, `proposal_accepted`, `proposal_rejected`, `proposal_expiring` | FileCheck |
| `lead_created`, `lead_assigned`, `lead_stage_updated`, `lead_converted`, `lead_reengagement_due` | UserPlus |
| `expense_submitted`, `expense_approved`, `expense_rejected` | Receipt |
| `project_budget_alert` | TrendingUp |
| `member_invited`, `user_approved`, `user_frozen`, `role_changed` | User |
| `time_entry_approved`, `time_period_locked` | Clock |
| `import_complete`, `bulk_action_complete`, `exchange_rates_updated` | RefreshCw |
| `system`, `trial_expiring`, `quota_warning` (fallback) | Bell |

(Icons from `lucide-react`, already a project dependency.)

### NotificationsLoadingSkeleton

`apps/zync-app/src/pages/notifications/NotificationsLoadingSkeleton.tsx`

Renders 8 skeleton rows. Each row matches `NotificationRow` layout exactly:

```
[8px circle block] [20px square block] [60% width rect, 14px h] [40% width rect, 12px h]  [64px rect]
```

Background: `--surface`. No animation (no shimmer, per spec constraint). `opacity: 0.5` on skeleton blocks using `background: var(--ink-faint)` at low opacity.

Shown:
- On initial page load (`isLoading === true`): replaces list entirely.
- On "Load more" (`isFetchingNextPage === true`): appended below existing rows (3 skeleton rows, not 8).

### PageHeader

Inside `NotificationsPage`, above the two-column card:

```
┌──────────────────────────────────────────────────────┐
│  התראות          [סמן הכל כנקרא]    [הגדרות התראות ▸] │
└──────────────────────────────────────────────────────┘
```

- **"סמן הכל כנקרא" button:** `variant="outline"` `size="sm"`. Disabled (+ `opacity: 0.5`) when there are no unread notifications (`meta.unreadCount === 0`). On click: `POST /api/notifications/read-all`, optimistic update — set all visible `read_at` to current timestamp.
- **"הגדרות התראות" link:** renders as `<a href="/profile/notifications">` via `Button asChild`. `variant="ghost"` `size="sm"`. Always visible. (The notification preferences page is owned by spec 97 `notification-preferences`; this link is a forward reference.)

---

## New API Endpoint

### `GET /api/notifications/all`

Distinct from `GET /api/notifications` (inbox-optimized: unread + last 20 read, no pagination).
This endpoint returns full paginated history with filtering.

**Location:** `apps/zync-api/src/routes/notifications.ts` — add handler alongside existing notification routes.

**Authentication:** standard auth middleware. `tenantId` and `userId` from auth context.

**Query parameters:**

```ts
interface NotificationsAllQuery {
  cursor?: string       // opaque cursor (base64-encoded `created_at + id`)
  limit?: number        // default 50, max 50
  type?: 'tasks' | 'invoices' | 'tickets' | 'mentions' | 'system'
  status?: 'unread'     // omit = all; 'unread' = unread only
}
```

**Type → NotificationType mapping (server-side):**
```ts
const typeGroupMap: Record<string, string[]> = {
  tasks:    ['task_assigned', 'task_updated', 'task_comment', 'task_due_soon', 'task_overdue'],
  invoices: ['invoice_paid', 'invoice_overdue', 'invoice_approved', 'invoice_rejected'],
  tickets:  ['ticket_created', 'ticket_assigned', 'ticket_replied', 'ticket_sla_breach'],
  mentions: ['mention'],
  system:   ['system', 'member_invited', 'user_approved', 'user_frozen', 'role_changed', 'trial_expiring', 'quota_warning'],
}
```

**Request example:**
```
GET /api/notifications/all?type=tasks&status=unread&limit=50
```

**Response shape:**

```ts
interface NotificationsAllResponse {
  data: NotificationRow[]
  meta: {
    nextCursor: string | null   // null = no more pages
    total: number               // total count matching current filters (for sidebar label)
    unreadCount: number         // total unread for current user (for "mark all" button state)
  }
}

interface NotificationRow {
  id: string
  type: string                  // NotificationType value e.g. 'task_assigned'
  title: string
  body: string | null
  entity_type: string | null
  entity_id: string | null
  read_at: string | null        // ISO 8601 or null
  created_at: string            // ISO 8601
}
```

**SQL query (Postgres, Neon):**

```sql
-- Cursor decode: cursor is base64(created_at_iso + '|' + id)
-- On first page: no cursor condition
-- On subsequent pages: (created_at, id) < (cursor_ts, cursor_id)  [DESC order]

SELECT
  id, type, title, body, entity_type, entity_id, read_at, created_at
FROM notifications
WHERE
  tenant_id = $1
  AND user_id = $2
  AND ($3::text[] IS NULL OR type = ANY($3))   -- type filter
  AND ($4 = false OR read_at IS NULL)           -- status=unread filter
  AND (
    $5::timestamptz IS NULL                     -- no cursor (first page)
    OR (created_at, id) < ($5, $6::uuid)        -- cursor pagination
  )
ORDER BY created_at DESC, id DESC
LIMIT $7 + 1;  -- fetch +1 to detect hasNextPage
```

If result length > `limit`: pop last row, encode `nextCursor` from the last kept row's `(created_at, id)`.

**`total` count:** a separate `COUNT(*)` query with the same `WHERE` conditions (without cursor, without LIMIT). Cached on the result — not recalculated per page turn.

**`unreadCount`:**
```sql
SELECT COUNT(*) FROM notifications
WHERE tenant_id = $1 AND user_id = $2 AND read_at IS NULL;
```

**Index note:** the existing `notifications_inbox` index covers `(tenant_id, user_id, read_at, created_at DESC)`. This is sufficient for the unread filter + ordering. For the type filter with no read filter, Postgres will use the same index (tenant_id + user_id prefix) and filter on `type` in-memory. At expected volumes (< 10K notifications per user) this is acceptable. No new index is needed.

**Error responses:**

| Status | Condition |
|--------|-----------|
| 400 | `limit` > 50 or non-integer |
| 400 | `type` not in allowed enum values |
| 400 | `status` not `"unread"` |
| 401 | Unauthenticated |
| 403 | Authenticated but no tenant context |

---

## Schema Changes

No new tables. No new indexes required (see index note above).

The existing `notifications` table and `notifications_inbox` index are sufficient.

---

## Frontend Data Layer

### TanStack Query setup

`apps/zync-app/src/pages/notifications/useNotificationsQuery.ts`

```ts
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'

export function useNotificationsQuery() {
  const [searchParams] = useSearchParams()
  const type = searchParams.get('type') ?? undefined
  const status = searchParams.get('status') ?? undefined

  return useInfiniteQuery({
    queryKey: ['notifications', 'all', { type, status }],
    queryFn: ({ pageParam }) =>
      fetchNotificationsAll({ cursor: pageParam, type, status, limit: 50 }),
    getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined,
    initialPageParam: undefined,
    staleTime: 30_000,
  })
}

export function useMarkAllReadMutation() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: () => fetch('/api/notifications/read-all', { method: 'POST' }).then(r => r.json()),
    onMutate: async () => {
      // Optimistic: stamp all visible rows as read
      const now = new Date().toISOString()
      qc.setQueriesData({ queryKey: ['notifications', 'all'] }, (old: any) => ({
        ...old,
        pages: old.pages.map((page: any) => ({
          ...page,
          data: page.data.map((n: any) => ({ ...n, read_at: n.read_at ?? now })),
          meta: { ...page.meta, unreadCount: 0 },
        })),
      }))
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['notifications'] })
    },
  })
}

export function useMarkOneReadMutation() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (id: string) =>
      fetch(`/api/notifications/${id}/read`, { method: 'PATCH' }).then(r => r.json()),
    onMutate: async (id: string) => {
      const now = new Date().toISOString()
      qc.setQueriesData({ queryKey: ['notifications', 'all'] }, (old: any) => ({
        ...old,
        pages: old.pages.map((page: any) => ({
          ...page,
          data: page.data.map((n: any) =>
            n.id === id ? { ...n, read_at: n.read_at ?? now } : n
          ),
        })),
      }))
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['notifications'] })
    },
  })
}
```

Cache invalidation: both mark-read mutations invalidate `['notifications']` broadly, which refreshes the dropdown badge count and `NotificationsAllResponse.meta.unreadCount`.

---

## Empty States

All empty states use the `EmptyState` primitive from `packages/ui/src/data-display/empty-state.tsx`.

### No notifications at all (account is fresh)

```
heading: "עדיין לא קיבלת התראות"
action: null
```

Condition: `data.length === 0` AND no active filters.

### No results for current filter combination

```
heading: "לא נמצאו התראות עבור הסינון הנוכחי"
action: { label: "נקה סינון", onClick: clearFilters }
```

Condition: `data.length === 0` AND at least one filter is active (`type` or `status` param present).

`clearFilters` → `setSearchParams({})`.

---

## Loading States

| State | Behavior |
|-------|---------|
| Initial page load | Show `NotificationsLoadingSkeleton` (8 rows) in place of list |
| Changing a filter | Show `NotificationsLoadingSkeleton` (8 rows) — same as initial load; filters clear `cursor` and restart |
| "Load more" clicked | Append 3 `NotificationsLoadingSkeleton` rows below existing items; "Load more" button disabled |
| Mark-read (single) | Optimistic: dot disappears, background flips to `--bg` immediately |
| Mark all read | Optimistic: all visible dots disappear, all backgrounds flip immediately; "Mark all" button disabled |

No error boundary needed for mark-read mutations — silent failure is acceptable (stale state will correct on next query refresh).

---

## Relative Time Formatting

Use `formatRelativeTime(date: Date): string` from `packages/utils/src/date.ts` (add if not present):

```
< 1 min  → "עכשיו"
< 60 min → "לפני {n} דקות"
< 24 h   → "לפני {n} שעות"
< 7 days → "לפני {n} ימים"
≥ 7 days → absolute date: "12 ביוני 2026" (Hebrew locale date format)
```

Use `Intl.RelativeTimeFormat` with `{ locale: 'he-IL', numeric: 'always' }` for < 7 days, `Intl.DateTimeFormat` with `{ locale: 'he-IL', day: 'numeric', month: 'long', year: 'numeric' }` for ≥ 7 days.

---

## RTL Implementation Notes

- Page root: `dir="rtl"` (inherited from `<html>` in the app shell — no override needed on this page).
- Use `ps-*`/`pe-*` (padding-inline-start/end) and `ms-*`/`me-*` (margin-inline-start/end) throughout. No `pl-*`/`pr-*`/`ml-*`/`mr-*`.
- Filter sidebar sits at `inline-start` (right in RTL). In the wireframe this appears on the left in LTR ASCII — in the actual rendered RTL layout it sits on the right side of the card.
- The unread dot sits at `inline-start` of each row.
- Relative time sits at `inline-end` of each row.
- `text-align: start` on all text (not `left`/`right`).

---

## Design Decisions

| # | Decision | Choice | Rationale |
|---|----------|--------|-----------|
| 1 | Pagination style | "Load more" button, not infinite scroll | Infinite scroll breaks keyboard nav and back-button position; "Load more" is predictable |
| 2 | Filter placement | Sidebar (collapsing to top bar on mobile) | Two filters don't warrant a modal; sidebar keeps them visible without cluttering the list; RTL layout naturally places sidebar at inline-start |
| 3 | Cursor encoding | `base64(created_at + '\|' + id)` composite | Stable under concurrent inserts; avoids duplicate-row edge case on `created_at` ties |
| 4 | Separate `/api/notifications/all` endpoint | New endpoint, not overloading existing `/api/notifications` | Existing endpoint is inbox-optimized (unread + 20 read, no pagination); mixing pagination into it complicates caching and the dropdown usage |
| 5 | `total` count in response | Included in `meta` | Sidebar count label requires it; cheap COUNT with same WHERE at the index scan level |
| 6 | Skeleton over spinner | Structural skeleton | Reduces layout shift; matches spec constraint ("no spinner for pagination") |
| 7 | Mark-read on row click | Optimistic, fire-and-forget | Read state is low-stakes; user perceives instant response; server catches up on next invalidation |
| 8 | No new DB index | Existing `notifications_inbox` index sufficient | Index covers `(tenant_id, user_id, read_at, created_at DESC)`; type-filtered queries use index prefix scan and in-memory type filter at expected volumes |
| 9 | Notification preferences link | Forward reference to `/profile/notifications` | Page owned by spec 97; the link must exist to close the UX loop |
| 10 | System-type group mapping | Includes user lifecycle types | `member_invited`, `user_approved`, `user_frozen`, `role_changed` are operational/administrative — grouping under "מערכת" is cleaner than a separate "Users" filter category |

---

## File Map

```
apps/zync-app/src/pages/notifications/
  NotificationsPage.tsx              — page root, query orchestration
  NotificationsFilterSidebar.tsx     — type + status Select filters
  NotificationsList.tsx              — list container + empty state + load more
  NotificationRow.tsx                — single row, click handler, unread dot
  NotificationsLoadingSkeleton.tsx   — structural skeleton (8-row + 3-row variants)
  useNotificationsQuery.ts           — infinite query + mark-read mutations
  resolveEntityHref.ts               — entity_type → URL mapper (extracted from app-shell NotificationDropdown)

apps/zync-api/src/routes/notifications.ts
  — add GET /api/notifications/all handler alongside existing routes

packages/utils/src/date.ts
  — add formatRelativeTime() if not already present
```

---

## Out of Scope

- Notification preferences page (`/profile/notifications`) — owned by spec 97 `notification-preferences`.
- Bulk delete / archive — v2.
- Notification grouping (e.g. "3 comments on task X") — v2.
- Push notifications (browser/mobile) — separate spec.
- Admin view of other users' notifications — not permitted; enforced at API layer.
