mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-06 00:22:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 };
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user