Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/web/lib/scheduling/waitlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Pure waitlist matching. When an appointment is cancelled and a slot frees up,
* a clinic wants to know who on the waitlist fits — so the pet gets seen sooner
* and the slot doesn't go to waste. No I/O.
*/

export interface WaitlistEntry {
id: string;
status: string;
typeId: string | null;
/** Date window the client is available within (YYYY-MM-DD), null = any. */
preferredFrom: string | null;
preferredTo: string | null;
createdAt: Date;
}

export interface OpenSlot {
/** Date of the freed slot, YYYY-MM-DD. */
date: string;
/** Appointment type of the freed slot, if any. */
typeId?: string | null;
}

/**
* Return waiting entries that fit the freed slot, oldest request first (FIFO,
* fair). An entry matches when it is still waiting, its type preference is
* unset or equals the slot's type, and the slot date falls within its
* preferred window (open-ended if a bound is null).
*/
export function matchWaitlist(
entries: WaitlistEntry[],
slot: OpenSlot
): WaitlistEntry[] {
return entries
.filter((e) => {
if (e.status !== "waiting") return false;
if (e.typeId && slot.typeId && e.typeId !== slot.typeId) return false;
if (e.preferredFrom && slot.date < e.preferredFrom) return false;
if (e.preferredTo && slot.date > e.preferredTo) return false;
return true;
})
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
9 changes: 3 additions & 6 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@ export async function middleware(request: NextRequest) {
secret: process.env.NEXTAUTH_SECRET,
});

// Unauthenticated visitors at root get redirected to the marketing site
if (!token && request.nextUrl.pathname === "/") {
const wwwUrl = process.env.NEXT_PUBLIC_WWW_URL || "https://openvpm.com";
return NextResponse.redirect(wwwUrl);
}

// Unauthenticated visitors go to the demo login, which offers one-click
// demo access. (Previously the root path bounced to the marketing site,
// which dead-ended anyone who came straight to demo.openvpm.com to try it.)
if (!token) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/server/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { vitalsRouter } from "./vitals";
import { agentRouter } from "./agent";
import { treatmentPlansRouter } from "./treatment-plans";
import { wellnessRouter } from "./wellness";
import { waitlistRouter } from "./waitlist";

export const appRouter = createRouter({
auth: authRouter,
Expand Down Expand Up @@ -53,6 +54,7 @@ export const appRouter = createRouter({
agent: agentRouter,
treatmentPlans: treatmentPlansRouter,
wellness: wellnessRouter,
waitlist: waitlistRouter,
});

export type AppRouter = typeof appRouter;
147 changes: 147 additions & 0 deletions apps/web/server/routers/waitlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { z } from "zod";
import { eq, and, isNull, asc } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { createRouter, protectedProcedure, requireRole } from "../trpc";
import { appointmentWaitlist, clients, patients, appointmentTypes } from "@openpims/db";
import { matchWaitlist, type WaitlistEntry } from "@/lib/scheduling/waitlist";

const manageRole = requireRole("admin", "veterinarian", "front_desk");

/** Join the display fields the UI needs alongside the matcher fields. */
function selectShape() {
return {
id: appointmentWaitlist.id,
status: appointmentWaitlist.status,
typeId: appointmentWaitlist.typeId,
preferredFrom: appointmentWaitlist.preferredFrom,
preferredTo: appointmentWaitlist.preferredTo,
notes: appointmentWaitlist.notes,
createdAt: appointmentWaitlist.createdAt,
clientFirstName: clients.firstName,
clientLastName: clients.lastName,
clientPhone: clients.phone,
patientName: patients.name,
typeName: appointmentTypes.name,
};
}

export const waitlistRouter = createRouter({
list: protectedProcedure
.input(
z
.object({ status: z.enum(["waiting", "scheduled", "cancelled"]).default("waiting") })
.optional()
)
.query(async ({ ctx, input }) => {
const status = input?.status ?? "waiting";
return ctx.db
.select(selectShape())
.from(appointmentWaitlist)
.leftJoin(clients, eq(appointmentWaitlist.clientId, clients.id))
.leftJoin(patients, eq(appointmentWaitlist.patientId, patients.id))
.leftJoin(appointmentTypes, eq(appointmentWaitlist.typeId, appointmentTypes.id))
.where(
and(
eq(appointmentWaitlist.practiceId, ctx.practiceId),
eq(appointmentWaitlist.status, status),
isNull(appointmentWaitlist.deletedAt)
)
)
.orderBy(asc(appointmentWaitlist.createdAt));
}),

add: protectedProcedure
.use(manageRole)
.input(
z.object({
clientId: z.string().uuid(),
patientId: z.string().uuid().optional(),
typeId: z.string().uuid().optional(),
preferredFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
preferredTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
notes: z.string().max(1000).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.db
.insert(appointmentWaitlist)
.values({
practiceId: ctx.practiceId,
clientId: input.clientId,
patientId: input.patientId ?? null,
typeId: input.typeId ?? null,
preferredFrom: input.preferredFrom ?? null,
preferredTo: input.preferredTo ?? null,
notes: input.notes ?? null,
createdBy: ctx.user.id,
})
.returning();
return row!;
}),

setStatus: protectedProcedure
.use(manageRole)
.input(
z.object({
id: z.string().uuid(),
status: z.enum(["waiting", "scheduled", "cancelled"]),
})
)
.mutation(async ({ ctx, input }) => {
const [updated] = await ctx.db
.update(appointmentWaitlist)
.set({ status: input.status })
.where(
and(
eq(appointmentWaitlist.id, input.id),
eq(appointmentWaitlist.practiceId, ctx.practiceId),
isNull(appointmentWaitlist.deletedAt)
)
)
.returning();
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Waitlist entry not found" });
return updated;
}),

/**
* When a slot opens up (e.g. a cancellation), find waiting clients who fit
* the freed date + appointment type, oldest request first.
*/
matchesForSlot: protectedProcedure
.input(
z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
typeId: z.string().uuid().optional(),
})
)
.query(async ({ ctx, input }) => {
const rows = await ctx.db
.select(selectShape())
.from(appointmentWaitlist)
.leftJoin(clients, eq(appointmentWaitlist.clientId, clients.id))
.leftJoin(patients, eq(appointmentWaitlist.patientId, patients.id))
.leftJoin(appointmentTypes, eq(appointmentWaitlist.typeId, appointmentTypes.id))
.where(
and(
eq(appointmentWaitlist.practiceId, ctx.practiceId),
eq(appointmentWaitlist.status, "waiting"),
isNull(appointmentWaitlist.deletedAt)
)
);

const entries: WaitlistEntry[] = rows.map((r) => ({
id: r.id,
status: r.status,
typeId: r.typeId,
preferredFrom: r.preferredFrom,
preferredTo: r.preferredTo,
createdAt: r.createdAt,
}));
// Run the matcher once, then map back to the joined display rows in the
// matcher's FIFO order.
const byId = new Map(rows.map((r) => [r.id, r]));
return matchWaitlist(entries, { date: input.date, typeId: input.typeId })
.map((e) => byId.get(e.id))
.filter((r): r is (typeof rows)[number] => Boolean(r));
}),
});
52 changes: 52 additions & 0 deletions packages/db/schema/scheduling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const recurringFrequencyEnum = pgEnum("recurring_frequency", [
"annual",
]);

export const waitlistStatusEnum = pgEnum("waitlist_status", [
"waiting",
"scheduled",
"cancelled",
]);

export const appointmentTypes = pgTable("appointment_types", {
...baseColumns(),
practiceId: uuid("practice_id")
Expand Down Expand Up @@ -104,6 +110,30 @@ export const appointments = pgTable(
})
);

export const appointmentWaitlist = pgTable(
"appointment_waitlist",
{
...baseColumns(),
practiceId: uuid("practice_id")
.notNull()
.references(() => practices.id),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id),
patientId: uuid("patient_id").references(() => patients.id),
typeId: uuid("type_id").references(() => appointmentTypes.id),
status: waitlistStatusEnum("status").notNull().default("waiting"),
// Optional date window the client is available within.
preferredFrom: date("preferred_from"),
preferredTo: date("preferred_to"),
notes: text("notes"),
createdBy: uuid("created_by").references(() => users.id),
},
(table) => ({
practiceIdx: index("waitlist_practice_idx").on(table.practiceId, table.status),
})
);

export const staffSchedules = pgTable("staff_schedules", {
...baseColumns(),
practiceId: uuid("practice_id")
Expand Down Expand Up @@ -188,3 +218,25 @@ export const staffSchedulesRelations = relations(
}),
})
);

export const appointmentWaitlistRelations = relations(
appointmentWaitlist,
({ one }) => ({
practice: one(practices, {
fields: [appointmentWaitlist.practiceId],
references: [practices.id],
}),
client: one(clients, {
fields: [appointmentWaitlist.clientId],
references: [clients.id],
}),
patient: one(patients, {
fields: [appointmentWaitlist.patientId],
references: [patients.id],
}),
type: one(appointmentTypes, {
fields: [appointmentWaitlist.typeId],
references: [appointmentTypes.id],
}),
})
);
Loading
Loading