Files
api/src/routes/portal.ts
T
Flea Flicker bedeb05a67
CI / Lint & Typecheck (push) Successful in 30s
CI / Test (push) Failing after 30s
CI / Build & Push Docker Images (push) Has been skipped
Promote uat → main (PROD): GRO-2359 OOBE portal-creation routing (api) (#214)
GRO-2359: add POST /api/portal/clients-from-auth for OOBE (#214)
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-12 16:47:30 +00:00

950 lines
32 KiB
TypeScript

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<PortalEnv>();
// 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<string, string> = { 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<string, unknown> = { 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<string, typeof lineItems> = {};
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<string, unknown> = { 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 });
});