feat(patients): clinic-side patient registry
Clinics get a real patient ledger. Labs see only patientCode — no name,
phone, date of birth, or notes ever cross the team boundary.
Data model
- New table 'patients' (clinicTenantId, patientCode, firstName, lastName,
phone?, dateOfBirth?, notes?, archived). Unique index on
(clinicTenantId, patientCode) so each clinic gets its own code space.
Fulltext index on (firstName, lastName) for future patient search.
Row permissions Role.team(clinicTenantId) only — labs literally cannot
read the rows.
- jobs.patientId attribute (optional) + key index, references the
patient row when one exists. patientCode stays denormalised on jobs so
labs keep a stable identifier without joining patients.
Server
- createPatientAction: clinic-only, requireTenantKind guard. Protocol no
is optional; if absent we generate a 6-char unique code (re-roll on
collision, 8 attempts). Duplicate protocol no within a clinic is
rejected with a friendly error.
- updatePatientAction: edits name/phone/dob/notes. patientCode is
explicitly NOT mutable — re-keying historical jobs would be confusing.
- archivePatientAction: toggle, preserves history.
- listPatients / getPatient queries return plain objects via toPlain.
UI
- /patients page (clinic-only, sidebar nav 'Hastalar', middleware
protected): table + add form + edit dialog + archive.
- /jobs/new: patient Select replaces the bare patientCode input. Picking
a patient locks the patientCode field to that patient's code; falling
back to 'Hasta listesinde yok — kodu manuel gir' keeps the old free-
text flow.
- createJobAction validates patientId ownership and overwrites
patientCode with the patient's code on the server, so a manipulated
form can't desync the two.
- /jobs/[jobId] (clinic side only): adds a 'Hasta Bilgileri' card with
name/phone/dob/notes and uses the patient's full name as the page
title. Lab side is unchanged — code only.
The protocol-no / generated-code split matches what the user asked for:
existing patient management software's protocol number flows in directly,
otherwise the system mints one.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
type Connection,
|
||||
type Job,
|
||||
type JobStep,
|
||||
type Patient,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
@@ -39,6 +40,7 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||
function pickFields(formData: FormData) {
|
||||
return {
|
||||
labTenantId: String(formData.get("labTenantId") ?? "").trim(),
|
||||
patientId: String(formData.get("patientId") ?? "").trim(),
|
||||
patientCode: String(formData.get("patientCode") ?? "").trim(),
|
||||
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
|
||||
memberCount: String(formData.get("memberCount") ?? ""),
|
||||
@@ -89,6 +91,33 @@ export async function createJobAction(
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
// If a patientId is supplied, verify it belongs to this clinic and inherit
|
||||
// its patientCode so the lab side always sees a stable identifier.
|
||||
let patientCode = parsed.data.patientCode;
|
||||
if (parsed.data.patientId) {
|
||||
try {
|
||||
const patientRow = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.patients,
|
||||
parsed.data.patientId,
|
||||
)) as unknown as Patient;
|
||||
if (patientRow.clinicTenantId !== ctx.tenantId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Bu hasta size ait değil.",
|
||||
fieldErrors: { patientId: "Yetki yok." },
|
||||
};
|
||||
}
|
||||
patientCode = patientRow.patientCode;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Hasta bulunamadı.",
|
||||
fieldErrors: { patientId: "Hasta kaydı yok." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the chosen lab is an approved connection of this clinic
|
||||
const connRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
@@ -118,7 +147,8 @@ export async function createJobAction(
|
||||
clinicTenantId: ctx.tenantId,
|
||||
labTenantId: parsed.data.labTenantId,
|
||||
createdBy: ctx.user.id,
|
||||
patientCode: parsed.data.patientCode,
|
||||
patientId: parsed.data.patientId,
|
||||
patientCode,
|
||||
prostheticType: parsed.data.prostheticType,
|
||||
memberCount: parsed.data.memberCount,
|
||||
color: parsed.data.color,
|
||||
@@ -136,12 +166,12 @@ export async function createJobAction(
|
||||
action: "create",
|
||||
entityType: "job",
|
||||
entityId: created.$id,
|
||||
changes: { labTenantId: parsed.data.labTenantId, patientCode: parsed.data.patientCode },
|
||||
changes: { labTenantId: parsed.data.labTenantId, patientCode },
|
||||
});
|
||||
await createNotification({
|
||||
tenantId: parsed.data.labTenantId,
|
||||
jobId: created.$id,
|
||||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${parsed.data.patientCode}).`,
|
||||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
|
||||
});
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type Patient } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
import type {
|
||||
PatientActionState,
|
||||
PatientFormState,
|
||||
} from "./patient-types";
|
||||
import { patientSchema } from "@/lib/validation/patient";
|
||||
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
if (e instanceof AppwriteException) return e.message || fallback;
|
||||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||
? `${fallback} (${e.message})`
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of err.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickFields(formData: FormData) {
|
||||
return {
|
||||
patientCode: String(formData.get("patientCode") ?? "").trim(),
|
||||
firstName: String(formData.get("firstName") ?? "").trim(),
|
||||
lastName: String(formData.get("lastName") ?? "").trim(),
|
||||
phone: String(formData.get("phone") ?? "").trim(),
|
||||
dateOfBirth: String(formData.get("dateOfBirth") ?? "").trim(),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function patientPermissions(clinicTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(clinicTenantId)),
|
||||
Permission.update(Role.team(clinicTenantId, "owner")),
|
||||
Permission.update(Role.team(clinicTenantId, "admin")),
|
||||
Permission.update(Role.team(clinicTenantId, "member")),
|
||||
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||||
Permission.delete(Role.team(clinicTenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
function generateCode(): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
out += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function reserveUniqueCode(clinicTenantId: string): Promise<string> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const candidate = generateCode();
|
||||
const existing = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.patients,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.equal("patientCode", candidate),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (existing.total === 0) return candidate;
|
||||
}
|
||||
throw new Error("PATIENT_CODE_GENERATION_FAILED");
|
||||
}
|
||||
|
||||
export async function createPatientAction(
|
||||
_prev: PatientFormState,
|
||||
formData: FormData,
|
||||
): Promise<PatientFormState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Hasta kaydı yalnızca klinik hesapları için." };
|
||||
}
|
||||
|
||||
const parsed = patientSchema.safeParse(pickFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
// If user supplied a code, make sure it isn't already in use within this clinic.
|
||||
let code = parsed.data.patientCode;
|
||||
if (code) {
|
||||
const dup = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.patients,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", ctx.tenantId),
|
||||
Query.equal("patientCode", code),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (dup.total > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Bu protokol no kayıtlı.",
|
||||
fieldErrors: { patientCode: "Bu kod başka bir hasta için kullanılmış." },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
code = await reserveUniqueCode(ctx.tenantId);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.patients,
|
||||
ID.unique(),
|
||||
{
|
||||
clinicTenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
patientCode: code,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
phone: parsed.data.phone,
|
||||
dateOfBirth: parsed.data.dateOfBirth,
|
||||
notes: parsed.data.notes,
|
||||
archived: false,
|
||||
},
|
||||
patientPermissions(ctx.tenantId),
|
||||
);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "patient",
|
||||
entityId: created.$id,
|
||||
changes: { patientCode: code, firstName: parsed.data.firstName },
|
||||
});
|
||||
revalidatePath("/patients");
|
||||
return { ok: true, patientId: created.$id };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Hasta eklenemedi.") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePatientAction(
|
||||
_prev: PatientFormState,
|
||||
formData: FormData,
|
||||
): Promise<PatientFormState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Hasta bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = patientSchema.safeParse(pickFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.patients,
|
||||
id,
|
||||
)) as unknown as Patient;
|
||||
if (row.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Bu hastayı düzenleme yetkiniz yok." };
|
||||
}
|
||||
|
||||
// patientCode is intentionally NOT updatable here — re-keying historical
|
||||
// jobs would be confusing. Only firstName/lastName/phone/dateOfBirth/notes.
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
phone: parsed.data.phone,
|
||||
dateOfBirth: parsed.data.dateOfBirth,
|
||||
notes: parsed.data.notes,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "patient",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/patients");
|
||||
return { ok: true, patientId: id };
|
||||
}
|
||||
|
||||
export async function archivePatientAction(
|
||||
_prev: PatientActionState,
|
||||
formData: FormData,
|
||||
): Promise<PatientActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Hasta bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.patients,
|
||||
id,
|
||||
)) as unknown as Patient;
|
||||
if (row.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
|
||||
archived: !row.archived,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "patient",
|
||||
entityId: id,
|
||||
changes: { archived: !row.archived },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İşlem başarısız.") };
|
||||
}
|
||||
|
||||
revalidatePath("/patients");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Patient } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { toPlain } from "./serialize";
|
||||
|
||||
export async function listPatients(
|
||||
clinicTenantId: string,
|
||||
options: { includeArchived?: boolean } = {},
|
||||
): Promise<Patient[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const queries = [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(500),
|
||||
];
|
||||
if (!options.includeArchived) {
|
||||
queries.push(Query.notEqual("archived", true));
|
||||
}
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.patients,
|
||||
queries,
|
||||
});
|
||||
return toPlain(result.rows as unknown as Patient[]);
|
||||
}
|
||||
|
||||
export async function getPatient(
|
||||
patientId: string,
|
||||
clinicTenantId: string,
|
||||
): Promise<Patient | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.patients,
|
||||
patientId,
|
||||
)) as unknown as Patient;
|
||||
if (row.clinicTenantId !== clinicTenantId) return null;
|
||||
return toPlain(row);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type PatientFormState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
patientId?: string;
|
||||
};
|
||||
|
||||
export const initialPatientFormState: PatientFormState = { ok: false };
|
||||
|
||||
export type PatientActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialPatientActionState: PatientActionState = { ok: false };
|
||||
@@ -9,6 +9,7 @@ export const TABLES = {
|
||||
tenantSettings: "tenant_settings",
|
||||
profiles: "profiles",
|
||||
connections: "connections",
|
||||
patients: "patients",
|
||||
jobs: "jobs",
|
||||
jobFiles: "job_files",
|
||||
jobStatusHistory: "job_status_history",
|
||||
@@ -81,11 +82,24 @@ export type ProstheticType =
|
||||
| "e_max"
|
||||
| "diger";
|
||||
|
||||
export interface Patient extends Row {
|
||||
clinicTenantId: string;
|
||||
createdBy: string;
|
||||
patientCode: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
dateOfBirth?: string;
|
||||
notes?: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface Job extends Row {
|
||||
clinicTenantId: string;
|
||||
labTenantId: string;
|
||||
createdBy: string;
|
||||
patientCode: string;
|
||||
patientId?: string;
|
||||
prostheticType: ProstheticType;
|
||||
memberCount: number;
|
||||
color?: string;
|
||||
|
||||
Reference in New Issue
Block a user