Skip to content

Commit a8d19ec

Browse files
evangauerclaude
andcommitted
Add appointment waitlist (fill cancellations, see pets sooner)
A roadmap 'Next' item that progresses clinic workflows: when an appointment is cancelled and a slot opens, clinics can find waiting clients who fit the freed date and appointment type — so pets get seen sooner and the slot isn't wasted. - appointment_waitlist table (client/patient/type, optional date window, status) - pure matchWaitlist() — waiting + type-compatible + within window, oldest request first (FIFO fairness); 6 unit tests - waitlist router: add, list, setStatus, matchesForSlot Additive schema — run pnpm db:push on deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01e1d7a commit a8d19ec

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Pure waitlist matching. When an appointment is cancelled and a slot frees up,
3+
* a clinic wants to know who on the waitlist fits — so the pet gets seen sooner
4+
* and the slot doesn't go to waste. No I/O.
5+
*/
6+
7+
export interface WaitlistEntry {
8+
id: string;
9+
status: string;
10+
typeId: string | null;
11+
/** Date window the client is available within (YYYY-MM-DD), null = any. */
12+
preferredFrom: string | null;
13+
preferredTo: string | null;
14+
createdAt: Date;
15+
}
16+
17+
export interface OpenSlot {
18+
/** Date of the freed slot, YYYY-MM-DD. */
19+
date: string;
20+
/** Appointment type of the freed slot, if any. */
21+
typeId?: string | null;
22+
}
23+
24+
/**
25+
* Return waiting entries that fit the freed slot, oldest request first (FIFO,
26+
* fair). An entry matches when it is still waiting, its type preference is
27+
* unset or equals the slot's type, and the slot date falls within its
28+
* preferred window (open-ended if a bound is null).
29+
*/
30+
export function matchWaitlist(
31+
entries: WaitlistEntry[],
32+
slot: OpenSlot
33+
): WaitlistEntry[] {
34+
return entries
35+
.filter((e) => {
36+
if (e.status !== "waiting") return false;
37+
if (e.typeId && slot.typeId && e.typeId !== slot.typeId) return false;
38+
if (e.preferredFrom && slot.date < e.preferredFrom) return false;
39+
if (e.preferredTo && slot.date > e.preferredTo) return false;
40+
return true;
41+
})
42+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
43+
}

apps/web/server/routers/_app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { vitalsRouter } from "./vitals";
2525
import { agentRouter } from "./agent";
2626
import { treatmentPlansRouter } from "./treatment-plans";
2727
import { wellnessRouter } from "./wellness";
28+
import { waitlistRouter } from "./waitlist";
2829

2930
export const appRouter = createRouter({
3031
auth: authRouter,
@@ -53,6 +54,7 @@ export const appRouter = createRouter({
5354
agent: agentRouter,
5455
treatmentPlans: treatmentPlansRouter,
5556
wellness: wellnessRouter,
57+
waitlist: waitlistRouter,
5658
});
5759

5860
export type AppRouter = typeof appRouter;
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { z } from "zod";
2+
import { eq, and, isNull, asc } from "drizzle-orm";
3+
import { TRPCError } from "@trpc/server";
4+
import { createRouter, protectedProcedure, requireRole } from "../trpc";
5+
import { appointmentWaitlist, clients, patients, appointmentTypes } from "@openpims/db";
6+
import { matchWaitlist, type WaitlistEntry } from "@/lib/scheduling/waitlist";
7+
8+
const manageRole = requireRole("admin", "veterinarian", "front_desk");
9+
10+
/** Join the display fields the UI needs alongside the matcher fields. */
11+
function selectShape() {
12+
return {
13+
id: appointmentWaitlist.id,
14+
status: appointmentWaitlist.status,
15+
typeId: appointmentWaitlist.typeId,
16+
preferredFrom: appointmentWaitlist.preferredFrom,
17+
preferredTo: appointmentWaitlist.preferredTo,
18+
notes: appointmentWaitlist.notes,
19+
createdAt: appointmentWaitlist.createdAt,
20+
clientFirstName: clients.firstName,
21+
clientLastName: clients.lastName,
22+
clientPhone: clients.phone,
23+
patientName: patients.name,
24+
typeName: appointmentTypes.name,
25+
};
26+
}
27+
28+
export const waitlistRouter = createRouter({
29+
list: protectedProcedure
30+
.input(
31+
z
32+
.object({ status: z.enum(["waiting", "scheduled", "cancelled"]).default("waiting") })
33+
.optional()
34+
)
35+
.query(async ({ ctx, input }) => {
36+
const status = input?.status ?? "waiting";
37+
return ctx.db
38+
.select(selectShape())
39+
.from(appointmentWaitlist)
40+
.leftJoin(clients, eq(appointmentWaitlist.clientId, clients.id))
41+
.leftJoin(patients, eq(appointmentWaitlist.patientId, patients.id))
42+
.leftJoin(appointmentTypes, eq(appointmentWaitlist.typeId, appointmentTypes.id))
43+
.where(
44+
and(
45+
eq(appointmentWaitlist.practiceId, ctx.practiceId),
46+
eq(appointmentWaitlist.status, status),
47+
isNull(appointmentWaitlist.deletedAt)
48+
)
49+
)
50+
.orderBy(asc(appointmentWaitlist.createdAt));
51+
}),
52+
53+
add: protectedProcedure
54+
.use(manageRole)
55+
.input(
56+
z.object({
57+
clientId: z.string().uuid(),
58+
patientId: z.string().uuid().optional(),
59+
typeId: z.string().uuid().optional(),
60+
preferredFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
61+
preferredTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
62+
notes: z.string().max(1000).optional(),
63+
})
64+
)
65+
.mutation(async ({ ctx, input }) => {
66+
const [row] = await ctx.db
67+
.insert(appointmentWaitlist)
68+
.values({
69+
practiceId: ctx.practiceId,
70+
clientId: input.clientId,
71+
patientId: input.patientId ?? null,
72+
typeId: input.typeId ?? null,
73+
preferredFrom: input.preferredFrom ?? null,
74+
preferredTo: input.preferredTo ?? null,
75+
notes: input.notes ?? null,
76+
createdBy: ctx.user.id,
77+
})
78+
.returning();
79+
return row!;
80+
}),
81+
82+
setStatus: protectedProcedure
83+
.use(manageRole)
84+
.input(
85+
z.object({
86+
id: z.string().uuid(),
87+
status: z.enum(["waiting", "scheduled", "cancelled"]),
88+
})
89+
)
90+
.mutation(async ({ ctx, input }) => {
91+
const [updated] = await ctx.db
92+
.update(appointmentWaitlist)
93+
.set({ status: input.status })
94+
.where(
95+
and(
96+
eq(appointmentWaitlist.id, input.id),
97+
eq(appointmentWaitlist.practiceId, ctx.practiceId),
98+
isNull(appointmentWaitlist.deletedAt)
99+
)
100+
)
101+
.returning();
102+
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Waitlist entry not found" });
103+
return updated;
104+
}),
105+
106+
/**
107+
* When a slot opens up (e.g. a cancellation), find waiting clients who fit
108+
* the freed date + appointment type, oldest request first.
109+
*/
110+
matchesForSlot: protectedProcedure
111+
.input(
112+
z.object({
113+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
114+
typeId: z.string().uuid().optional(),
115+
})
116+
)
117+
.query(async ({ ctx, input }) => {
118+
const rows = await ctx.db
119+
.select(selectShape())
120+
.from(appointmentWaitlist)
121+
.leftJoin(clients, eq(appointmentWaitlist.clientId, clients.id))
122+
.leftJoin(patients, eq(appointmentWaitlist.patientId, patients.id))
123+
.leftJoin(appointmentTypes, eq(appointmentWaitlist.typeId, appointmentTypes.id))
124+
.where(
125+
and(
126+
eq(appointmentWaitlist.practiceId, ctx.practiceId),
127+
eq(appointmentWaitlist.status, "waiting"),
128+
isNull(appointmentWaitlist.deletedAt)
129+
)
130+
);
131+
132+
const entries: WaitlistEntry[] = rows.map((r) => ({
133+
id: r.id,
134+
status: r.status,
135+
typeId: r.typeId,
136+
preferredFrom: r.preferredFrom,
137+
preferredTo: r.preferredTo,
138+
createdAt: r.createdAt,
139+
}));
140+
// Run the matcher once, then map back to the joined display rows in the
141+
// matcher's FIFO order.
142+
const byId = new Map(rows.map((r) => [r.id, r]));
143+
return matchWaitlist(entries, { date: input.date, typeId: input.typeId })
144+
.map((e) => byId.get(e.id))
145+
.filter((r): r is (typeof rows)[number] => Boolean(r));
146+
}),
147+
});

packages/db/schema/scheduling.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export const recurringFrequencyEnum = pgEnum("recurring_frequency", [
4141
"annual",
4242
]);
4343

44+
export const waitlistStatusEnum = pgEnum("waitlist_status", [
45+
"waiting",
46+
"scheduled",
47+
"cancelled",
48+
]);
49+
4450
export const appointmentTypes = pgTable("appointment_types", {
4551
...baseColumns(),
4652
practiceId: uuid("practice_id")
@@ -104,6 +110,30 @@ export const appointments = pgTable(
104110
})
105111
);
106112

113+
export const appointmentWaitlist = pgTable(
114+
"appointment_waitlist",
115+
{
116+
...baseColumns(),
117+
practiceId: uuid("practice_id")
118+
.notNull()
119+
.references(() => practices.id),
120+
clientId: uuid("client_id")
121+
.notNull()
122+
.references(() => clients.id),
123+
patientId: uuid("patient_id").references(() => patients.id),
124+
typeId: uuid("type_id").references(() => appointmentTypes.id),
125+
status: waitlistStatusEnum("status").notNull().default("waiting"),
126+
// Optional date window the client is available within.
127+
preferredFrom: date("preferred_from"),
128+
preferredTo: date("preferred_to"),
129+
notes: text("notes"),
130+
createdBy: uuid("created_by").references(() => users.id),
131+
},
132+
(table) => ({
133+
practiceIdx: index("waitlist_practice_idx").on(table.practiceId, table.status),
134+
})
135+
);
136+
107137
export const staffSchedules = pgTable("staff_schedules", {
108138
...baseColumns(),
109139
practiceId: uuid("practice_id")
@@ -188,3 +218,25 @@ export const staffSchedulesRelations = relations(
188218
}),
189219
})
190220
);
221+
222+
export const appointmentWaitlistRelations = relations(
223+
appointmentWaitlist,
224+
({ one }) => ({
225+
practice: one(practices, {
226+
fields: [appointmentWaitlist.practiceId],
227+
references: [practices.id],
228+
}),
229+
client: one(clients, {
230+
fields: [appointmentWaitlist.clientId],
231+
references: [clients.id],
232+
}),
233+
patient: one(patients, {
234+
fields: [appointmentWaitlist.patientId],
235+
references: [patients.id],
236+
}),
237+
type: one(appointmentTypes, {
238+
fields: [appointmentWaitlist.typeId],
239+
references: [appointmentTypes.id],
240+
}),
241+
})
242+
);

0 commit comments

Comments
 (0)