Promote dev→uat: GRO-2225 UAT seed route cohort + receptionist credential
This commit is contained in:
+6
-1
@@ -365,7 +365,12 @@ This means:
|
|||||||
|
|
||||||
### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1)
|
### 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 |
|
| # | 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) ──
|
// ── 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 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) ?? [];
|
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-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-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" },
|
{ 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) {
|
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) ───────────────────────────────────────
|
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1169,6 +1374,11 @@ async function runSeedBody(
|
|||||||
// the time seedUatStaffAccounts() returns).
|
// the time seedUatStaffAccounts() returns).
|
||||||
await seedUatGroomerLinkage(db, uatCustomerClientId);
|
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 ──
|
// ── Clients & Pets ──
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const appointmentsBackDate = new Date(now);
|
const appointmentsBackDate = new Date(now);
|
||||||
|
|||||||
Reference in New Issue
Block a user