diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5e6744b..7609eaf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,6 +12,7 @@ import { authRoutes } from './modules/auth/auth.controller'; import { clinicOnboardingRoutes } from './modules/clinics/onboarding.controller'; import { clinicSettingsRoutes } from './modules/clinics/settings.controller'; import { paymentRoutes } from './modules/payments/payments.controller'; +import { queueRoutes } from './modules/queue/queue.controller'; import { userRoutes } from './modules/users/users.controller'; import { notesRoutes } from './modules/notes/notes.controller'; import { vitalsRoutes } from './modules/vitals/vitals.controller'; @@ -28,6 +29,7 @@ app.use('/api/v1/clinics', clinicOnboardingRoutes); app.use('/api/v1/clinics', clinicSettingsRoutes); app.use('/api/v1/users', userRoutes); app.use('/api/v1/payments', paymentRoutes); +app.use('/api/v1/queue', queueRoutes); app.use('/api/v1/patients', patientRoutes); app.use('/api/v1/vitals', vitalsRoutes); app.use('/api/v1/encounters', encounterRoutes); diff --git a/apps/api/src/modules/queue/models/queue-encounter.model.ts b/apps/api/src/modules/queue/models/queue-encounter.model.ts new file mode 100644 index 0000000..f80fb1a --- /dev/null +++ b/apps/api/src/modules/queue/models/queue-encounter.model.ts @@ -0,0 +1,63 @@ +import { Schema, model, models } from "mongoose"; + +export type QueueStatus = "WAITING" | "TRIAGE" | "CONSULTATION"; +export type EncounterStatus = "OPEN" | "IN_PROGRESS" | "CLOSED"; + +export interface QueueEncounterDocument { + clinicId: string; + patientName: string; + systemId: string; + queueStatus: QueueStatus; + encounterStatus: EncounterStatus; + openedAt: Date; +} + +const queueEncounterSchema = new Schema( + { + clinicId: { + type: String, + required: true, + trim: true, + index: true, + }, + patientName: { + type: String, + required: true, + trim: true, + }, + systemId: { + type: String, + required: true, + trim: true, + index: true, + }, + queueStatus: { + type: String, + enum: ["WAITING", "TRIAGE", "CONSULTATION"], + required: true, + index: true, + }, + encounterStatus: { + type: String, + enum: ["OPEN", "IN_PROGRESS", "CLOSED"], + required: true, + default: "OPEN", + index: true, + }, + openedAt: { + type: Date, + required: true, + default: () => new Date(), + index: true, + }, + }, + { + timestamps: true, + versionKey: false, + }, +); + +queueEncounterSchema.index({ clinicId: 1, openedAt: -1 }); + +export const QueueEncounterModel = + models.QueueEncounter || model("QueueEncounter", queueEncounterSchema); diff --git a/apps/api/src/modules/queue/queue.controller.ts b/apps/api/src/modules/queue/queue.controller.ts new file mode 100644 index 0000000..c897e7e --- /dev/null +++ b/apps/api/src/modules/queue/queue.controller.ts @@ -0,0 +1,210 @@ +import { Request, Response, Router } from "express"; +import { authorize, Roles } from "../../middlewares/rbac.middleware"; +import { validateRequest } from "../../middlewares/validate.middleware"; +import { verifyAccessToken } from "../auth/token.service"; +import { emitQueueUpdate, subscribeQueueUpdates } from "./queue.events"; +import { QueueEncounterModel } from "./models/queue-encounter.model"; +import { + QueueStreamQueryDto, + RouteQueueEncounterBodyDto, + RouteQueueEncounterParamsDto, + queueStreamQuerySchema, + routeQueueEncounterBodySchema, + routeQueueEncounterParamsSchema, +} from "./queue.validation"; + +const router = Router(); +const ALL_ROLES: Roles[] = Object.values(Roles); + +type RouteQueueEncounterRequest = Request< + RouteQueueEncounterParamsDto, + unknown, + RouteQueueEncounterBodyDto +>; +type QueueStreamRequest = Request, unknown, unknown, QueueStreamQueryDto>; + +const startOfDay = (date: Date) => { + const result = new Date(date); + result.setHours(0, 0, 0, 0); + return result; +}; + +const endOfDay = (date: Date) => { + const result = new Date(date); + result.setHours(23, 59, 59, 999); + return result; +}; + +const buildMockQueueRows = (clinicId: string) => { + const now = Date.now(); + return [ + { + clinicId, + patientName: "Amina Kato", + systemId: "LMN-2041", + queueStatus: "WAITING" as const, + encounterStatus: "OPEN" as const, + openedAt: new Date(now - 22 * 60_000), + }, + { + clinicId, + patientName: "John Okello", + systemId: "LMN-2042", + queueStatus: "WAITING" as const, + encounterStatus: "OPEN" as const, + openedAt: new Date(now - 14 * 60_000), + }, + { + clinicId, + patientName: "Sarah Ninsiima", + systemId: "LMN-2043", + queueStatus: "TRIAGE" as const, + encounterStatus: "IN_PROGRESS" as const, + openedAt: new Date(now - 9 * 60_000), + }, + { + clinicId, + patientName: "David Mugisha", + systemId: "LMN-2044", + queueStatus: "CONSULTATION" as const, + encounterStatus: "IN_PROGRESS" as const, + openedAt: new Date(now - 31 * 60_000), + }, + { + clinicId, + patientName: "Mercy Atwine", + systemId: "LMN-2045", + queueStatus: "TRIAGE" as const, + encounterStatus: "IN_PROGRESS" as const, + openedAt: new Date(now - 5 * 60_000), + }, + ]; +}; + +const ensureQueueSeed = async (clinicId: string) => { + const todayStart = startOfDay(new Date()); + const todayEnd = endOfDay(new Date()); + + const existingCount = await QueueEncounterModel.countDocuments({ + clinicId, + openedAt: { $gte: todayStart, $lte: todayEnd }, + encounterStatus: { $in: ["OPEN", "IN_PROGRESS"] }, + }); + + if (existingCount > 0) { + return; + } + + await QueueEncounterModel.insertMany(buildMockQueueRows(clinicId)); +}; + +const toPayload = (doc: { + _id: unknown; + patientName: string; + systemId: string; + queueStatus: "WAITING" | "TRIAGE" | "CONSULTATION"; + encounterStatus: "OPEN" | "IN_PROGRESS" | "CLOSED"; + openedAt: Date; +}) => ({ + id: String(doc._id), + patientName: doc.patientName, + systemId: doc.systemId, + queueStatus: doc.queueStatus, + encounterStatus: doc.encounterStatus, + openedAt: doc.openedAt.toISOString(), + waitMinutes: Math.max(0, Math.floor((Date.now() - doc.openedAt.getTime()) / 60_000)), +}); + +router.get("/today", authorize(ALL_ROLES), async (req: Request, res: Response) => { + const clinicId = req.user?.clinicId; + if (!clinicId) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + await ensureQueueSeed(clinicId); + + const todayStart = startOfDay(new Date()); + const todayEnd = endOfDay(new Date()); + + const rows = await QueueEncounterModel.find({ + clinicId, + openedAt: { $gte: todayStart, $lte: todayEnd }, + encounterStatus: { $in: ["OPEN", "IN_PROGRESS"] }, + }) + .sort({ openedAt: 1 }) + .lean(); + + return res.json({ + status: "success", + data: rows.map((row) => toPayload(row as Parameters[0])), + }); +}); + +router.get( + "/stream", + validateRequest({ query: queueStreamQuerySchema }), + async (req: QueueStreamRequest, res: Response) => { + const user = verifyAccessToken(req.query.token); + if (!user) { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid or expired token", + }); + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders?.(); + + res.write(`event: connected\ndata: ${JSON.stringify({ ok: true })}\n\n`); + + subscribeQueueUpdates(user.clinicId, res); + }, +); + +router.patch( + "/:id/route", + authorize(ALL_ROLES), + validateRequest({ params: routeQueueEncounterParamsSchema, body: routeQueueEncounterBodySchema }), + async (req: RouteQueueEncounterRequest, res: Response) => { + const clinicId = req.user?.clinicId; + if (!clinicId) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const updated = await QueueEncounterModel.findOneAndUpdate( + { _id: req.params.id, clinicId, encounterStatus: { $in: ["OPEN", "IN_PROGRESS"] } }, + { + $set: { + queueStatus: req.body.queueStatus, + encounterStatus: req.body.queueStatus === "CONSULTATION" ? "IN_PROGRESS" : "OPEN", + }, + }, + { new: true }, + ).lean(); + + if (!updated) { + return res.status(404).json({ + error: "NotFound", + message: "Queue item not found", + }); + } + + const payload = toPayload(updated as Parameters[0]); + emitQueueUpdate(clinicId, { type: "queue.updated", item: payload }); + + return res.json({ + status: "success", + data: payload, + }); + }, +); + +export const queueRoutes = router; diff --git a/apps/api/src/modules/queue/queue.events.ts b/apps/api/src/modules/queue/queue.events.ts new file mode 100644 index 0000000..8b19ce4 --- /dev/null +++ b/apps/api/src/modules/queue/queue.events.ts @@ -0,0 +1,52 @@ +import { Response } from "express"; + +const subscribersByClinic = new Map>(); + +export const subscribeQueueUpdates = (clinicId: string, res: Response) => { + const clinicSubscribers = subscribersByClinic.get(clinicId) ?? new Set(); + clinicSubscribers.add(res); + subscribersByClinic.set(clinicId, clinicSubscribers); + + const heartbeat = setInterval(() => { + if (!res.writableEnded) { + res.write(`event: heartbeat\ndata: ${JSON.stringify({ ok: true })}\n\n`); + } + }, 15_000); + + const cleanup = () => { + clearInterval(heartbeat); + + const current = subscribersByClinic.get(clinicId); + if (!current) return; + + current.delete(res); + if (current.size === 0) { + subscribersByClinic.delete(clinicId); + } + }; + + res.on("close", cleanup); + res.on("finish", cleanup); +}; + +export const emitQueueUpdate = (clinicId: string, payload: unknown) => { + const clinicSubscribers = subscribersByClinic.get(clinicId); + if (!clinicSubscribers || clinicSubscribers.size === 0) { + return; + } + + const data = `event: queue.update\ndata: ${JSON.stringify(payload)}\n\n`; + + clinicSubscribers.forEach((res) => { + if (res.writableEnded) { + clinicSubscribers.delete(res); + return; + } + + res.write(data); + }); + + if (clinicSubscribers.size === 0) { + subscribersByClinic.delete(clinicId); + } +}; diff --git a/apps/api/src/modules/queue/queue.validation.ts b/apps/api/src/modules/queue/queue.validation.ts new file mode 100644 index 0000000..969d551 --- /dev/null +++ b/apps/api/src/modules/queue/queue.validation.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const routeQueueEncounterParamsSchema = z.object({ + id: z.string().regex(/^[0-9a-fA-F]{24}$/, "Queue encounter id must be a valid ObjectId"), +}); + +export const routeQueueEncounterBodySchema = z.object({ + queueStatus: z.enum(["WAITING", "TRIAGE", "CONSULTATION"]), +}); + +export const queueStreamQuerySchema = z.object({ + token: z.string().min(1, "token is required"), +}); + +export type RouteQueueEncounterParamsDto = z.infer; +export type RouteQueueEncounterBodyDto = z.infer; +export type QueueStreamQueryDto = z.infer; diff --git a/apps/web/app/dashboard/queue/page.tsx b/apps/web/app/dashboard/queue/page.tsx new file mode 100644 index 0000000..c6d8008 --- /dev/null +++ b/apps/web/app/dashboard/queue/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { apiFetch } from "@/lib/api-client"; +import { useAuth } from "@/providers/AuthProvider"; + +type QueueStatus = "WAITING" | "TRIAGE" | "CONSULTATION"; + +type QueueItem = { + id: string; + patientName: string; + systemId: string; + queueStatus: QueueStatus; + encounterStatus: "OPEN" | "IN_PROGRESS" | "CLOSED"; + openedAt: string; + waitMinutes: number; +}; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000/api/v1"; + +const COLUMNS: Array<{ key: QueueStatus; label: string }> = [ + { key: "WAITING", label: "Waiting Room" }, + { key: "TRIAGE", label: "Triage" }, + { key: "CONSULTATION", label: "Consultation" }, +]; + +const formatWaitTime = (openedAt: string, nowTick: number) => { + const diffMs = Math.max(0, nowTick - new Date(openedAt).getTime()); + const totalMinutes = Math.floor(diffMs / 60_000); + const hours = Math.floor(totalMinutes / 60) + .toString() + .padStart(2, "0"); + const mins = (totalMinutes % 60).toString().padStart(2, "0"); + return `${hours}:${mins}`; +}; + +const nextStage = (current: QueueStatus): QueueStatus => { + if (current === "WAITING") { + return "TRIAGE"; + } + if (current === "TRIAGE") { + return "CONSULTATION"; + } + return "CONSULTATION"; +}; + +export default function QueuePage() { + const { tokens } = useAuth(); + const [items, setItems] = useState([]); + const [selectedTargetById, setSelectedTargetById] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [nowTick, setNowTick] = useState(() => Date.now()); + + const fetchQueue = async () => { + setError(null); + + try { + const response = await apiFetch("/queue/today"); + if (!response.ok) { + throw new Error("Failed to fetch queue"); + } + + const payload = (await response.json()) as { data?: QueueItem[] }; + setItems(payload.data ?? []); + } catch { + setError("Unable to load queue data."); + setItems([]); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void fetchQueue(); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + setNowTick(Date.now()); + }, 1000); + + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + const interval = window.setInterval(() => { + void fetchQueue(); + }, 5000); + + return () => window.clearInterval(interval); + }, []); + + useEffect(() => { + if (!tokens?.accessToken) { + return; + } + + const stream = new EventSource( + `${API_BASE}/queue/stream?token=${encodeURIComponent(tokens.accessToken)}`, + ); + + const onUpdate = (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { + type?: string; + item?: QueueItem; + }; + + if (payload.type !== "queue.updated" || !payload.item) { + return; + } + + setItems((current) => { + const index = current.findIndex((item) => item.id === payload.item!.id); + if (index === -1) { + return [...current, payload.item!]; + } + + const next = [...current]; + next[index] = payload.item!; + return next; + }); + } catch { + // no-op for malformed stream payloads + } + }; + + stream.addEventListener("queue.update", onUpdate as EventListener); + + return () => { + stream.removeEventListener("queue.update", onUpdate as EventListener); + stream.close(); + }; + }, [tokens?.accessToken]); + + const grouped = useMemo(() => { + const groups: Record = { + WAITING: [], + TRIAGE: [], + CONSULTATION: [], + }; + + items.forEach((item) => { + groups[item.queueStatus].push(item); + }); + + return groups; + }, [items]); + + const moveQueueItem = async (item: QueueItem) => { + const target = selectedTargetById[item.id] ?? nextStage(item.queueStatus); + + const response = await apiFetch(`/queue/${item.id}/route`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ queueStatus: target }), + }); + + if (!response.ok) { + setError("Failed to route patient. Please retry."); + return; + } + + const payload = (await response.json()) as { data?: QueueItem }; + if (!payload.data) { + return; + } + + setItems((current) => + current.map((entry) => (entry.id === payload.data!.id ? payload.data! : entry)), + ); + }; + + return ( +
+
+

Clinic Queue

+

+ Real-time routing board for waiting room, triage, and consultation flow. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {isLoading ? ( +
+ Loading queue... +
+ ) : ( +
+ {COLUMNS.map((column) => ( +
+
+

+ {column.label} +

+ + {grouped[column.key].length} + +
+ +
+ {grouped[column.key].length === 0 ? ( +

+ No patients in this lane. +

+ ) : ( + grouped[column.key].map((item) => ( +
+
+
+

{item.patientName}

+

{item.systemId}

+
+ + Wait {formatWaitTime(item.openedAt, nowTick)} + +
+ +
+ + +
+
+ )) + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/layouts/DashboardLayout.tsx b/apps/web/layouts/DashboardLayout.tsx index 421fb35..196503a 100644 --- a/apps/web/layouts/DashboardLayout.tsx +++ b/apps/web/layouts/DashboardLayout.tsx @@ -7,6 +7,7 @@ import { SubscriptionProvider } from "@/providers/SubscriptionProvider"; const NAV = [ { href: "/dashboard", label: "Dashboard", disabled: false }, + { href: "/dashboard/queue", label: "Queue", disabled: false }, { href: "/dashboard/encounters", label: "Encounters", disabled: false }, { href: "/dashboard/vitals", label: "Vitals", disabled: false }, { href: "/dashboard/notes", label: "Notes", disabled: false },