Promote dev→uat: GRO-2225 UAT seed route cohort + receptionist credential
CI / Test (pull_request) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Failing after 15s

This commit is contained in:
Flea Flicker
2026-06-08 23:16:51 +00:00
2 changed files with 216 additions and 1 deletions
+6 -1
View File
@@ -365,7 +365,12 @@ This means:
### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1)
A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer).
A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.**
**Pre-condition (GRO-2225 — zero-touch; no manual PATCH/geocoding needed).** A fresh UAT reset+seed now provisions a deterministic route cohort, so §4.16 runs directly against seed data:
- **Groomer:** `uat-groomer@groombook.dev` (staffId `00000000-0000-0000-0000-000000000004`). Resolve its id via `GET /api/staff` or sign in as the groomer and omit `staffId`.
- **Date:** `2026-09-15` (fixed). On this date the groomer has **12** confirmed appointments: **10 pre-geocoded** clients clustered in the Seattle metro (multi-stop route) + **2 intentionally un-geocoded** clients (exercise the skip-and-surface path, TC-API-16.4). Cohort clients are named `Route Demo — …` (emails `route-client-NN@uat.groombook.dev`).
- **Receptionist (TC-API-16.9 403):** sign in as `uat-receptionist@groombook.dev` (password from the `seed-uat-passwords` secret, key `SEED_UAT_RECEPTIONIST_PASSWORD`) — a standing receptionist login; no hand-built session required.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
+210
View File
@@ -456,6 +456,36 @@ async function seedUatStaffAccounts(
}
}
// ── Staff: UAT Receptionist (GRO-2225) ──────────────────────────────────────
// Standing receptionist staff record so the route-optimization 403 path
// (TC-API-16.9: receptionist GET/POST /api/routes → 403) is reproducible
// without a hand-built session. The matching Better-Auth credential is
// provisioned below from SEED_UAT_RECEPTIONIST_PASSWORD. Created here (gated
// on the password env) so the credential loop's staff-link step finds it.
if (process.env.SEED_UAT_RECEPTIONIST_PASSWORD) {
const UAT_RECEPTIONIST_STAFF_ID = "00000000-0000-0000-0000-000000000099";
const [existingReceptionist] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "uat-receptionist@groombook.dev"))
.limit(1);
if (existingReceptionist) {
console.log(`✓ Staff 'UAT Receptionist' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: UAT_RECEPTIONIST_STAFF_ID,
name: "UAT Receptionist",
email: "uat-receptionist@groombook.dev",
oidcSub: "uat-receptionist@groombook.dev",
role: "receptionist",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff 'UAT Receptionist' (uat-receptionist@groombook.dev)`);
}
}
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
@@ -495,6 +525,8 @@ async function seedUatStaffAccounts(
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
// GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9).
{ email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
@@ -798,6 +830,179 @@ async function seedUatGroomerLinkage(
);
}
// ── GRO-2225: deterministic route-optimization cohort ────────────────────────
/**
* GRO-2225: seed a deterministic, pre-geocoded client cohort + a fixed-date set
* of appointments for the UAT groomer so the route-optimization endpoints
* (`GET /api/routes/daily`, `POST /api/routes/optimize`, UAT §4.16
* TC-API-16.1…16.11) are exercisable with ZERO manual PATCHing.
*
* Design (no live geocoder — UAT has no Google Maps key, provider is
* nearest_neighbor; coordinates are hand-picked fixtures clustered in the
* Seattle metro):
* - All appointments are on a FIXED calendar date (ROUTE_DATE) and assigned to
* the UAT groomer (`uat-groomer@groombook.dev`). The optimize endpoint pulls
* non-cancelled appointments in [date 00:00Z, +24h) joined to client coords.
* - 10 clients carry deterministic lat/lng → a multi-stop optimized route.
* - 2 clients are intentionally left UN-geocoded so the "skipped + surfaced"
* path (TC-API-16.5) stays reproducible.
*
* Idempotent: clients/pets are upserted by fixed UUID (they are NOT truncated on
* reset); appointments are upserted by fixed UUID too (they ARE truncated on
* reset, but the upsert keeps re-runs safe in non-truncating dev/test paths).
* Skips cleanly when the UAT groomer staff record is absent (e.g. prod/demo or a
* dev seed without the UAT personas).
*/
async function seedUatRouteCohort(db: ReturnType<typeof drizzle>): Promise<void> {
// Fixed calendar date the UAT playbook hardcodes for §4.16. Times are UTC so
// they fall inside the optimize endpoint's [date 00:00Z, +24h) day window.
const ROUTE_DATE = "2026-09-15";
const [uatGroomer] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
.limit(1);
if (!uatGroomer) {
console.log("✓ GRO-2225: uat-groomer not present — skipping route cohort");
return;
}
// Resolve a service for the appointments: prefer Bath & Brush, else any active.
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn("⚠ GRO-2225: no active services found — skipping route cohort");
return;
}
serviceId = fallback.id;
}
// Hand-picked fixture coordinates clustered in the Seattle metro. `coords:null`
// marks an intentionally un-geocoded client (skip-and-surface path TC-16.5).
const cohort: Array<{
n: number;
name: string;
coords: { lat: number; lng: number } | null;
}> = [
{ n: 1, name: "Route Demo — Ada Lovelace", coords: { lat: 47.6097, lng: -122.3331 } },
{ n: 2, name: "Route Demo — Grace Hopper", coords: { lat: 47.6205, lng: -122.3493 } },
{ n: 3, name: "Route Demo — Alan Turing", coords: { lat: 47.5990, lng: -122.3300 } },
{ n: 4, name: "Route Demo — Katherine Johnson", coords: { lat: 47.6150, lng: -122.3200 } },
{ n: 5, name: "Route Demo — Edsger Dijkstra", coords: { lat: 47.6280, lng: -122.3550 } },
{ n: 6, name: "Route Demo — Barbara Liskov", coords: { lat: 47.5920, lng: -122.3150 } },
{ n: 7, name: "Route Demo — Donald Knuth", coords: { lat: 47.6350, lng: -122.3400 } },
{ n: 8, name: "Route Demo — Margaret Hamilton", coords: { lat: 47.6050, lng: -122.3600 } },
{ n: 9, name: "Route Demo — Ken Thompson", coords: { lat: 47.6420, lng: -122.3250 } },
{ n: 10, name: "Route Demo — Radia Perlman", coords: { lat: 47.5880, lng: -122.3450 } },
// Intentionally un-geocoded — exercises the skip-and-surface path.
{ n: 11, name: "Route Demo — Ungeocoded One", coords: null },
{ n: 12, name: "Route Demo — Ungeocoded Two", coords: null },
];
// Stagger appointments 45 min apart starting 15:00Z on ROUTE_DATE.
const dayStartMs = new Date(`${ROUTE_DATE}T15:00:00.000Z`).getTime();
const SLOT_MS = 45 * 60 * 1000;
let geocodedCount = 0;
let ungeocodedCount = 0;
for (const c of cohort) {
const pad = String(c.n).padStart(2, "0");
const clientId = `d0000000-0000-0000-0000-0000000000${pad}`;
const petId = `d0000000-0000-0000-0000-0000000001${pad}`;
const apptId = `d0000000-0000-0000-0000-0000000002${pad}`;
const geocodedAt = c.coords ? new Date(`${ROUTE_DATE}T00:00:00.000Z`) : null;
await db.insert(schema.clients)
.values({
id: clientId,
name: c.name,
email: `route-client-${pad}@uat.groombook.dev`,
phone: `(206) 555-01${pad}`,
address: `${100 + c.n} Pike Street, Seattle, WA 98101`,
status: "active",
latitude: c.coords?.lat ?? null,
longitude: c.coords?.lng ?? null,
geocodedAt,
})
.onConflictDoUpdate({
target: schema.clients.id,
set: {
name: c.name,
address: `${100 + c.n} Pike Street, Seattle, WA 98101`,
latitude: c.coords?.lat ?? null,
longitude: c.coords?.lng ?? null,
geocodedAt,
},
});
await db.insert(schema.pets)
.values({
id: petId,
clientId,
name: `Route Pup ${c.n}`,
species: "Dog",
breed: "Mixed",
weightKg: "18.00",
})
.onConflictDoUpdate({
target: schema.pets.id,
set: { clientId, name: `Route Pup ${c.n}`, species: "Dog" },
});
const startTime = new Date(dayStartMs + (c.n - 1) * SLOT_MS);
const endTime = new Date(startTime.getTime() + SLOT_MS);
await db.insert(schema.appointments)
.values({
id: apptId,
clientId,
petId,
serviceId,
staffId: uatGroomer.id,
batherStaffId: null,
status: "confirmed",
startTime,
endTime,
notes: "GRO-2225: deterministic route-optimization cohort appointment.",
priceCents: null,
confirmationStatus: "confirmed",
})
.onConflictDoUpdate({
target: schema.appointments.id,
set: {
clientId,
petId,
serviceId,
staffId: uatGroomer.id,
status: "confirmed",
startTime,
endTime,
},
});
if (c.coords) geocodedCount++;
else ungeocodedCount++;
}
console.log(
`✓ GRO-2225: seeded route cohort for ${ROUTE_DATE}${geocodedCount} geocoded + ${ungeocodedCount} un-geocoded appointment(s) for uat-groomer (${uatGroomer.id})`,
);
}
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
/**
@@ -1169,6 +1374,11 @@ async function runSeedBody(
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments
// for the UAT groomer. Must run AFTER services are seeded (it looks up a
// service id for the appointments). Skips cleanly if uat-groomer is absent.
await seedUatRouteCohort(db);
// ── Clients & Pets ──
const now = new Date();
const appointmentsBackDate = new Date(now);