import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } 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() + PORTAL_SESSION_IDLE_TTL_MS), }) .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 ); }); // GRO-2359 — register a brand-new SSO user. The post-auth handler in the // web portal redirects here when `session-from-auth` returns 404, so the // OOBE can complete a customer record for the new user. Auth is via the // Better Auth session (same shape as `session-from-auth`), so this is // registered BEFORE the `validatePortalSession` middleware. // // Contract: // POST /api/portal/clients-from-auth // Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null } // 201: { id, name, email } // 400: invalid body (zod failure) // 401: no Better Auth session // 409: a `clients` row already exists for this email (portal selection case) // 500: insert failed // // We do NOT auto-link the user's auth account to the new client row; the // existing `session-from-auth` endpoint re-resolves the row by email on the // next call, so the OOBE's success path just navigates the user back to // `/` and lets the bridge mint a portal session. const createClientFromAuthSchema = z.object({ name: z.string().min(1).max(200), phone: z.string().max(50).nullish(), address: z.string().max(500).nullish(), notes: z.string().max(2000).nullish(), }); portalRouter.post( "/clients-from-auth", zValidator("json", createClientFromAuthSchema), 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 body = c.req.valid("json"); const db = getDb(); // Pre-check: if a client already exists for this email, return 409 so // the OOBE can render the "portal selection" message (the user needs // to contact their groomer to link the new SSO identity to the // pre-existing customer record). We don't return the existing row to // avoid leaking PII about other accounts. const [existing] = await db .select({ id: clients.id }) .from(clients) .where(eq(clients.email, session.user.email)) .limit(1); if (existing) { return c.json( { error: "A customer record with this email already exists" }, 409, ); } let row; try { [row] = await db .insert(clients) .values({ name: body.name.trim(), email: session.user.email, phone: body.phone?.trim() || null, address: body.address?.trim() || null, notes: body.notes?.trim() || null, }) .returning(); } catch (err) { // Concurrent insert from a parallel OOBE submit — treat as 409. if ( err instanceof Error && "code" in err && (err as { code?: string }).code === "23505" ) { return c.json( { error: "A customer record with this email already exists" }, 409, ); } throw err; } if (!row) { return c.json({ error: "Failed to create client" }, 500); } return c.json( { id: row.id, name: row.name, email: row.email, }, 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); // GRO-2319: surface the client's ACTIVE waitlist entries alongside their // appointments so the portal can render them as `waitlisted` cards in the // Upcoming list. The `appointment_status` enum cannot represent `waitlisted`, // so these are synthetic entries (status hard-set to `waitlisted`, id prefixed // `waitlist:`) derived from `waitlist_entries`. const waitlistRows = await db .select({ id: waitlistEntries.id, petId: waitlistEntries.petId, serviceId: waitlistEntries.serviceId, preferredDate: waitlistEntries.preferredDate, preferredTime: waitlistEntries.preferredTime, }) .from(waitlistEntries) .where( and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")), ); // Pet lookups must cover both appointment and waitlist pets. const petIds = [ ...allAppts.map(a => a.petId).filter((id): id is string => id !== null), ...waitlistRows.map(w => w.petId), ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); // GRO-2342: services must be looked up for both appointment and waitlist cards // so the portal can render `service.name` in place of the fallback "Service" // label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the // service name; this follow-up closes the cosmetic gap). const serviceIds = [ ...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null), ...waitlistRows.map(w => w.serviceId).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 serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); const serviceMap = Object.fromEntries(serviceRows.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, name: serviceMap[a.serviceId]?.name } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); // Derive a display `startTime` from the entry's preferred date/time so the // portal can sort/classify the synthetic card (an invalid combination simply // yields a null startTime, which the portal tolerates). GRO-2342: also // populate the synthetic card's `service` object with the full service // record (id + name) — same shape the appointments join returns — so the // portal renders the real service name in place of the fallback "Service" // label. const waitlistAppts = waitlistRows.map(w => { const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; return { id: `waitlist:${w.id}`, startTime, endTime: null, status: "waitlisted" as const, confirmationStatus: null, customerNotes: null, notes: null, pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null, staff: null, }; }); return c.json({ appointments: [...appts, ...waitlistAppts] }); }); 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"); // GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID // string to a uuid column makes the driver throw ("invalid input syntax for // type uuid"), which previously surfaced as an unhandled 500. Mirror the // GRO-2014 fix in pets.ts and treat a malformed id as Not found. if (!z.string().uuid().safeParse(petId).success) { return c.json({ error: "Not found" }, 404); } 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 ──────────────────────────────────────────── // Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO // datetime), throwing a DateTimeParseError that surfaces as an unhandled 500. // Constrain client input here so malformed values are rejected with a 400 by // zValidator before they ever reach the DB (GRO-2211 defense-in-depth). const preferredDateSchema = z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD"); const preferredTimeSchema = z .string() .regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS"); // Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format. function normalizeTime(value: string): string { return value.length === 5 ? `${value}:00` : value; } const createWaitlistEntrySchema = z.object({ petId: z.string().uuid(), serviceId: z.string().uuid(), preferredDate: preferredDateSchema, preferredTime: preferredTimeSchema, }); const updateWaitlistEntrySchema = z.object({ status: z.literal("cancelled").optional(), preferredDate: preferredDateSchema.optional(), preferredTime: preferredTimeSchema.optional(), }); portalRouter.post( "/waitlist", zValidator("json", createWaitlistEntrySchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const clientId = c.get("portalClientId"); let entry; try { [entry] = await db .insert(waitlistEntries) .values({ clientId, petId: body.petId, serviceId: body.serviceId, preferredDate: body.preferredDate, preferredTime: normalizeTime(body.preferredTime), }) .returning(); } catch (err) { // An exact duplicate active waitlist entry violates the partial unique // index idx_waitlist_active_unique (client_id, pet_id, service_id, // preferred_date, preferred_time WHERE status='active'). postgres-js // surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a // generic 500 (GRO-2235). Unrelated errors still surface as 500. if ((err as { code?: string })?.code === "23505") { return c.json( { error: "You already have a booking for this pet at that date and time." }, 409 ); } throw err; } 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 = normalizeTime(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 }); });