uat→main (PROD): GRO-2157 nav export + GRO-2225/2235 (frozen @4868f18) (#192)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Successful in 28s

feat: nav export + conflict guard + UAT seed (GRO-2157, GRO-2225, GRO-2235)

Squash-merges PR #192: uat→main PROD promotion.
Freezes at validated SHA 4868f18 (UAT regression GRO-2261 11/11 PASS).
Bundles: GRO-2157 (nav export), GRO-2225 (UAT seed), GRO-2235 (conflict guard).
CTO-reviewed and approved (review #4542).

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
This commit was merged in pull request #192.
This commit is contained in:
2026-06-09 01:23:06 +00:00
committed by Scrubs McBarkley
parent bfe1a29c08
commit e9ad92de01
7 changed files with 782 additions and 12 deletions
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect } from "vitest";
import {
buildGoogleMapsUrl,
buildAppleMapsUrl,
buildNavigationUrl,
intermediateWaypointCount,
GOOGLE_MAPS_MAX_WAYPOINTS,
APPLE_MAPS_MAX_WAYPOINTS,
type NavigationStop,
} from "../services/navigationExport.js";
function stops(n: number): NavigationStop[] {
return Array.from({ length: n }, (_, i) => ({
latitude: 47 + i / 100,
longitude: -122 - i / 100,
label: `Stop ${i + 1}`,
}));
}
describe("intermediateWaypointCount", () => {
it("excludes origin and destination", () => {
expect(intermediateWaypointCount(0)).toBe(0);
expect(intermediateWaypointCount(1)).toBe(0);
expect(intermediateWaypointCount(2)).toBe(0);
expect(intermediateWaypointCount(5)).toBe(3);
});
});
describe("buildGoogleMapsUrl", () => {
it("rejects an empty route", () => {
const r = buildGoogleMapsUrl([]);
expect(r).toEqual({ error: "route has no stops to export", status: 400 });
});
it("builds a single-stop link (destination only, no waypoints)", () => {
const r = buildGoogleMapsUrl(stops(1));
if ("error" in r) throw new Error(r.error);
expect(r.platform).toBe("google-maps");
expect(r.stopCount).toBe(1);
expect(r.waypointCount).toBe(0);
expect(r.url).toContain("https://www.google.com/maps/dir/?");
expect(r.url).toContain("api=1");
expect(r.url).toContain("travelmode=driving");
expect(r.url).toContain("origin=47%2C-122");
expect(r.url).toContain("destination=47%2C-122");
expect(r.url).not.toContain("waypoints=");
});
it("builds origin/destination only for two stops", () => {
const r = buildGoogleMapsUrl(stops(2));
if ("error" in r) throw new Error(r.error);
expect(r.waypointCount).toBe(0);
expect(r.url).not.toContain("waypoints=");
expect(r.url).toContain("origin=47%2C-122");
expect(r.url).toContain("destination=47.01%2C-122.01");
});
it("includes intermediate waypoints in order, pipe-separated", () => {
const r = buildGoogleMapsUrl(stops(4));
if ("error" in r) throw new Error(r.error);
expect(r.stopCount).toBe(4);
expect(r.waypointCount).toBe(2);
// waypoints param holds stops[1] and stops[2], pipe-joined (encoded %7C)
const url = new URL(r.url);
expect(url.searchParams.get("origin")).toBe("47,-122");
expect(url.searchParams.get("destination")).toBe("47.03,-122.03");
expect(url.searchParams.get("waypoints")).toBe(
"47.01,-122.01|47.02,-122.02"
);
});
it("accepts a route at exactly the waypoint limit", () => {
const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 2));
if ("error" in r) throw new Error(r.error);
expect(r.waypointCount).toBe(GOOGLE_MAPS_MAX_WAYPOINTS);
});
it("rejects a route over the waypoint limit", () => {
const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 3));
expect("error" in r).toBe(true);
if ("error" in r) {
expect(r.status).toBe(400);
expect(r.error).toContain(`${GOOGLE_MAPS_MAX_WAYPOINTS}`);
}
});
});
describe("buildAppleMapsUrl", () => {
it("rejects an empty route", () => {
const r = buildAppleMapsUrl([]);
expect(r).toEqual({ error: "route has no stops to export", status: 400 });
});
it("builds a destination-only link for one stop", () => {
const r = buildAppleMapsUrl(stops(1));
if ("error" in r) throw new Error(r.error);
expect(r.platform).toBe("apple-maps");
expect(r.url).toBe("maps://?daddr=47,-122&dirflg=d");
expect(r.url).not.toContain("saddr=");
});
it("chains destinations with +to: for multiple stops", () => {
const r = buildAppleMapsUrl(stops(3));
if ("error" in r) throw new Error(r.error);
expect(r.stopCount).toBe(3);
expect(r.waypointCount).toBe(1);
expect(r.url).toBe(
"maps://?saddr=47,-122&daddr=47.01,-122.01+to:47.02,-122.02&dirflg=d"
);
});
it("accepts a route at exactly the waypoint limit", () => {
const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 2));
if ("error" in r) throw new Error(r.error);
expect(r.waypointCount).toBe(APPLE_MAPS_MAX_WAYPOINTS);
});
it("rejects a route over the waypoint limit", () => {
const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 3));
expect("error" in r).toBe(true);
if ("error" in r) {
expect(r.status).toBe(400);
expect(r.error).toContain(`${APPLE_MAPS_MAX_WAYPOINTS}`);
}
});
});
describe("buildNavigationUrl", () => {
it("dispatches to the google-maps builder", () => {
const r = buildNavigationUrl("google-maps", stops(2));
if ("error" in r) throw new Error(r.error);
expect(r.platform).toBe("google-maps");
});
it("dispatches to the apple-maps builder", () => {
const r = buildNavigationUrl("apple-maps", stops(2));
if ("error" in r) throw new Error(r.error);
expect(r.platform).toBe("apple-maps");
});
});
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// GRO-2235: a duplicate active waitlist entry violates the partial unique index
// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the
// handler must return a friendly 409, not a generic 500. The first insert still
// returns 201, and unrelated errors still surface as 500.
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
reason: "manual",
startedAt: new Date(),
expiresAt: futureDate(),
createdAt: new Date(),
};
// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws
// a postgres-js-shaped unique-violation, "other" throws an unrelated error.
let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok";
function resetMock() {
waitlistInsertMode = "ok";
}
function tableProxy(name: string) {
return new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
}
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;
}
const impersonationSessions = tableProxy("impersonationSessions");
const waitlistEntries = tableProxy("waitlistEntries");
const impersonationAuditLogs = tableProxy("impersonationAuditLogs");
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable([ACTIVE_SESSION]);
}
return makeChainable([]);
},
}),
insert: (table: { _name: string }) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
if (table._name === "waitlistEntries") {
if (waitlistInsertMode === "duplicate") {
throw Object.assign(new Error("duplicate key value"), { code: "23505" });
}
if (waitlistInsertMode === "other") {
throw Object.assign(new Error("not null violation"), { code: "23502" });
}
return [{ id: "entry-1", ...vals }];
}
// impersonationAuditLogs and anything else: succeed silently.
return [{ id: "audit-1", ...vals }];
},
}),
}),
update: () => ({
set: () => ({ where: () => Promise.resolve() }),
}),
}),
impersonationSessions,
waitlistEntries,
impersonationAuditLogs,
appointments: tableProxy("appointments"),
clients: tableProxy("clients"),
pets: tableProxy("pets"),
services: tableProxy("services"),
staff: tableProxy("staff"),
invoices: tableProxy("invoices"),
invoiceLineItems: tableProxy("invoiceLineItems"),
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 postWaitlist(body: unknown) {
return app.request("/portal/waitlist", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Impersonation-Session-Id": SESSION_ID,
},
body: JSON.stringify(body),
});
}
const VALID_BODY = {
petId: PET_ID,
serviceId: SERVICE_ID,
preferredDate: "2026-07-01",
preferredTime: "09:00",
};
beforeEach(() => resetMock());
describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => {
it("returns 201 for the first insert", async () => {
waitlistInsertMode = "ok";
const res = await postWaitlist(VALID_BODY);
expect(res.status).toBe(201);
});
it("returns 409 with a friendly message for a duplicate (23505)", async () => {
waitlistInsertMode = "duplicate";
const res = await postWaitlist(VALID_BODY);
expect(res.status).toBe(409);
const json = (await res.json()) as { error: string };
expect(json.error).toBe(
"You already have a booking for this pet at that date and time."
);
});
it("still surfaces unrelated DB errors as 500", async () => {
waitlistInsertMode = "other";
const res = await postWaitlist(VALID_BODY);
expect(res.status).toBe(500);
});
});
+26 -10
View File
@@ -596,16 +596,32 @@ portalRouter.post(
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: normalizeTime(body.preferredTime),
})
.returning();
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);
}
+68 -1
View File
@@ -1,4 +1,4 @@
import { Hono } from "hono";
import { Hono, type Context } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
@@ -24,6 +24,11 @@ import {
type RouteStopInput,
type StopConflictFlags,
} from "../services/routeOptimization.js";
import {
buildNavigationUrl,
type NavigationPlatform,
type NavigationStop,
} from "../services/navigationExport.js";
export const routesRouter = new Hono<AppEnv>();
@@ -460,3 +465,65 @@ routesRouter.patch(
});
}
);
/**
* GET /:routeId/export/:platform — build a native-navigation deep-link URL for an
* optimized route. Origin = first stop, destination = last stop, the rest carried
* as ordered intermediate waypoints. Waypoint count is validated against the
* platform's limit. Auth: manager (any route) or groomer (own route only).
*/
async function handleNavigationExport(
c: Context<AppEnv>,
platform: NavigationPlatform
) {
const db = getDb();
const routeId = c.req.param("routeId");
if (!routeId || !z.string().uuid().safeParse(routeId).success) {
return c.json({ error: "routeId must be a UUID" }, 400);
}
const [route] = await db
.select()
.from(groomerRoutes)
.where(eq(groomerRoutes.id, routeId));
if (!route) {
return c.json({ error: "Route not found" }, 404);
}
// Reuse the groomer-own / manager authorization rule against the route owner.
const resolved = resolveTargetStaffId(c.get("staff"), route.staffId);
if ("error" in resolved) {
return c.json({ error: resolved.error }, resolved.status);
}
const stops = await loadRouteStops(db, routeId);
if (stops.length === 0) {
return c.json({ error: "route has no stops to export" }, 400);
}
const navStops: NavigationStop[] = stops.map((s) => ({
latitude: s.latitude,
longitude: s.longitude,
label: s.clientName,
}));
const result = buildNavigationUrl(platform, navStops);
if ("error" in result) {
return c.json({ error: result.error }, result.status);
}
return c.json({
platform: result.platform,
url: result.url,
stopCount: result.stopCount,
waypointCount: result.waypointCount,
});
}
routesRouter.get("/:routeId/export/google-maps", (c) =>
handleNavigationExport(c, "google-maps")
);
routesRouter.get("/:routeId/export/apple-maps", (c) =>
handleNavigationExport(c, "apple-maps")
);
+155
View File
@@ -0,0 +1,155 @@
// Navigation export — turn an optimized groomer route into a deep-link URL that
// opens the device's native navigation app (Google Maps / Apple Maps).
//
// A route is exported as: origin = first stop, destination = last stop, with the
// in-between stops carried as ordered intermediate waypoints. Each platform caps
// how many intermediate waypoints a deep link may carry, so callers must validate
// the route length before handing the URL to the client.
/**
* Max intermediate waypoints a Google Maps URLs API deep link supports
* (`https://www.google.com/maps/dir/?api=1&...&waypoints=...`). Google documents
* a ceiling of 9 waypoints between origin and destination.
*/
export const GOOGLE_MAPS_MAX_WAYPOINTS = 9;
/**
* Max intermediate waypoints we allow in an Apple Maps `maps://` deep link. Apple's
* URL scheme chains destinations with `+to:` but does not publish a hard cap; 15 is
* a conservative practical limit that keeps the URL well under length limits.
*/
export const APPLE_MAPS_MAX_WAYPOINTS = 15;
export type NavigationPlatform = "google-maps" | "apple-maps";
/** A single ordered point on the route. `label` is optional, for display only. */
export interface NavigationStop {
latitude: number;
longitude: number;
label?: string | null;
}
export interface NavigationExportSuccess {
platform: NavigationPlatform;
url: string;
/** Total stops included (origin + waypoints + destination). */
stopCount: number;
/** Intermediate waypoints only (excludes origin and destination). */
waypointCount: number;
}
export interface NavigationExportError {
error: string;
status: 400;
}
export type NavigationExportResult =
| NavigationExportSuccess
| NavigationExportError;
function isError(r: NavigationExportResult): r is NavigationExportError {
return "error" in r;
}
/** Intermediate waypoints = every stop that is neither origin nor destination. */
export function intermediateWaypointCount(stopCount: number): number {
return Math.max(0, stopCount - 2);
}
function coord(stop: NavigationStop): string {
return `${stop.latitude},${stop.longitude}`;
}
/**
* Builds a Google Maps URLs API driving deep link. On mobile this opens the
* native Google Maps app; on desktop it opens maps.google.com.
*/
export function buildGoogleMapsUrl(
stops: NavigationStop[]
): NavigationExportResult {
if (stops.length === 0) {
return { error: "route has no stops to export", status: 400 };
}
const waypointCount = intermediateWaypointCount(stops.length);
if (waypointCount > GOOGLE_MAPS_MAX_WAYPOINTS) {
return {
error: `route has ${waypointCount} intermediate waypoints, exceeding Google Maps' limit of ${GOOGLE_MAPS_MAX_WAYPOINTS}`,
status: 400,
};
}
const origin = stops[0]!;
const destination = stops[stops.length - 1]!;
const params = new URLSearchParams();
params.set("api", "1");
params.set("travelmode", "driving");
params.set("origin", coord(origin));
params.set("destination", coord(destination));
if (stops.length > 2) {
const mids = stops
.slice(1, -1)
.map(coord)
.join("|");
params.set("waypoints", mids);
}
return {
platform: "google-maps",
url: `https://www.google.com/maps/dir/?${params.toString()}`,
stopCount: stops.length,
waypointCount,
};
}
/**
* Builds an Apple Maps `maps://` driving deep link. The first stop is the source
* (`saddr`); the remaining stops are chained as destinations with `+to:` (`daddr`).
* Built by hand because the `+to:` separators are part of Apple's scheme and must
* not be percent-encoded.
*/
export function buildAppleMapsUrl(
stops: NavigationStop[]
): NavigationExportResult {
if (stops.length === 0) {
return { error: "route has no stops to export", status: 400 };
}
const waypointCount = intermediateWaypointCount(stops.length);
if (waypointCount > APPLE_MAPS_MAX_WAYPOINTS) {
return {
error: `route has ${waypointCount} intermediate waypoints, exceeding Apple Maps' limit of ${APPLE_MAPS_MAX_WAYPOINTS}`,
status: 400,
};
}
const params: string[] = ["dirflg=d"];
if (stops.length === 1) {
// Single stop: destination only, no source.
params.unshift(`daddr=${coord(stops[0]!)}`);
} else {
const daddr = stops
.slice(1)
.map(coord)
.join("+to:");
params.unshift(`daddr=${daddr}`);
params.unshift(`saddr=${coord(stops[0]!)}`);
}
return {
platform: "apple-maps",
url: `maps://?${params.join("&")}`,
stopCount: stops.length,
waypointCount,
};
}
/** Dispatches to the correct builder for the requested platform. */
export function buildNavigationUrl(
platform: NavigationPlatform,
stops: NavigationStop[]
): NavigationExportResult {
return platform === "google-maps"
? buildGoogleMapsUrl(stops)
: buildAppleMapsUrl(stops);
}
export { isError as isNavigationExportError };