From d62f2cf776b2d26893001f954322dd8eeffc1593 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 23:15:05 +0000 Subject: [PATCH] fix(portal): restore missing appointment action and waitlist routes The portal.ts refactor (GRO-218) dropped several route handlers that tests depend on. Restored: - PATCH /portal/appointments/:id/notes - POST /portal/appointments/:id/confirm - POST /portal/appointments/:id/cancel - POST /portal/waitlist - PATCH /portal/waitlist/:id - DELETE /portal/waitlist/:id All 190 API tests now pass. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 321 +++++++++++++++++++++++++++++++++- 1 file changed, 319 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index dd6d1f2..2754bf6 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -126,5 +126,322 @@ portalRouter.get("/invoices", async (c) => { }))); }); -// ─── Existing PATCH /appointments/:id/notes route ───────────────────────────── -// (keep all existing routes below - do not remove or modify anything below this line) \ No newline at end of file +// ─── Appointment action routes ──────────────────────────────────────────────── + +const customerNotesSchema = z.object({ + // .min(1) prevents empty strings — clearing notes is not a supported use case + customerNotes: z.string().min(1).max(500), +}); + +portalRouter.patch( + "/appointments/:id/notes", + zValidator("json", customerNotesSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ customerNotes: body.customerNotes, updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + customerNotes: updated.customerNotes, + updatedAt: updated.updatedAt, + }); + } +); + +// ─── Appointment confirm/cancel ────────────────────────────────────────────── + +portalRouter.post("/appointments/:id/confirm", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422); + } + + if (appt.confirmationStatus !== "pending") { + return c.json({ error: "Appointment is not pending confirmation" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated!.id, + confirmationStatus: updated!.confirmationStatus, + confirmedAt: updated!.confirmedAt, + updatedAt: updated!.updatedAt, + }); +}); + +portalRouter.post("/appointments/:id/cancel", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Appointment is already cancelled or completed" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated!.id, + status: updated!.status, + confirmationStatus: updated!.confirmationStatus, + cancelledAt: updated!.cancelledAt, + updatedAt: updated!.updatedAt, + }); +}); + +// ─── Client-facing waitlist routes ──────────────────────────────────────────── + +const createWaitlistEntrySchema = z.object({ + petId: z.string().uuid(), + serviceId: z.string().uuid(), + preferredDate: z.string(), + preferredTime: z.string(), +}); + +const updateWaitlistEntrySchema = z.object({ + status: z.literal("cancelled").optional(), + preferredDate: z.string().optional(), + preferredTime: z.string().optional(), +}); + +portalRouter.post( + "/waitlist", + zValidator("json", createWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + let clientId: string | null = null; + if (sessionId) { + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + if (session && session.expiresAt > new Date()) { + clientId = session.clientId; + } + } + + if (!clientId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: body.preferredTime, + }) + .returning(); + + return c.json(entry, 201); + } +); + +portalRouter.patch( + "/waitlist/:id", + zValidator("json", updateWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [existing] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!existing) return c.json({ error: "Not found" }, 404); + if (existing.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + const updateData: Record = { updatedAt: new Date() }; + if (body.status !== undefined) updateData.status = body.status; + if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; + if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; + + const [updated] = await db + .update(waitlistEntries) + .set(updateData) + .where(eq(waitlistEntries.id, id)) + .returning(); + + return c.json(updated); + } +); + +portalRouter.delete("/waitlist/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [entry] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!entry) return c.json({ error: "Not found" }, 404); + if (entry.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + await db + .delete(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .returning(); + + return c.json({ ok: true }); +}); \ No newline at end of file