feat: org-scoped appointments backend, wire appointments page

Add the appointments table, validation, service and CRUD routes
(/api/appointments, RBAC-gated) and the matching frontend data module.
The appointments page now loads and persists real data; KPIs are computed
from it and the schedule/calendar anchor to the real current date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-07 19:33:37 +03:00
parent 128ce36df2
commit dec04ec506
14 changed files with 2004 additions and 142 deletions
+35
View File
@@ -0,0 +1,35 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import type { AppointmentStatus } from "../../types/appointment.js";
import { organization, user } from "./auth.js";
// One row per scheduled visit, scoped to a clinic (organization). Patient
// identity is denormalized (name/initials/file number) so the schedule renders
// without joining; `date`/`time` are stored as plain strings to match the UI's
// local-date model exactly (no timezone drift).
export const appointments = pgTable(
"appointments",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
patientFileNumber: text("patient_file_number").notNull().default(""),
patientName: text("patient_name").notNull(),
patientInitials: text("patient_initials").notNull(),
date: text("date").notNull(), // YYYY-MM-DD
time: text("time").notNull(), // HH:mm
type: text("type").notNull(),
provider: text("provider").notNull(),
status: text("status").$type<AppointmentStatus>().notNull(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [index("appointments_org_date_idx").on(t.organizationId, t.date)],
);
+1
View File
@@ -1,3 +1,4 @@
export * from "./auth.js";
export * from "./patients.js";
export * from "./notes.js";
export * from "./appointments.js";
+3
View File
@@ -5,6 +5,7 @@ import express from "express";
import { auth } from "./auth.js";
import { env } from "./env.js";
import { errorHandler, notFound } from "./middleware/error.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { notesRouter } from "./routes/notes.js";
import { patientsRouter } from "./routes/patients.js";
@@ -45,6 +46,7 @@ app.get("/health", (_req, res) => {
app.use("/api/patients", patientsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use(notFound);
app.use(errorHandler);
@@ -54,4 +56,5 @@ app.listen(env.PORT, () => {
console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`);
console.log(` • patients: /api/patients`);
console.log(` • notes: /api/notes`);
console.log(` • appts: /api/appointments`);
});
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
// Payload accepted by POST/PUT /api/appointments. Mirrors the frontend
// `NewAppointment` shape; `status` defaults to "confirmed" on create.
export const appointmentInputSchema = z.object({
fileNumber: z.string().trim().default(""),
name: z.string().trim().min(1, "Patient name is required.").max(200),
initials: z.string().trim().min(1).max(4),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
type: z.string().trim().min(1, "Type is required.").max(120),
provider: z.string().trim().min(1, "Provider is required.").max(200),
status: z
.enum(["confirmed", "checked-in", "completed", "cancelled"])
.default("confirmed"),
});
export type AppointmentInput = z.infer<typeof appointmentInputSchema>;
+81
View File
@@ -0,0 +1,81 @@
import { Router } from "express";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import * as service from "../services/appointments.js";
export const appointmentsRouter = Router();
// Appointments are clinic-wide records, gated by the caller's role like patients.
appointmentsRouter.use(requireAuth, requireOrg);
appointmentsRouter.get(
"/",
requirePermission({ appointment: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listAppointments(req.organizationId!));
} catch (err) {
next(err);
}
},
);
appointmentsRouter.post(
"/",
requirePermission({ appointment: ["write"] }),
async (req, res, next) => {
try {
const input = appointmentInputSchema.parse(req.body);
const created = await service.createAppointment(
req.organizationId!,
req.user!.id,
input,
);
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
appointmentsRouter.put(
"/:id",
requirePermission({ appointment: ["write"] }),
async (req, res, next) => {
try {
const input = appointmentInputSchema.parse(req.body);
const updated = await service.updateAppointment(
req.organizationId!,
req.params.id as string,
input,
);
if (!updated) throw new HttpError(404, "Appointment not found.");
res.json(updated);
} catch (err) {
next(err);
}
},
);
appointmentsRouter.delete(
"/:id",
requirePermission({ appointment: ["delete"] }),
async (req, res, next) => {
try {
const ok = await service.deleteAppointment(
req.organizationId!,
req.params.id as string,
);
if (!ok) throw new HttpError(404, "Appointment not found.");
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+94
View File
@@ -0,0 +1,94 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { appointments } from "../db/schema/appointments.js";
import type { AppointmentInput } from "../lib/appointment-validation.js";
import type { Appointment } from "../types/appointment.js";
type AppointmentRow = typeof appointments.$inferSelect;
// Postgres throws on a malformed uuid; treat non-uuid ids as "not found".
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function toAppointment(row: AppointmentRow): Appointment {
return {
id: row.id,
fileNumber: row.patientFileNumber,
name: row.patientName,
initials: row.patientInitials,
date: row.date,
time: row.time,
type: row.type,
provider: row.provider,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function columns(orgId: string, input: AppointmentInput, createdBy?: string) {
return {
organizationId: orgId,
patientFileNumber: input.fileNumber,
patientName: input.name,
patientInitials: input.initials,
date: input.date,
time: input.time,
type: input.type,
provider: input.provider,
status: input.status,
...(createdBy ? { createdBy } : {}),
};
}
export async function listAppointments(orgId: string): Promise<Appointment[]> {
const rows = await db
.select()
.from(appointments)
.where(eq(appointments.organizationId, orgId))
.orderBy(asc(appointments.date), asc(appointments.time));
return rows.map(toAppointment);
}
export async function createAppointment(
orgId: string,
userId: string,
input: AppointmentInput,
): Promise<Appointment> {
const [row] = await db
.insert(appointments)
.values(columns(orgId, input, userId))
.returning();
return toAppointment(row!);
}
export async function updateAppointment(
orgId: string,
id: string,
input: AppointmentInput,
): Promise<Appointment | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.update(appointments)
.set(columns(orgId, input))
.where(
and(eq(appointments.id, id), eq(appointments.organizationId, orgId)),
)
.returning();
return row ? toAppointment(row) : null;
}
export async function deleteAppointment(
orgId: string,
id: string,
): Promise<boolean> {
if (!UUID_RE.test(id)) return false;
const deleted = await db
.delete(appointments)
.where(
and(eq(appointments.id, id), eq(appointments.organizationId, orgId)),
)
.returning({ id: appointments.id });
return deleted.length > 0;
}
+22
View File
@@ -0,0 +1,22 @@
// The canonical Appointment shape returned by the API. Mirrors the frontend
// `lib/appointments.ts` Appointment type. Patient fields are denormalized for
// display; `fileNumber` links back to a patient record when set.
export type AppointmentStatus =
| "confirmed"
| "checked-in"
| "completed"
| "cancelled";
export type Appointment = {
id: string;
fileNumber: string;
name: string;
initials: string;
date: string; // ISO YYYY-MM-DD
time: string; // HH:mm
type: string;
provider: string;
status: AppointmentStatus;
createdAt: string;
updatedAt: string;
};