# Analysis: notification system for admin → applicant (status & feedback)

This document describes **how the stack works today**, **what is missing** for true notifications, and a **concrete v1 path** aligned with **current repo complexity** (`fe0` / `be0` / PostgreSQL). It incorporates a **review** of the refined draft (`ADMIN_APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md` from review) and **adjusts** a few points for this codebase.

It complements `assets/APPLICANT_STATUS_NOTIFICATIONS_PLAN.md` (council + broader product) by anchoring on **`application_admin_results`** and **`PUT /api/applications/{applicationId}/admin-result`**.

---

## 0. Evaluation of the reviewed draft (summary)

The reviewed version improves the original repo doc in several ways; **adopt these**:

| Theme | Verdict |
|--------|---------|
| **Locked v1 scope** | In-app inbox only; ~60s polling + `refetchOnWindowFocus`; **no** email, MinIO PDF, WebSocket, or council unification in v1. Reduces scope and matches current team capacity. |
| **Append-on-every-save** | Explicit product choice: new row per admin save; optional **UI-only** collapsing of consecutive rows. Clear and simple. |
| **Schema pragmatism** | v1 `type` with `TEXT + CHECK`; **omit `JSONB` until a second notification type** exists. Fewer columns to maintain. |
| **Indexes** | `created_at DESC` inbox index + **partial index** on `(recipient_user_id) WHERE read_at IS NULL` for unread count. Appropriate. |
| **Security** | `PATCH .../read` returns **404** for foreign rows (same as missing id) to avoid user enumeration. Good default. |
| **Helper surface** | `notification_service.create_admin_decision_notification(...)` keeps the admin route thin and future council hook consistent. |

**Adjust for this repository (critical):**

1. **`application_id` type** — Public application identifiers in this project are **strings** (e.g. `sub-{hex}`, case codes), not UUIDs. The notification table should use **`application_id TEXT NOT NULL`** (or `VARCHAR`) for deep links, matching `ApplicationItem.id` and API paths—not `UUID`.
2. **“try/except without rolling back the decision”** — With **`get_session()`**, everything runs in **one transaction** that **commits once** at context exit. If the notification `INSERT` fails **after** the upsert has flushed, the **entire** transaction—including the decision—rolls back on exception, unless you:
   - use a **`begin_nested()` savepoint** around the notification insert (rollback to savepoint on failure, then continue), or
   - perform the notification insert in a **separate short session after** the admin-result transaction **commits** (best-effort second transaction).

   The reviewed draft’s intent (“decision is sacred; notification best-effort”) is right; the implementation must use one of the patterns above, not a bare `try/except` in the same flat transaction.

---

## 1. v1 scope (locked)

- **In-app notifications only:** PostgreSQL table + applicant `GET` / `PATCH` + `/dashboard/notifications` + optional header bell.
- **Delivery:** React Query `refetchInterval: 60_000` and `refetchOnWindowFocus: true` (no SSE/WebSocket in v1).
- **Write trigger:** successful **admin-result** upsert (same product semantics as today’s `AdminStaffReadonlyReviewDialog` / `ResultManager`).
- **Deferred to v2:** email + outbox worker, MinIO/PDF letter, council `localStorage` → API unification, notification preferences, i18n beyond Vietnamese, retention jobs.

---

## 2. Current state (baseline)

### 2.1 Data and decisions

| Layer | What exists |
|--------|-------------|
| **PostgreSQL** | `initiatives.status` ↔ **`application_admin_results`** on **`PUT …/admin-result`** (idempotent upsert). |
| **Applicant reads** | `GET /api/applications/mine`, `GET /api/applications/{id}` — status and **`nhan_xet`** can reflect admin feedback after submission enrichment. |
| **Admin** | Decided list `lifecycle=decided`; React Query invalidation on save. |
| **Council** | Some flows still use **`localStorage`**; not applicant-visible until server-backed (v2; see assets plan). |

**Gap:** No **`user_notifications`** (or equivalent); applicants only discover changes by refetching lists/detail.

### 2.2 Frontend

- **Sonner:** admin-only affordance at save time.
- **React Query:** `applications`, `applications-mine`, `application-detail`, etc.—no notification queries yet.
- **`/dashboard/notifications`:** linked from sidebars; **no page implementation** observed.

### 2.3 Backend

- FastAPI + transactional `get_session()`; natural hook: after admin-result body returns, or inside handler with savepoint / post-commit insert (see §0).

### 2.4 MinIO (v2 only for notifications)

Evidence/exports buckets are **not** the source of truth for notification text. Optional v2 PDF letter remains separate from v1.

---

## 3. Target architecture (v1)

```mermaid
flowchart LR
  subgraph admin [Admin UI]
    A[Confirm / ResultManager]
  end
  subgraph be [be0]
    B[PUT admin-result]
    C[Notification helper]
  end
  subgraph db [PostgreSQL]
    D[application_admin_results]
    E[initiatives]
    F[user_notifications]
  end
  subgraph fe [Applicant FE]
    H[Polling + onFocus]
    I[Inbox + bell]
  end
  A --> B
  B --> D
  B --> E
  B --> C
  C --> F
  F --> H
  H --> I
```

---

## 4. Database design (v1)

### 4.1 Principles

- **`application_admin_results`** remains canonical for full feedback/rationale.
- Notification rows are **summaries + pointer** to the public **`application_id` string** and optional FKs for audit.

### 4.2 Table: `user_notifications`

| Column | Type | Notes |
|--------|------|------|
| `id` | UUID PK | |
| `recipient_user_id` | UUID FK → `users.id` (`ON DELETE CASCADE`) | From `initiatives.owner_id` at insert. |
| `type` | `TEXT NOT NULL` | v1: `CHECK (type IN ('admin_application_decision'))`. |
| `title` | `TEXT NOT NULL` | e.g. “Kết quả duyệt hồ sơ”. |
| `body` | `TEXT NOT NULL` | Decision label + ~280 chars feedback (newline-stripped). |
| `application_id` | `TEXT NOT NULL` | **Public** id (`sub-…` / case-shaped), matches API list/detail. |
| `related_initiative_id` | UUID FK → `initiatives.id` (`ON DELETE SET NULL`) | |
| `source_admin_result_id` | UUID FK → `application_admin_results.id` (`ON DELETE SET NULL`) | |
| `read_at` | `TIMESTAMPTZ` nullable | |
| `created_at` | `TIMESTAMPTZ NOT NULL DEFAULT now()` | |

**v1:** no `payload JSONB`; add when a second `type` needs extra fields.

### 4.3 Indexes

```sql
CREATE INDEX user_notifications_inbox_idx
  ON user_notifications (recipient_user_id, created_at DESC);

CREATE INDEX user_notifications_unread_idx
  ON user_notifications (recipient_user_id)
  WHERE read_at IS NULL;
```

### 4.4 Insertion semantics

- **Product:** append **one row per successful admin-result save** (including typo fixes).
- **Technical:** implement **best-effort** notification with **savepoint** (`session.begin_nested()`) or **post-commit** insert so a notification failure **never** rolls back the adjudication. Document the chosen pattern in code comments next to the handler.

---

## 5. Backend API (`be0`)

### 5.1 Write path

After **`upsert_admin_result`** succeeds, resolve `owner_id`, build title/body, insert `user_notifications` via helper using the patterns in §4.4.

Sketch:

```text
notification_service.create_admin_decision_notification(
    session, *, initiative, admin_result_row, application_id_public: str, decision_label: str
) -> UserNotification | None
```

### 5.2 Read paths (applicant-only in v1)

| Method | Purpose |
|--------|---------|
| `GET /api/notifications` | Paginated; `recipient_user_id = current user`; fields: `id`, `type`, `title`, `body`, `read_at`, `created_at`, `application_id`, `related_initiative_id`. |
| `GET /api/notifications/unread-count` | Count unread; benefits from partial index. |
| `PATCH /api/notifications/{id}/read` | Set `read_at = now()` only if row belongs to user. |

**Authorization:** for `PATCH`, return **404** if row missing **or** not owned (same body as missing).

### 5.3 Relationship to applications API

Notifications complement **`GET /api/applications/{id}`** (status + feedback); they do not replace it.

---

## 6. Frontend (`fe0`)

- **Page:** implement **`/dashboard/notifications`**.
- **React Query:** `["notifications", { page }]` and `["notifications-unread-count"]`.
- **Polling:** `refetchInterval: 60_000`, `refetchOnWindowFocus: true`.
- **UX:** row click → `PATCH .../read` (optimistic unread decrement optional) → navigate using **`application_id`** string to existing applicant routes.
- **Bell:** subscribe to unread-count query; same polling cadence.
- **Admin UI:** no change required for v1 unless product adds “don’t notify” toggle later.

---

## 7. Security and privacy

- Scope all reads/patch to authenticated **recipient**.
- Do not log full notification bodies in verbose HTTP logs in production.
- `recipient_user_id` snapshot at insert time: historical rows stay with original recipient if ownership changes.

---

## 8. Rollout order (v1)

1. Migration: table + indexes.
2. SQLAlchemy model + `notification_service` helper (with savepoint or post-commit).
3. Wire **admin-result** handler.
4. Applicant `GET` list, `GET` unread-count, `PATCH` read.
5. FE: inbox page + bell.
6. Tests: notification appears after PUT (applicant token); PATCH read; PATCH foreign id → 404; **notification failure does not undo admin-result** (integration test with forced insert error).

---

## 9. v2 candidates (out of scope for v1)

| Item | Notes |
|------|------|
| Email | Outbox + worker; `user_notifications` stays canonical. |
| MinIO PDF | Generate on save; store artifact key; optional `payload` JSONB for metadata. |
| Council | New `type` + `CHECK` extension + second writer from council API. |
| Preferences / retention | Add when volume or compliance requires. |

---

## 10. Relation to other docs

- **`assets/APPLICANT_STATUS_NOTIFICATIONS_PLAN.md`** — council outcomes, workflow, broader audit. This file is the **admin-first, v1-scoped** implementation companion.

---

*Update when migrations land, when the transaction strategy (savepoint vs post-commit) is fixed in code, or when v2 scope is agreed.*
