feat(doctors): admin doctor management (Phase 2 slice)

- 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.
This commit is contained in:
2026-07-25 01:57:01 +07:00
parent 695a9a6bec
commit e2fb601b2a
10 changed files with 467 additions and 1 deletions
+119
View File
@@ -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<DoctorFormState> {
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<string, string[]>,
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<void> {
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<void> {
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();
}
}
@@ -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<DoctorFormState, FormData>(
createDoctorAction,
{ status: "idle" },
);
const form = useForm<DoctorInput>({
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 (
<form action={dispatchAction} noValidate className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1.5">
<Label htmlFor="doctor-last-name">{t("lastName")}</Label>
<Input
id="doctor-last-name"
disabled={isPending}
aria-invalid={!!fieldErrors.lastName}
{...form.register("lastName")}
/>
{fieldErrors.lastName && (
<p className="text-destructive text-sm" role="alert">
{fieldErrors.lastName.message}
</p>
)}
</div>
<div className="flex-1 space-y-1.5">
<Label htmlFor="doctor-first-name">{t("firstName")}</Label>
<Input
id="doctor-first-name"
disabled={isPending}
aria-invalid={!!fieldErrors.firstName}
{...form.register("firstName")}
/>
{fieldErrors.firstName && (
<p className="text-destructive text-sm" role="alert">
{fieldErrors.firstName.message}
</p>
)}
</div>
<Button type="submit" size="lg" disabled={isPending}>
{isPending ? t("submitting") : t("add")}
</Button>
{state.status === "success" && (
<p className="text-sm font-medium text-green-600 sm:self-center" role="status">
{t("added", { name: state.doctorName })}
</p>
)}
{formError && (
<p className="text-destructive text-sm sm:self-center" role="alert">
{formError}
</p>
)}
</form>
);
}
+78
View File
@@ -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 (
<div className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-foreground mb-6 text-xl font-semibold">{t("title")}</h1>
<section className="border-border mb-8 rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-medium">{t("addTitle")}</h2>
<AddDoctorForm />
</section>
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("empty")}</p>
) : (
<ul className="space-y-2" aria-label={t("title")}>
{rows.map((d) => (
<li
key={d.id}
className="border-border flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-end"
>
<form action={updateDoctorAction} className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-end">
<input type="hidden" name="id" value={d.id} />
<div className="flex-1 space-y-1">
<label htmlFor={`ln-${d.id}`} className="text-muted-foreground text-xs">
{t("lastName")}
</label>
<Input id={`ln-${d.id}`} name="lastName" defaultValue={d.last_name} required />
</div>
<div className="flex-1 space-y-1">
<label htmlFor={`fn-${d.id}`} className="text-muted-foreground text-xs">
{t("firstName")}
</label>
<Input id={`fn-${d.id}`} name="firstName" defaultValue={d.first_name} required />
</div>
<Button type="submit" variant="outline">
{t("save")}
</Button>
</form>
<form action={deactivateDoctorAction}>
<input type="hidden" name="id" value={d.id} />
<Button type="submit" variant="ghost" className="text-destructive">
{t("deactivate")}
</Button>
</form>
</li>
))}
</ul>
)}
</div>
);
}
+2 -1
View File
@@ -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<AppRole, MenuItem[]> = {
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 }],
+21
View File
@@ -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<typeof DoctorSchema>;
/** useActionState shape for the add-doctor form. JSON-serializable. */
export type DoctorFormState =
| { status: "idle" }
| { status: "error"; fieldErrors: Record<string, string[]>; formError: string | null }
| { status: "success"; doctorName: string };
+14
View File
@@ -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": {
+14
View File
@@ -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": {
@@ -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.
@@ -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;
+24
View File
@@ -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;