222 lines
10 KiB
Markdown
222 lines
10 KiB
Markdown
# 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.*
|