From e2fb601b2ac30573e55f47cebe83a0ce896908b6 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 25 Jul 2026 01:57:01 +0700 Subject: [PATCH] feat(doctors): admin doctor management (Phase 2 slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bsk.doctors table (soft-delete) with RLS: reads for enrolled staff, writes admin-only; user-client writes so RLS is the enforcement point - add / edit / deactivate Server Actions — admin-gated, Zod-validated, audit-logged via log_audit, revalidate the list - doctors admin page (RSC list + inline edit + deactivate) and add form - sidebar nav entry + vi/en strings Establishes the Phase 2 CRUD pattern (RLS gate + defense-in-depth role check + Zod + audit + revalidate) for the remaining core entities. --- app/[locale]/(app)/admin/doctors/actions.ts | 119 ++++++++++++++++++ .../(app)/admin/doctors/add-doctor-form.tsx | 100 +++++++++++++++ app/[locale]/(app)/admin/doctors/page.tsx | 78 ++++++++++++ lib/auth/role-menu.ts | 3 +- lib/doctors/doctor-schema.ts | 21 ++++ messages/en.json | 14 +++ messages/vi.json | 14 +++ .../260725-0149-phase-2-core-entities/plan.md | 30 +++++ .../migrations/20260725015000_bsk_doctors.sql | 65 ++++++++++ types/supabase-bsk.ts | 24 ++++ 10 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 app/[locale]/(app)/admin/doctors/actions.ts create mode 100644 app/[locale]/(app)/admin/doctors/add-doctor-form.tsx create mode 100644 app/[locale]/(app)/admin/doctors/page.tsx create mode 100644 lib/doctors/doctor-schema.ts create mode 100644 plans/260725-0149-phase-2-core-entities/plan.md create mode 100644 supabase/migrations/20260725015000_bsk_doctors.sql diff --git a/app/[locale]/(app)/admin/doctors/actions.ts b/app/[locale]/(app)/admin/doctors/actions.ts new file mode 100644 index 0000000..f86a571 --- /dev/null +++ b/app/[locale]/(app)/admin/doctors/actions.ts @@ -0,0 +1,119 @@ +"use server"; + +/** + * Server Actions for doctor management (admin only). + * + * Writes run through the caller's user client so the RLS admin policy on + * bsk.doctors is the enforcement point; the getServerSession role check is + * defense-in-depth (and lets us return a friendly error instead of a raw RLS + * failure). Every mutation is audit-logged via bsk.log_audit and revalidates + * the list. Soft-delete only — rows are never hard-deleted. + */ + +import { getLocale, getTranslations } from "next-intl/server"; +import { revalidatePath } from "next/cache"; + +import { getServerSession } from "@/lib/auth/get-server-session"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { DoctorSchema, type DoctorFormState } from "@/lib/doctors/doctor-schema"; + +async function revalidateDoctors() { + const locale = await getLocale(); + revalidatePath(`/${locale}/admin/doctors`); +} + +// ── Create (useActionState-compatible) ─────────────────────────────────────── +export async function createDoctorAction( + _prev: DoctorFormState, + formData: FormData, +): Promise { + const t = await getTranslations("admin.doctors"); + + const session = await getServerSession(); + if (session?.role !== "admin") { + return { status: "error", fieldErrors: {}, formError: t("errorForbidden") }; + } + + const parsed = DoctorSchema.safeParse({ + firstName: formData.get("firstName"), + lastName: formData.get("lastName"), + }); + if (!parsed.success) { + return { + status: "error", + fieldErrors: parsed.error.flatten().fieldErrors as Record, + formError: null, + }; + } + + const { firstName, lastName } = parsed.data; + const supabase = await createSupabaseServerClient(); + + const { data, error } = await supabase + .from("doctors") + .insert({ first_name: firstName, last_name: lastName }) + .select("id") + .single(); + + if (error || !data) { + return { status: "error", fieldErrors: {}, formError: t("errorGeneric") }; + } + + await supabase.rpc("log_audit", { + p_action: "doctor.create", + p_entity: "doctors", + p_entity_id: String(data.id), + }); + + await revalidateDoctors(); + return { status: "success", doctorName: `${lastName} ${firstName}`.trim() }; +} + +// ── Update ─────────────────────────────────────────────────────────────────── +export async function updateDoctorAction(formData: FormData): Promise { + const session = await getServerSession(); + if (session?.role !== "admin") return; + + const id = Number(formData.get("id")); + const parsed = DoctorSchema.safeParse({ + firstName: formData.get("firstName"), + lastName: formData.get("lastName"), + }); + if (!Number.isFinite(id) || !parsed.success) return; + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from("doctors") + .update({ first_name: parsed.data.firstName, last_name: parsed.data.lastName }) + .eq("id", id); + + if (!error) { + await supabase.rpc("log_audit", { + p_action: "doctor.update", + p_entity: "doctors", + p_entity_id: String(id), + }); + await revalidateDoctors(); + } +} + +// ── Soft-delete (deactivate) ───────────────────────────────────────────────── +export async function deactivateDoctorAction(formData: FormData): Promise { + const session = await getServerSession(); + if (session?.role !== "admin") return; + + const id = Number(formData.get("id")); + if (!Number.isFinite(id)) return; + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from("doctors").update({ deleted: true }).eq("id", id); + + if (!error) { + await supabase.rpc("log_audit", { + p_action: "doctor.deactivate", + p_entity: "doctors", + p_entity_id: String(id), + }); + await revalidateDoctors(); + } +} diff --git a/app/[locale]/(app)/admin/doctors/add-doctor-form.tsx b/app/[locale]/(app)/admin/doctors/add-doctor-form.tsx new file mode 100644 index 0000000..fa5e805 --- /dev/null +++ b/app/[locale]/(app)/admin/doctors/add-doctor-form.tsx @@ -0,0 +1,100 @@ +"use client"; + +/** + * Add-doctor form — Client Component. Same wiring as invite-user-form: + * RHF validates on blur for inline UX; useActionState dispatches the native + * form action to createDoctorAction. Resets on success. + */ + +import { useActionState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { DoctorSchema, type DoctorInput, type DoctorFormState } from "@/lib/doctors/doctor-schema"; +import { createDoctorAction } from "./actions"; + +export function AddDoctorForm() { + const t = useTranslations("admin.doctors"); + + const [state, dispatchAction, isPending] = useActionState( + createDoctorAction, + { status: "idle" }, + ); + + const form = useForm({ + resolver: zodResolver(DoctorSchema), + mode: "onBlur", + defaultValues: { firstName: "", lastName: "" }, + }); + + const { errors: fieldErrors } = form.formState; + + useEffect(() => { + if (state.status !== "error") return; + if (state.fieldErrors.firstName?.length) { + form.setError("firstName", { message: state.fieldErrors.firstName[0] }); + } + if (state.fieldErrors.lastName?.length) { + form.setError("lastName", { message: state.fieldErrors.lastName[0] }); + } + }, [state, form]); + + useEffect(() => { + if (state.status === "success") form.reset(); + }, [state, form]); + + const formError = state.status === "error" && state.formError ? state.formError : null; + + return ( +
+
+ + + {fieldErrors.lastName && ( +

+ {fieldErrors.lastName.message} +

+ )} +
+ +
+ + + {fieldErrors.firstName && ( +

+ {fieldErrors.firstName.message} +

+ )} +
+ + + + {state.status === "success" && ( +

+ {t("added", { name: state.doctorName })} +

+ )} + {formError && ( +

+ {formError} +

+ )} +
+ ); +} diff --git a/app/[locale]/(app)/admin/doctors/page.tsx b/app/[locale]/(app)/admin/doctors/page.tsx new file mode 100644 index 0000000..7ef2c44 --- /dev/null +++ b/app/[locale]/(app)/admin/doctors/page.tsx @@ -0,0 +1,78 @@ +// WARNING: Do NOT add `'use cache'` — reads cookies() via the server client. + +/** + * Doctor management — Server Component (admin only; gated by (app)/admin layout). + * + * Lists active doctors and renders inline edit + deactivate forms bound to + * Server Actions. Reads run under the caller's RLS (doctors_select_enrolled). + */ + +import { getTranslations } from "next-intl/server"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { AddDoctorForm } from "./add-doctor-form"; +import { updateDoctorAction, deactivateDoctorAction } from "./actions"; + +export default async function DoctorsPage({ params }: { params: Promise<{ locale: string }> }) { + await params; // Next.js 16: params is async. + const t = await getTranslations("admin.doctors"); + + const supabase = await createSupabaseServerClient(); + const { data: doctors } = await supabase + .from("doctors") + .select("id, first_name, last_name") + .eq("deleted", false) + .order("last_name", { ascending: true }); + + const rows = doctors ?? []; + + return ( +
+

{t("title")}

+ +
+

{t("addTitle")}

+ +
+ + {rows.length === 0 ? ( +

{t("empty")}

+ ) : ( +
    + {rows.map((d) => ( +
  • +
    + +
    + + +
    +
    + + +
    + +
    +
    + + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/lib/auth/role-menu.ts b/lib/auth/role-menu.ts index d62c01a..f013d0c 100644 --- a/lib/auth/role-menu.ts +++ b/lib/auth/role-menu.ts @@ -14,7 +14,7 @@ import type { AppRole } from "@/lib/db/roles"; import type { ComponentType } from "react"; -import { LayoutDashboard, UserPlus } from "lucide-react"; +import { LayoutDashboard, UserPlus, Stethoscope } from "lucide-react"; export type MenuItem = { /** Locale-relative path, e.g. "/dashboard". Sidebar prefixes with locale. */ @@ -38,6 +38,7 @@ export type MenuItem = { export const ROLE_MENU: Record = { admin: [ { href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }, + { href: "/admin/doctors", labelKey: "nav.doctors", icon: Stethoscope }, { href: "/admin/invite", labelKey: "nav.invite", icon: UserPlus }, ], doctor: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }], diff --git a/lib/doctors/doctor-schema.ts b/lib/doctors/doctor-schema.ts new file mode 100644 index 0000000..ca36304 --- /dev/null +++ b/lib/doctors/doctor-schema.ts @@ -0,0 +1,21 @@ +/** + * Zod schema + state types for doctor management. + * + * No `'use server'` — shared between the Server Action (validation) and the + * Client Component (RHF resolver), same split as lib/auth/invite-schema.ts. + */ + +import { z } from "zod"; + +export const DoctorSchema = z.object({ + firstName: z.string().trim().min(1).max(100), + lastName: z.string().trim().min(1).max(100), +}); + +export type DoctorInput = z.infer; + +/** useActionState shape for the add-doctor form. JSON-serializable. */ +export type DoctorFormState = + | { status: "idle" } + | { status: "error"; fieldErrors: Record; formError: string | null } + | { status: "success"; doctorName: string }; diff --git a/messages/en.json b/messages/en.json index f88d144..ac0b386 100644 --- a/messages/en.json +++ b/messages/en.json @@ -20,6 +20,20 @@ "errorEmailTaken": "This user is already enrolled in BSK.", "errorGeneric": "Something went wrong. Please try again.", "tooManyRequests": "Too many invites sent. Please try again in a few minutes." + }, + "doctors": { + "title": "Doctor management", + "addTitle": "Add doctor", + "firstName": "First name", + "lastName": "Last name", + "add": "Add", + "submitting": "Saving…", + "added": "Added {name}.", + "save": "Save", + "deactivate": "Deactivate", + "empty": "No doctors yet.", + "errorForbidden": "You do not have permission to manage doctors.", + "errorGeneric": "Something went wrong. Please try again." } }, "roles": { diff --git a/messages/vi.json b/messages/vi.json index a9e44a6..5eb678a 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -20,6 +20,20 @@ "errorEmailTaken": "Người dùng này đã được đăng ký trong BSK.", "errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại.", "tooManyRequests": "Bạn đã gửi quá nhiều lời mời. Vui lòng thử lại sau ít phút." + }, + "doctors": { + "title": "Quản lý bác sĩ", + "addTitle": "Thêm bác sĩ", + "firstName": "Tên", + "lastName": "Họ", + "add": "Thêm", + "submitting": "Đang lưu…", + "added": "Đã thêm {name}.", + "save": "Lưu", + "deactivate": "Ngừng hoạt động", + "empty": "Chưa có bác sĩ nào.", + "errorForbidden": "Bạn không có quyền quản lý bác sĩ.", + "errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại." } }, "roles": { diff --git a/plans/260725-0149-phase-2-core-entities/plan.md b/plans/260725-0149-phase-2-core-entities/plan.md new file mode 100644 index 0000000..abdc0d8 --- /dev/null +++ b/plans/260725-0149-phase-2-core-entities/plan.md @@ -0,0 +1,30 @@ +# Phase 2 — Core Entities (CRUD) + +**Status:** In progress. Delivered as validated vertical slices (schema + RLS + Server Actions + UI + i18n + audit per entity). +**Goal:** Build the master-data layer that unblocks Phase 3+ (queue/checkup). Covers the original's Doctor/User/Template/Clinic/Customer/geo commands. +**Basis:** `plans/reports/researcher-260725-0048-...-report.md` (source-grounded feature list). + +## Slices +| # | Entity | Original commands | Status | +|---|---|---|---| +| 2a | **Doctors** | AddDoctor/EditDoctor/GetDoctorInfo/GetDoctorGeneralInfo | ✅ DONE (this session) | +| 2b | Clinic settings | ClinicInfoRequest + settings edit | TODO | +| 2c | Patients (customers) | AddPatient/GetRecentPatient + **geo-lookup** (provinces/wards) + **accent-insensitive search** | TODO (needs geo seed + unaccent decision) | +| 2d | Checkup templates | Add/Edit/Delete/GetAllTemplates + **gender** field | TODO | +| 2e | Staff user management | AddUser/EditUser/GetAllUserInfo (extends app_users) | TODO | + +## Slice 2a — Doctors (done) +- Migration `20260725015000_bsk_doctors.sql`: `bsk.doctors` (first_name, last_name, soft-delete), RLS (read=enrolled, write=admin), grants (no DELETE — soft-delete only). +- Types updated (`types/supabase-bsk.ts`). +- `lib/doctors/doctor-schema.ts` (Zod, shared client/server). +- `app/[locale]/(app)/admin/doctors/`: `actions.ts` (create/update/deactivate — admin-gated, RLS-enforced, audit-logged, revalidate), `add-doctor-form.tsx` (client RHF+useActionState), `page.tsx` (RSC list + inline edit + deactivate). +- Nav: admin gets `/admin/doctors` (`nav.doctors`, Stethoscope). i18n `admin.doctors.*` both locales. +- Pattern established: **RLS-as-gate (user client) + getServerSession defense-in-depth + Zod + log_audit + revalidatePath**. Later slices mirror it. + +## Open decisions (before their slices) +- **2c geo data**: which VN provinces/wards dataset to seed (post-2025 merged administrative units?); accent search via Postgres `unaccent` extension vs a normalized generated column. +- **2c/2d**: `customers` full field set (CCCD, phone, DOB, gender, weight/height); `checkup_templates` field-layout storage (JSON `fields`) + gender. +- **2e**: staff management vs the existing invite flow — reconcile (invite creates auth+enrollment; edit/list/role-reassign is the new surface). + +## Acceptance (per slice) +`pnpm typecheck` / `lint` / `build` green; admin-only writes enforced by RLS; mutations audit-logged; VI/EN strings present. diff --git a/supabase/migrations/20260725015000_bsk_doctors.sql b/supabase/migrations/20260725015000_bsk_doctors.sql new file mode 100644 index 0000000..27559b8 --- /dev/null +++ b/supabase/migrations/20260725015000_bsk_doctors.sql @@ -0,0 +1,65 @@ +-- BSK Phase 2 — doctors catalog. +-- +-- Mirrors the original Doctor entity (first_name, last_name, soft-delete). +-- Reads: any enrolled staff (doctors appear in assignment dropdowns). +-- Writes: admin only, enforced by RLS (no service_role needed — mutations run +-- through the caller's user client so the admin policy is the gate). + +-- ─── 1. Table ────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS bsk.doctors ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + first_name text NOT NULL, + last_name text NOT NULL, + deleted boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE bsk.doctors IS + 'Clinic doctors. Soft-deleted via the deleted flag (never hard-deleted) so ' + 'historical checkups keep a valid doctor reference. Admin-managed.'; + +CREATE INDEX IF NOT EXISTS doctors_active_idx ON bsk.doctors (deleted) WHERE NOT deleted; + +-- ─── 2. RLS ──────────────────────────────────────────────────────────────── + +ALTER TABLE bsk.doctors ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + -- Read: any enrolled user (current_role() is non-null only for enrolled staff). + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'bsk' AND tablename = 'doctors' AND policyname = 'doctors_select_enrolled' + ) THEN + CREATE POLICY doctors_select_enrolled + ON bsk.doctors FOR SELECT + USING (bsk.current_role() IS NOT NULL); + END IF; + + -- Insert: admin only. + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'bsk' AND tablename = 'doctors' AND policyname = 'doctors_insert_admin' + ) THEN + CREATE POLICY doctors_insert_admin + ON bsk.doctors FOR INSERT + WITH CHECK (bsk.current_role() = 'admin'); + END IF; + + -- Update: admin only (covers soft-delete via the deleted flag). + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'bsk' AND tablename = 'doctors' AND policyname = 'doctors_update_admin' + ) THEN + CREATE POLICY doctors_update_admin + ON bsk.doctors FOR UPDATE + USING (bsk.current_role() = 'admin') + WITH CHECK (bsk.current_role() = 'admin'); + END IF; +END +$$; + +-- ─── 3. Grants ───────────────────────────────────────────────────────────── +-- No DELETE grant: rows are soft-deleted (UPDATE deleted = true), never removed. +GRANT SELECT, INSERT, UPDATE ON bsk.doctors TO authenticated; diff --git a/types/supabase-bsk.ts b/types/supabase-bsk.ts index eecdcd4..dbbc246 100644 --- a/types/supabase-bsk.ts +++ b/types/supabase-bsk.ts @@ -46,6 +46,30 @@ export type Database = { }; Relationships: []; }; + doctors: { + Row: { + created_at: string; + deleted: boolean; + first_name: string; + id: number; + last_name: string; + }; + Insert: { + created_at?: string; + deleted?: boolean; + first_name: string; + id?: never; + last_name: string; + }; + Update: { + created_at?: string; + deleted?: boolean; + first_name?: string; + id?: never; + last_name?: string; + }; + Relationships: []; + }; app_users: { Row: { created_at: string;