# Radical fix plan: admin applications, admin results, and dashboard consistency

This document captures how the current stack is wired, what went wrong with “save decision → see it in **Kết quả đăng ký**”, and a **phased, radical** plan to make the behavior **reliable by design**—not only by patching one screen.

---

## 1. Problem statement (what users experience)

1. An admin uses **Mẫu hồ sơ và Minh chứng** → **Duyệt (xem trước)** → **Xác nhận** (`AdminStaffReadonlyReviewDialog` + `upsertAdminApplicationResult`).
2. They expect the submission to appear under **Kết quả đăng ký** (`ConsideredInitiativesList` → `ApprovedApplicationsList` with `lifecycle=decided`).
3. Sometimes nothing changes, or behaviour is confusing, because **several independent subsystems** must stay aligned: HTTP client semantics, API routes, PostgreSQL `initiatives.status`, optional file fallback, React Query keys, and optional MinIO (evidence—not the same as admin result).

---

## 2. Implementation map (how it fits together)

### 2.1 Frontend

| Piece | Role |
|--------|------|
| `ConsideredInitiativesList` | Renders `ApprovedApplicationsList` with `lifecycle="decided"` — only rows whose **list `status`** is `approved` or `rejected`. |
| `ApprovedApplicationsList` | `useQuery(["applications", filters])` → `GET /api/applications` with filters including `lifecycle`. |
| `AdminDocxTemplatePreview` | Opens `AdminStaffReadonlyReviewDialog`; confirm calls `upsertAdminApplicationResult`. |
| `applicationAdminResultApi` | `fetch` + `create` / `update`; `upsert` = **GET then POST or PUT**. |
| `shared/api/client.ts` | Axios `validateStatus: (s) => s < 500` — **4xx responses do not throw** unless callers pass an override. |

### 2.2 Backend

| Piece | Role |
|--------|------|
| `POST/PUT/GET/DELETE …/admin-result` | Persists `application_admin_results` and sets **`initiatives.status`** to `approved` / `rejected` (PostgreSQL). |
| `GET /api/applications` | **Primary:** `list_submitted_applications` from Postgres. **Fallback:** `_load_submitted_items()` file index if Postgres fails or is disabled. |
| `GET /api/applications/{id}` | Same pattern: Postgres first, then file index. |
| MinIO (S3-compatible) | Evidence / attachments buckets; **not** where admin “decision rows” live. Decisions are **Postgres**; MinIO only matters for evidence flows. |

### 2.3 Data flow (intended)

```mermaid
sequenceDiagram
  participant UI as Admin UI
  participant API as be0 FastAPI
  participant PG as Postgres
  participant List as GET /api/applications

  UI->>API: upsert admin-result (POST or PUT)
  API->>PG: application_admin_results + initiatives.status
  UI->>API: invalidate + refetch applications
  List->>PG: list initiatives + drafts → status approved/rejected
  List-->>UI: decided list
```

Breakage happens when **any** step returns “success” without real data, reads from a **different backend** than writes, or treats **HTTP 404** as a valid JSON body.

---

## 3. Root causes (systemic, not one-line bugs)

### A. Global Axios: 4xx treated as success (`validateStatus < 500`)

- For `GET /api/.../admin-result` with **no row**, the server returns **404** + `{ detail: "…" }`.
- With the default client, that **resolves** instead of rejects.
- Any code that checks `if (!data)` **fails** → truthy object `{ detail }` looks like “existing result”.
- **`upsert`** then chose **PUT** instead of **POST** on first save → no row created, **silent wrong success** possible.

**Partial fix already applied:** pass `axiosSuccessStatusOnly` (2xx-only) on all `applicationAdminResultApi` calls.

**Radical fix:** see §5.1 — stop relying on opt-in overrides.

### B. Client upsert = GET + mutate (race + footguns)

- Two round trips; duplicated server rules; easy to get wrong if GET semantics change.
- Prefer **idempotent server upsert** (`PUT` with “create or replace” semantics) **or** `POST`-only with clear 409 handling.

### C. Dual source of truth for application lists (Postgres vs file index)

- Listing can **silently fall back** to `_load_submitted_items()` when Postgres throws.
- Admin-result writes **only** hit Postgres.
- Result: UI can show **stale or empty** “decided” data while DB was actually updated (or the reverse in dev).

### D. React Query key `["applications", filters]`

- Invalidate `["applications"]` is correct for TanStack Query partial matching, but **any** cache/subscription edge case should be covered by tests.

### E. MinIO vs admin decision (scope confusion)

- **Fixing “Kết quả đăng ký”** does not require MinIO updates.
- Evidence upload paths are separate; do not conflate in testing or plans.

---

## 4. Verification already performed (baseline)

- **Docker:** `postgres`, `minio`, `be0` healthy; `GET /api/v1/test` OK.
- **Postgres + `create_admin_result`:** `initiatives.status` and `application_admin_results` stay in sync when using the Python layer directly.
- **Integration test `test_applications_db_integration`:** one failing test (`get_application_by_id` fallback `sub-…` without `submissionRecord.id`) — suggests **ID-resolution** edge cases still risky; align with list/GET contract in the same plan.
- **Host Python** may lack `boto3`; validate MinIO from **`be0` container** or install dev deps locally for S3 tests.

---

## 5. Radical fixes (options, from smaller to larger)

### 5.1 Frontend: default to real HTTP semantics (highest leverage)

**Goal:** No API call “succeeds” with a 404/422 body unless explicitly handled.

**Options:**

1. **Change default `validateStatus` to 2xx-only** in `ApiClient`, then **globally fix** call sites that depended on reading `{ detail }` from a resolved 4xx response (likely few; grep for patterns).
2. **Two clients:** `apiClientStrict` (2xx-only) for CRUD and `apiClient` legacy only where needed—migrate modules incrementally.
3. **Response interceptor:** if `status >= 400`, reject with unified `ApiError` (preserves current “no throw on 4xx” idea but **never** returns `res.data` as success to `.then()`).

**Acceptance:** ESLint rule or CI script: forbid `apiClient.get/post/put/delete` without explicit `validateStatus` or wrapper.

### 5.2 Backend: atomic admin-result upsert

**Goal:** Single request, no client-side GET-before-POST.

- Expose **`PUT /api/applications/{id}/admin-result`** as **idempotent upsert** (create or update in one transaction), or document **`POST`** as upsert with unique constraint handling.
- Optionally return **updated application row** snippet (`status`, `applicationId`) so the client can patch cache without listing.

### 5.3 Backend: single listing source in production

**Goal:** No silent list fallback when `INITIATIVE_DATABASE_URL` is set.

- On Postgres enabled: **fail listing with 503** and a clear JSON error instead of falling back to files; or log + metrics + feature flag.
- Deprecate file index for `/api/applications` in environments where submissions are always in PG.

### 5.4 Contract tests (API + FE)

- **pytest/httpx:** `POST admin-result` → `GET /api/applications?lifecycle=decided` contains that `id`.
- **Playwright or MSW:** Admin flow confirm → list row appears (requires auth fixture).

### 5.5 Observability

- Structured logs: `application_id`, `initiative_id`, `decision`, `source=postgres|file_fallback`.
- Metrics: `applications_list_fallback_total`, `admin_result_upsert_duration_ms`.

---

## 6. Recommended phases (practical rollout)

| Phase | Scope | Outcome |
|-------|--------|---------|
| **P0** | Keep `axiosSuccessStatusOnly` on admin-result API; add **one** e2e/API test: upsert → decided list. | Regression guard. |
| **P1** | Introduce **strict HTTP** default or interceptor (§5.1); fix broken call sites. | Class of bugs eliminated. |
| **P2** | Backend **idempotent PUT** upsert; simplify client to single call. | Fewer races, simpler mental model. |
| **P3** | Remove or gate **file fallback** for `/api/applications` when PG is configured. | Align list with admin writes. |
| **P4** | Fix failing DB test for `submissionRecord.id` omission; document canonical `applicationId` rules. | Predictable IDs end-to-end. |

---

## 7. Out of scope / non-goals

- **MinIO** consistency for “admin approve” — wrong layer unless the feature explicitly writes objects on decision.
- **Council** flow (`saveCouncilReviewOutcome` / local storage) — separate product path; only mention if merging with admin outcomes.

---

## 8. Decision log (fill in as you implement)

| Date | Decision | Rationale |
|------|----------|-----------|
| | | |

---

## References (code)

- `fe0/src/shared/api/client.ts` — global `validateStatus`.
- `fe0/src/lib/applicationReviewApi.ts` — `axiosSuccessStatusOnly`.
- `fe0/src/lib/applicationAdminResultApi.ts` — admin-result CRUD + upsert.
- `fe0/src/components/admin/result/ConsideredInitiativesList.tsx` — decided list entry point.
- `be0/src/initiative_db/application_admin_results.py` — DB writes + `initiative.status`.
- `be0/main.py` — `list_applications` Postgres vs file fallback.
- `be0/tests/test_applications_db_integration.py` — Postgres integration tests.
