import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { validatePortalSession } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; import type { PortalEnv } from "../middleware/portalSession.js"; export const portalRouter = new Hono(); // Dev-mode session creation — must be registered BEFORE the /* middleware so it is // NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates // the impersonation session and has no X-Impersonation-Session-Id header yet. const devSessionSchema = z.object({ clientId: z.string().uuid(), }); portalRouter.post( "/dev-session", zValidator("json", devSessionSchema), async (c) => { if (process.env.AUTH_DISABLED !== "true") { return c.json({ error: "Not available when auth is enabled" }, 403); } const db = getDb(); const body = c.req.valid("json"); const [client] = await db .select() .from(clients) .where(eq(clients.id, body.clientId)) .limit(1); if (!client) { return c.json({ error: "Client not found" }, 404); } const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001"; let staffId = DEMO_STAFF_ID; const [demoStaff] = await db .select({ id: staff.id }) .from(staff) .where(eq(staff.id, DEMO_STAFF_ID)) .limit(1); if (!demoStaff) { const [firstStaff] = await db .select({ id: staff.id }) .from(staff) .where(eq(staff.active, true)) .limit(1); if (!firstStaff) { return c.json({ error: "No staff records found. Run the database seed." }, 500); } staffId = firstStaff.id; } const [session] = await db .insert(impersonationSessions) .values({ staffId, clientId: body.clientId, reason: "dev-mode-client-portal", expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }) .returning(); return c.json(session, 201); } ); // Bridge Better Auth session → portal session for real SSO customers (GRO-1866). // Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession. import { getAuth } from "../lib/auth.js"; portalRouter.post("/session-from-auth", async (c) => { let auth; try { auth = getAuth(); } catch { return c.json({ error: "Authentication not configured" }, 503); } const session = await auth.api.getSession({ headers: c.req.raw.headers, }); if (!session) { return c.json({ error: "Unauthorized" }, 401); } const db = getDb(); const [client] = await db .select() .from(clients) .where(eq(clients.email, session.user.email)) .limit(1); if (!client) { return c.json({ error: "No client record found for this user" }, 404); } const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001"; let staffId = DEMO_STAFF_ID; const [demoStaff] = await db .select({ id: staff.id }) .from(staff) .where(eq(staff.id, DEMO_STAFF_ID)) .limit(1); if (!demoStaff) { const [firstStaff] = await db .select({ id: staff.id }) .from(staff) .where(eq(staff.active, true)) .limit(1); if (!firstStaff) { return c.json({ error: "No staff records found" }, 500); } staffId = firstStaff.id; } const [portalSession] = await db .insert(impersonationSessions) .values({ staffId, clientId: client.id, reason: "sso-bridge", expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }) .returning(); if (!portalSession) { return c.json({ error: "Failed to create session" }, 500); } return c.json( { sessionId: portalSession.id, clientId: client.id, clientName: client.name, }, 201 ); }); // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit); // ─── GET routes ────────────────────────────────────────────────────────────── portalRouter.get("/me", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); if (!client) return c.json({ error: "Not found" }, 404); return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone }); }); portalRouter.get("/config", async (c) => { return c.json({ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "", }); }); portalRouter.get("/services", async (c) => { const db = getDb(); const allServices = await db.select().from(services).where(eq(services.active, true)); return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes }))); }); portalRouter.get("/appointments", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); const allAppts = await db .select({ id: appointments.id, startTime: appointments.startTime, endTime: appointments.endTime, status: appointments.status, confirmationStatus: appointments.confirmationStatus, customerNotes: appointments.customerNotes, notes: appointments.notes, petId: appointments.petId, serviceId: appointments.serviceId, staffId: appointments.staffId, }) .from(appointments) .where(eq(appointments.clientId, clientId)) .orderBy(appointments.startTime); const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); const appts = allAppts.map(a => ({ id: a.id, startTime: a.startTime, endTime: a.endTime, status: a.status, confirmationStatus: a.confirmationStatus, customerNotes: a.customerNotes, notes: a.notes, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, service: a.serviceId ? { id: a.serviceId } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); return c.json({ appointments: appts }); }); portalRouter.get("/pets", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes, coatType: p.coatType, petSizeCategory: p.petSizeCategory, healthAlerts: p.healthAlerts, preferredCuts: p.preferredCuts, medicalAlerts: p.medicalAlerts, }))); }); // ─── Customer-facing pet update ─────────────────────────────────────────────── // // The customer portal pet-profile form (groombook/web) saves edits via // PATCH /api/portal/pets/:petId. The web payload mixes the keys returned by // GET /portal/pets (weight, birthDate, photoUrl, notes) with the form's own // edited keys (weightKg, healthAlerts, coatType, …), so we accept both spellings // and map each to its `pets` column. Ownership is enforced exactly like the // appointment-notes handler: 404 if the pet does not exist, 403 if it belongs to // another client. // Allowed enum values mirror packages/db/src/schema.ts coatTypeEnum / // petSizeCategoryEnum. Kept as plain string lists so an invalid value can be // rejected with 422 in-handler (zValidator failures would surface as 400). const PORTAL_COAT_TYPES: readonly string[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]; const PORTAL_PET_SIZES: readonly string[] = ["small", "medium", "large", "extra_large"]; // The web size dropdown emits "xlarge"; the DB enum value is "extra_large". const PORTAL_PET_SIZE_ALIASES: Record = { xlarge: "extra_large" }; const portalMedicalAlertSchema = z.object({ id: z.string().optional(), type: z.string().max(2000), description: z.string().max(2000), severity: z.enum(["low", "medium", "high"]), }); const portalPetUpdateSchema = z.object({ name: z.string().min(1).max(200).optional(), breed: z.string().max(200).nullable().optional(), // weightKg is the form's edited key; weight is the GET-shaped key. Accept both. weightKg: z.union([z.number(), z.string()]).nullable().optional(), weight: z.union([z.number(), z.string()]).nullable().optional(), birthDate: z.string().nullable().optional(), notes: z.string().max(2000).nullable().optional(), healthAlerts: z.string().max(2000).nullable().optional(), // photoUrl/photoKey are intentionally NOT writable here: photoKey is a trusted // S3 object key consumed server-side (getPresignedGetUrl / deleteObject), and the // upload path (pets.ts) already enforces a pets/{petId}/ prefix guard against key // hijacking. Photo changes go through the dedicated upload + /photo/confirm flow. // The web form round-trips the GET-shaped photoUrl; Zod strips it as an unknown key. // coatType / petSizeCategory validated in-handler so bad values return 422. coatType: z.string().nullable().optional(), petSizeCategory: z.string().nullable().optional(), preferredCuts: z.array(z.string().max(2000)).max(50).nullable().optional(), medicalAlerts: z.array(portalMedicalAlertSchema).max(50).nullable().optional(), }); portalRouter.patch( "/pets/:petId", zValidator("json", portalPetUpdateSchema), async (c) => { const db = getDb(); const petId = c.req.param("petId"); const body = c.req.valid("json"); const clientId = c.get("portalClientId"); const [pet] = await db .select() .from(pets) .where(eq(pets.id, petId)) .limit(1); if (!pet) { return c.json({ error: "Not found" }, 404); } if (pet.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } const updateData: Record = { updatedAt: new Date() }; if (body.name !== undefined) updateData.name = body.name; if (body.breed !== undefined) updateData.breed = body.breed; if (body.weightKg !== undefined || body.weight !== undefined) { const w = body.weightKg ?? body.weight; updateData.weightKg = w === null || w === undefined ? null : String(w); } if (body.birthDate !== undefined) { updateData.dateOfBirth = body.birthDate ? new Date(body.birthDate) : null; } if (body.notes !== undefined) updateData.groomingNotes = body.notes; if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts; // photoKey is intentionally not writable here — see portalPetUpdateSchema note. // Photo changes go through the key-validated upload + /photo/confirm flow. if (body.coatType !== undefined) { if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) { return c.json({ error: "Invalid coatType" }, 422); } updateData.coatType = body.coatType; } if (body.petSizeCategory !== undefined) { let size: string | null = body.petSizeCategory; if (size !== null) { size = PORTAL_PET_SIZE_ALIASES[size] ?? size; if (!PORTAL_PET_SIZES.includes(size)) { return c.json({ error: "Invalid petSizeCategory" }, 422); } } updateData.petSizeCategory = size; } if (body.preferredCuts !== undefined) updateData.preferredCuts = body.preferredCuts ?? []; if (body.medicalAlerts !== undefined) updateData.medicalAlerts = body.medicalAlerts ?? []; const [updated] = await db .update(pets) .set(updateData) .where(eq(pets.id, petId)) .returning(); if (!updated) { return c.json({ error: "Not found" }, 404); } return c.json({ id: updated.id, name: updated.name, breed: updated.breed, weight: updated.weightKg, birthDate: updated.dateOfBirth, photoUrl: updated.photoKey, notes: updated.groomingNotes, coatType: updated.coatType, petSizeCategory: updated.petSizeCategory, healthAlerts: updated.healthAlerts, preferredCuts: updated.preferredCuts, medicalAlerts: updated.medicalAlerts, }); } ); portalRouter.get("/invoices", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const invoiceIds = clientInvoices.map(i => i.id); const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : []; const itemsByInvoice: Record = {}; for (const li of lineItems) { if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = []; itemsByInvoice[li.invoiceId]!.push(li); } return c.json(clientInvoices.map(inv => ({ id: inv.id, status: inv.status, totalCents: inv.totalCents, date: inv.createdAt, lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })), }))); }); // ─── 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 clientId = c.get("portalClientId"); 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 clientId = c.get("portalClientId"); 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 clientId = c.get("portalClientId"); 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 clientId = c.get("portalClientId"); 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 clientId = c.get("portalClientId"); 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 !== 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 clientId = c.get("portalClientId"); 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 !== clientId) { return c.json({ error: "Forbidden" }, 403); } await db .delete(waitlistEntries) .where(eq(waitlistEntries.id, id)) .returning(); return c.json({ ok: true }); }); // ─── Payment routes ─────────────────────────────────────────────────────────── import { createPaymentIntent, listPaymentMethods, detachPaymentMethod, createSetupIntent, getOrCreateStripeCustomer, getStripeClient, } from "../services/payment.js"; const payMultipleSchema = z.object({ invoiceIds: z.array(z.string().uuid()).min(1), }); portalRouter.post( "/invoices/pay-multiple", zValidator("json", payMultipleSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const clientId = c.get("portalClientId"); const invoiceRows = await db .select() .from(invoices) .where(inArray(invoices.id, body.invoiceIds)); if (invoiceRows.length !== body.invoiceIds.length) { return c.json({ error: "One or more invoices not found" }, 404); } for (const inv of invoiceRows) { if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403); if (inv.status === "draft" || inv.status === "void") { return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422); } if (inv.status === "paid") { return c.json({ error: `Invoice ${inv.id} is already paid` }, 422); } } const firstInvoice = invoiceRows[0]; if (!firstInvoice) return c.json({ error: "No invoices found" }, 400); const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId); if (!allSameClient) { return c.json({ error: "All invoices must belong to the same client" }, 422); } const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const result = await createPaymentIntent(body.invoiceIds, clientId); if (!result) return c.json({ error: "Payment service unavailable" }, 503); return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); } ); portalRouter.get("/payment-methods", async (c) => { const clientId = c.get("portalClientId"); const methods = await listPaymentMethods(clientId); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); return c.json(methods); }); portalRouter.post("/payment-methods", async (c) => { const clientId = c.get("portalClientId"); const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const customerId = await getOrCreateStripeCustomer(clientId); if (!customerId) return c.json({ error: "Could not create customer" }, 500); const result = await createSetupIntent(customerId); if (!result) return c.json({ error: "Payment service unavailable" }, 503); return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); }); portalRouter.delete("/payment-methods/:id", async (c) => { const clientId = c.get("portalClientId"); const paymentMethodId = c.req.param("id"); const stripeCustomerId = await getOrCreateStripeCustomer(clientId); if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404); const stripe = getStripeClient(); if (!stripe) return c.json({ error: "Payment service unavailable" }, 503); const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) { return c.json({ error: "Payment method not found" }, 404); } const ok = await detachPaymentMethod(paymentMethodId); if (!ok) return c.json({ error: "Failed to detach payment method" }, 500); return c.json({ ok: true }); });