# Research-project proposals + PI cockpit (frontend_investigator)

_Added 2026-06-14. A new parallel domain (independent of the sáng-kiến / initiative flow): a Principal
Investigator submits a research-project proposal (Thuyết minh đề tài, Mẫu III.06-TM.ĐTUD), an admin
approves it, then the PI manages the project via a "cockpit" (members / datasets / models / assets /
tiến độ + audit)._

## Shape (one-line)
PI app `frontend_investigator` → fill proposal → submit → **admin approves in `frontend_admin`** →
cockpit unlocks (auto-seeded from the proposal) → PI manages entities. Owner + admin authz; every
mutation writes an append-only audit row.

## Backend (`be0/`)
- **Migration `016_research_projects.sql`** — 7 tables:
  - `research_projects` — the aggregate root. **The proposal row IS the project** across its lifecycle
    (`status`: `draft → submitted → approved | rejected`). `content JSONB` holds the whole proposal
    form blob; a few **extracted scalars** (`title/level/pi_name/period_months/budget_total`) are columns
    for listing/overview. `code` (mã số) is null until approved. Review fields: `reviewed_by/at/note`.
  - `research_project_{members,datasets,models,assets,milestones}` — normalized child entity tables
    (FK → project, `ON DELETE CASCADE`), columns mirror the cockpit artifact's fields. Milestones use
    `start_period`/`end_period` (the JSON keys `start`/`end` map to them).
  - `research_project_audit` — append-only (BIGSERIAL; actor + action + subject + detail).
  - Registered in **4 places** (the stage-together rule): both compose files' `docker-entrypoint-initdb.d`
    + `apply_initiative_migrations.py` (`_needs_research_projects_migration` + guarded block) + the `.sql`.
- **`src/research_routes.py`** — router mounted at `/api/v1/research/*` (now the **5th** extracted router:
  auth · admin-audit · admin-user-profiles · templates · **research**). Endpoints:
  - Proposals: `POST/GET /projects`, `GET/PUT/DELETE /projects/{id}`, `POST /projects/{id}/submit`,
    `POST /projects/{id}/approve` (admin), `POST /projects/{id}/reject` (admin), `GET /projects/{id}/audit`.
  - Cockpit entities: `GET/POST /projects/{id}/entities/{entity}`, `PUT/DELETE …/{item_id}`,
    `GET /projects/{id}/cockpit` (one-shot bundle: project + 5 entity lists + recent audit).
  - **Generic config-driven CRUD** (`_ENTITY_CONFIG`): one whitelist `_apply_fields` maps json_key→column
    for all 5 entities (no column injection — sensitive cols absent from configs).
  - **Authz (v1) = owner OR platform admin.** Read hides others' rows with 404; submit/update are
    owner-only + draft-only; approve/reject are admin-only + submitted-only; entity mutations require the
    project to be **approved** (409 otherwise = "cockpit unlocks on approval"). `credential_version` is
    enforced by the global `main.py` middleware (routers use bare `decode_access_token_user_id`).
  - **Seed-on-approve** — `_seed_cockpit_from_proposal` populates members (chủ nhiệm + thư ký +
    thành viên) + milestones (tiến độ) from `content` so the cockpit opens "according to the proposal".
    Best-effort + idempotent.
- Auth model: a **PI = any authenticated user who owns a project** (no new system role). Admin via the
  existing allow-list mechanism (`AUTH_ADMIN_EMAILS` env, else the built-in default list in `auth_api.py`).

## Frontend
- **`frontend_investigator/`** — new Vite/React/TS app cloned from `frontend_user` (same design system by
  construction: `index.css` + tailwind tokens, Inter/Merriweather, shared shadcn primitives, auth flow,
  dashboard shell). Dev `:8083`→container `5175` (static IP `10.5.0.7`); prod `FE_INV_PORT`. Pages:
  `ProjectsListPage` (Đề tài của tôi), `ProposalFormPage` (schema-driven form), `CockpitPage`.
  Monorepo wiring: root `workspaces` += `frontend_investigator`; **all 4 Dockerfiles** COPY the new
  manifest (npm ci/install resolves); both compose files mount it into the FE services.
- **`shared/src/lib/researchApi.ts`** — typed client for `/api/v1/research/*` (+ barrel export); used by
  both `frontend_investigator` and `frontend_admin`.
- **Proposal form** (`components/proposal/`): `proposalSchema.ts` (the Mẫu III.06-TM.ĐTUD schema +
  buildInitial/shouldShow/collectMissing) + `ProposalFormFields.tsx` (renderer on shared shadcn) +
  `ProposalFormPage.tsx` (load/save-draft/submit; read-only for non-draft; approved → cockpit).
- **Cockpit** (`components/cockpit/`): `cockpitConfig.ts` (ENTITIES field defs + status tones) +
  `CockpitWidgets.tsx` (Badge/Stat/Bar/EntityCard/EntityDrawer) + `CockpitPage.tsx` (bundle via TanStack
  Query, tabbed Overview/entities/audit, CRUD mutations + cache invalidation, owner/admin-gated).
- **`frontend_admin`** — `ResearchReviewPage.tsx` (route `/research`, nav "Thẩm định đề tài", admin-gated):
  submitted-proposals queue + detail dialog (content read-only) + approve (assign mã số) / reject (note).

## Tests
- `be0/tests/test_research_routes.py` — **7 tests** (3 pure-unit scalar extraction + 4 DB integration:
  lifecycle, reject/authz, entity CRUD+coercion+seeding+approved-gate+audit+bundle, malformed-content
  seeding). Run: `docker exec be0 sh -lc 'cd /app && python -m unittest tests.test_research_routes'`.
- FE: `npm run typecheck` (×4 workspaces clean) + `npm run build -w frontend_investigator|frontend_admin`.

## Gotchas hit this session (blameless RCA)
1. **Migration COMMENT with semicolons** (016 v1) → the naive SQL splitter in
   `apply_initiative_migrations.py` splits on `;` even inside string literals → `unterminated quoted
   string`, the COMMENT failed (tables still created, since they commit before it). _5-why:_ the COMMENT
   body contained `; ` separators. _Fix:_ rewrote without semicolons (periods) + stripped accents.
   _Guard:_ already a documented rule (CLAUDE.md + reviewer memory) — keep COMMENT bodies semicolon/accent-free.
2. **Seeder crash on malformed content** (P2, reviewer-caught P1) — `for x in c.get(key) or []` only
   guards falsy; a truthy non-list (PI-controlled JSONB, e.g. `{"tienDoThucHien": 5}`) → `TypeError` →
   500 on approve. _Fix (double-fault: code + test):_ `v = c.get(key); rows = v if isinstance(v, list) else []`
   + regression test `test_seeding_survives_malformed_content`.

## Commits (local `main`)
`63e8bec` P1 schema+lifecycle · `c10ce1b` P2 entity APIs+seeding · `b561db4` P3 scaffold ·
`d3e7daf` P4 form · `8d186a6` P5 cockpit · `93cf6bf` P6 admin review · `b80cb64` admin allow-list.

## Eval / run
- Admin account provisioned for E2E: `ththinh@ump.edu.vn` (added to the default policy-admin list).
- Dev: PI app `localhost:5175`, admin `localhost:5174` (host vite servers, proxy `/api`→be0:4402).
- **Not deployed** (push ≠ deploy; no CI). Prod needs `scripts/deploy-prod.sh` + `FE_INV_PORT` set, and
  a deploy of the new `frontend_investigator` service.

## Follow-ups (not done)
- Bundle code-split (frontend_investigator ~1.6 MB — shared-deps bloat, same as frontend_user).
- Richer admin proposal view (currently a humanized key:value dump — moving `proposalSchema` into
  `@ump/shared` would let the admin reuse the labeled renderer).
- Per-member RBAC (v2) — link cockpit members to real accounts + enforce the 5 project roles server-side.
- A `file-replace`/attachments story if proposals need evidence files (none yet).
