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
+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")
);