Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f3c048cbf |
+6
-1
@@ -363,7 +363,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 |
|
||||
|---|----------|-------|----------|
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user