dev → uat: GRO-2187 portal pet PATCH + GET enrichment (carries GRO-2152) #166

Merged
Flea Flicker merged 3 commits from dev-to-uat-gro-2187 into uat 2026-06-08 10:02:17 +00:00
7 changed files with 575 additions and 2 deletions
+4
View File
@@ -259,6 +259,10 @@ This means:
| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized |
| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" |
| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned |
| TC-API-8.12 | Portal GET pets returns extended fields (GRO-2187) | Establish a portal session (TC-API-8.8), then `GET /api/portal/pets` with `X-Impersonation-Session-Id` | 200 OK; each pet includes `coatType`, `petSizeCategory`, `healthAlerts`, `preferredCuts`, `medicalAlerts` (in addition to id/name/breed/weight/birthDate/photoUrl/notes) |
| TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values |
| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted |
| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged |
### 4.9 Waitlist
@@ -0,0 +1,66 @@
-- Migration: 0041_route_optimization.sql
-- Route optimization schema: geocoding columns on clients, groomerRoutes +
-- routeStops tables, and route settings on business_settings.
-- Written idempotently so it is safe to re-run.
-- ─── Enums ────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE "route_status" AS ENUM ('draft', 'optimized', 'in_progress', 'completed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ─── Clients: geocoding columns ───────────────────────────────────────────────
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "latitude" double precision;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "longitude" double precision;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "geocoded_at" timestamp;
-- ─── Business settings: route optimization config ─────────────────────────────
ALTER TABLE "business_settings"
ADD COLUMN IF NOT EXISTS "default_travel_buffer_mins" integer NOT NULL DEFAULT 15;
ALTER TABLE "business_settings"
ADD COLUMN IF NOT EXISTS "route_optimization_provider" text DEFAULT 'nominatim';
-- Encrypted at rest at the application layer (AES-256-GCM).
ALTER TABLE "business_settings"
ADD COLUMN IF NOT EXISTS "google_maps_api_key" text;
-- ─── Groomer routes table ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "groomer_routes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"staff_id" uuid NOT NULL REFERENCES "staff"("id") ON DELETE CASCADE,
"route_date" date NOT NULL,
"status" "route_status" NOT NULL DEFAULT 'draft',
"total_travel_mins" integer,
"total_distance_km" numeric(8, 2),
"optimized_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "uq_groomer_routes_staff_date" UNIQUE ("staff_id", "route_date")
);
CREATE INDEX IF NOT EXISTS "idx_groomer_routes_staff_id"
ON "groomer_routes"("staff_id");
-- ─── Route stops table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "route_stops" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"route_id" uuid NOT NULL REFERENCES "groomer_routes"("id") ON DELETE CASCADE,
"appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE,
"stop_order" integer NOT NULL,
"latitude" double precision NOT NULL,
"longitude" double precision NOT NULL,
"travel_mins_from_prev" integer,
"travel_distance_km_from_prev" numeric(8, 2),
"buffer_mins" integer NOT NULL DEFAULT 15,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "uq_route_stops_route_appointment" UNIQUE ("route_id", "appointment_id"),
CONSTRAINT "uq_route_stops_route_order" UNIQUE ("route_id", "stop_order")
);
CREATE INDEX IF NOT EXISTS "idx_route_stops_route_id"
ON "route_stops"("route_id");
+8 -1
View File
@@ -281,6 +281,13 @@
"when": 1780000000002,
"tag": "0040_register_missing_coat_type_values",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1780000000003,
"tag": "0041_route_optimization",
"breakpoints": true
}
]
}
}
+3
View File
@@ -78,6 +78,9 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
stripeCustomerId: null,
status: "active",
disabledAt: null,
latitude: null,
longitude: null,
geocodedAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
...overrides,
+82
View File
@@ -1,5 +1,7 @@
import {
boolean,
date,
doublePrecision,
index,
integer,
jsonb,
@@ -140,6 +142,10 @@ export const clients = pgTable(
stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
// Geocoded coordinates for route optimization; null until geocoded.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
geocodedAt: timestamp("geocoded_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
@@ -555,6 +561,16 @@ export const businessSettings = pgTable("business_settings", {
accentColor: text("accent_color").notNull().default("#8b7355"),
messagingPhoneNumber: text("messaging_phone_number"),
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
// Route optimization settings.
defaultTravelBufferMins: integer("default_travel_buffer_mins")
.notNull()
.default(15),
routeOptimizationProvider: text("route_optimization_provider").default(
"nominatim"
),
// Encrypted at rest at the application layer (AES-256-GCM), mirroring
// the handling of authProviderConfigs.clientSecret.
googleMapsApiKey: text("google_maps_api_key"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -658,3 +674,69 @@ export const bufferRules = pgTable(
index("idx_buffer_rules_service_id").on(t.serviceId),
]
);
// ─── Route Optimization ───────────────────────────────────────────────────────
export const routeStatusEnum = pgEnum("route_status", [
"draft",
"optimized",
"in_progress",
"completed",
]);
// A groomer's optimized route for a single day. One row per (staff, date).
export const groomerRoutes = pgTable(
"groomer_routes",
{
id: uuid("id").primaryKey().defaultRandom(),
staffId: uuid("staff_id")
.notNull()
.references(() => staff.id, { onDelete: "cascade" }),
routeDate: date("route_date", { mode: "string" }).notNull(),
status: routeStatusEnum("status").notNull().default("draft"),
// Populated once the route is optimized.
totalTravelMins: integer("total_travel_mins"),
totalDistanceKm: numeric("total_distance_km", { precision: 8, scale: 2 }),
optimizedAt: timestamp("optimized_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
// One route per groomer per day.
unique("uq_groomer_routes_staff_date").on(t.staffId, t.routeDate),
index("idx_groomer_routes_staff_id").on(t.staffId),
]
);
// An ordered stop within a groomer's route, tied to an appointment.
export const routeStops = pgTable(
"route_stops",
{
id: uuid("id").primaryKey().defaultRandom(),
routeId: uuid("route_id")
.notNull()
.references(() => groomerRoutes.id, { onDelete: "cascade" }),
appointmentId: uuid("appointment_id")
.notNull()
.references(() => appointments.id, { onDelete: "cascade" }),
stopOrder: integer("stop_order").notNull(),
latitude: doublePrecision("latitude").notNull(),
longitude: doublePrecision("longitude").notNull(),
// Null for the first stop in the route.
travelMinsFromPrev: integer("travel_mins_from_prev"),
travelDistanceKmFromPrev: numeric("travel_distance_km_from_prev", {
precision: 8,
scale: 2,
}),
bufferMins: integer("buffer_mins").notNull().default(15),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
// An appointment appears at most once per route.
unique("uq_route_stops_route_appointment").on(t.routeId, t.appointmentId),
// Stop order is unique within a route.
unique("uq_route_stops_route_order").on(t.routeId, t.stopOrder),
index("idx_route_stops_route_id").on(t.routeId),
]
);
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const OTHER_CLIENT_ID = "550e8400-e29b-41d4-a716-446655440099";
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: futureDate(),
createdAt: new Date(),
};
// A persisted pet owned by CLIENT_ID. weightKg is a string because the column is
// numeric (Drizzle serialises numeric to string).
const PET = {
id: PET_ID,
clientId: CLIENT_ID,
name: "Rex",
species: "dog",
breed: "Labrador",
weightKg: "12.50",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
coatType: null,
petSizeCategory: null,
preferredCuts: [],
medicalAlerts: [],
photoKey: null,
};
let selectSessionRow: Record<string, unknown> | null = null;
let selectPetRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectPetRow = null;
updatedValues = [];
}
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
function tableProxy(name: string) {
return new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
}
const impersonationSessions = tableProxy("impersonationSessions");
const pets = tableProxy("pets");
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
}
if (table._name === "pets") {
return makeChainable(selectPetRow ? [selectPetRow] : []);
}
return makeChainable([]);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
if (selectPetRow) {
updatedValues.push(vals);
return [{ ...selectPetRow, ...vals }];
}
return [];
},
}),
}),
}),
// portalAudit inserts an audit row after the handler; make it a no-op so
// the middleware does not log a swallowed error during tests.
insert: () => ({ values: () => ({ returning: () => [] }) }),
}),
impersonationSessions,
pets,
// Other tables imported by the portal router but unused in these tests.
appointments: tableProxy("appointments"),
waitlistEntries: tableProxy("waitlistEntries"),
clients: tableProxy("clients"),
services: tableProxy("services"),
staff: tableProxy("staff"),
invoices: tableProxy("invoices"),
invoiceLineItems: tableProxy("invoiceLineItems"),
impersonationAuditLogs: tableProxy("impersonationAuditLogs"),
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
function jsonPatch(path: string, body: unknown, headers?: Record<string, string>) {
return app.request(path, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
}
beforeEach(() => resetMock());
describe("PATCH /portal/pets/:petId", () => {
it("updates an owned pet and persists the mapped columns (200)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
// Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet
// (weight, notes, birthDate, photoUrl) and adds the form's edited keys
// (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large".
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
id: PET_ID,
name: "Rex Updated",
breed: "Golden Retriever",
weight: "12.50",
weightKg: 18.25,
notes: "old grooming notes",
healthAlerts: "Allergic to oatmeal shampoo",
photoUrl: "pets/rex.jpg",
coatType: "double",
petSizeCategory: "xlarge",
preferredCuts: ["teddy bear", "puppy cut"],
medicalAlerts: [
{ id: "a1", type: "allergy", description: "oatmeal", severity: "medium" },
],
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Rex Updated");
expect(body.petSizeCategory).toBe("extra_large");
expect(body.coatType).toBe("double");
const persisted = updatedValues[0]!;
expect(persisted.name).toBe("Rex Updated");
expect(persisted.breed).toBe("Golden Retriever");
// weightKg (form key) wins over weight (GET key) and is stored as a string.
expect(persisted.weightKg).toBe("18.25");
expect(persisted.groomingNotes).toBe("old grooming notes");
expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo");
expect(persisted.photoKey).toBe("pets/rex.jpg");
expect(persisted.coatType).toBe("double");
expect(persisted.petSizeCategory).toBe("extra_large");
expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]);
expect(persisted.medicalAlerts).toEqual([
{ id: "a1", type: "allergy", description: "oatmeal", severity: "medium" },
]);
expect(persisted.updatedAt).toBeInstanceOf(Date);
});
it("falls back to the weight key when weightKg is absent", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ weight: "9.75" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
expect(updatedValues[0]!.weightKg).toBe("9.75");
});
it("returns 403 when the pet belongs to a different client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID };
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ name: "Hacker" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
expect(updatedValues).toHaveLength(0);
});
it("returns 404 when the pet does not exist", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = null;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ name: "Ghost" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
it("returns 422 for an invalid coatType", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ coatType: "fluffy" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
expect(updatedValues).toHaveLength(0);
});
it("returns 422 for an invalid petSizeCategory", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ petSizeCategory: "gigantic" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
expect(updatedValues).toHaveLength(0);
});
it("returns 401 without an impersonation session header", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(`/portal/pets/${PET_ID}`, { name: "NoAuth" });
expect(res.status).toBe(401);
});
});
+145 -1
View File
@@ -225,9 +225,153 @@ portalRouter.get("/pets", async (c) => {
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 })));
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(),
description: z.string(),
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: z.string().nullable().optional(),
// 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()).nullable().optional(),
medicalAlerts: z.array(portalMedicalAlertSchema).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<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;
if (body.photoUrl !== undefined) updateData.photoKey = body.photoUrl;
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");