diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index b8949de..0f95961 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -147,6 +147,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) | | TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) | | TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) | +| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) | +| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note | | TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) | | TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) | | TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` | @@ -166,6 +168,7 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) | | TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` | | TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) | +| TC-API-3.29 | Verify `reset-demo-data` CronJob does not fail with FK 23503 on `invoice_tip_splits` (GRO-2123) | Trigger the CronJob manually: `kubectl create job --from=cronjob/reset-demo-data verify-gro2123 -n groombook-uat`. Wait for pod to terminate. Inspect logs: `kubectl logs -n groombook-uat -l job-name=verify-gro2123` | Pod reaches `Completed` state; logs show `✓ Acquired seed advisory lock` and `✓ Released seed advisory lock` from `seed.ts`; no `PostgresError: … violates foreign key constraint "invoice_tip_splits_invoice_id_invoices_id_fk"` (code 23503); final counts unchanged (500 clients, ~4000 invoices) | ### 4.4 Appointment Scheduling diff --git a/apps/api/package.json b/apps/api/package.json index cb340f4..af29e16 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,8 +12,8 @@ "test": "vitest run", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:seed": "tsx src/db/seed.ts", - "db:reset": "tsx src/db/reset.ts && drizzle-kit migrate && tsx src/db/seed.ts", + "db:seed": "pnpm --filter @groombook/db seed", + "db:reset": "pnpm --filter @groombook/db reset", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts deleted file mode 100644 index bf57144..0000000 --- a/apps/api/src/db/seed.ts +++ /dev/null @@ -1,1349 +0,0 @@ -/** - * Seed script — generates deterministic, PII-free test data for Groom Book. - * - * Creates: - * - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total) - * - 10 services - * - 500 clients, each with 1-3 dogs - * - ~2 500 appointments spread across the past 12 months - * - Invoices for completed appointments with line items and tip splits - * - Grooming visit logs for completed appointments - * - * Output is fully deterministic: the same seed value always produces the - * same rows with the same IDs. - * - * Usage: - * DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts - */ - -import postgres from "postgres"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { eq, and, sql } from "drizzle-orm"; -import * as schema from "./schema.js"; -import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js"; - -// ── Seed profile configuration ───────────────────────────────────────────── - -type SeedProfile = "dev" | "uat" | "demo"; - -interface ProfileConfig { - staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; - clientCount: number; - appointmentsBackDays: number; - appointmentsForwardDays: number; - invoiceCount: number; - includeUatClients: boolean; -} - -const profiles: Record = { - dev: { - staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, - clientCount: 100, - appointmentsBackDays: 7, - appointmentsForwardDays: 30, - invoiceCount: 1000, - includeUatClients: false, - }, - uat: { - staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, - clientCount: 500, - appointmentsBackDays: 30, - appointmentsForwardDays: 90, - invoiceCount: 4000, - includeUatClients: true, - }, - demo: { - staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, - clientCount: 500, - appointmentsBackDays: 30, - appointmentsForwardDays: 90, - invoiceCount: 4000, - includeUatClients: true, - }, -}; - -function getProfile(): SeedProfile { - const raw = process.env.SEED_PROFILE?.toLowerCase(); - if (raw === "dev" || raw === "uat" || raw === "demo") { - return raw; - } - return "uat"; -} - -// ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── - -/** - * Returns a seeded pseudo-random number generator. - * Same seed → identical sequence of numbers every run. - */ -function createPrng(seed: number): () => number { - let s = seed | 0; - return function (): number { - s = (s + 0x6d2b79f5) | 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -const rand = createPrng(42); - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** Return a random element from an array using the seeded PRNG. */ -function pick(arr: T[]): T { - return arr[Math.floor(rand() * arr.length)]!; -} - - -function randInt(min: number, max: number): number { - return Math.floor(rand() * (max - min + 1)) + min; -} - -function randDate(start: Date, end: Date): Date { - return new Date(start.getTime() + rand() * (end.getTime() - start.getTime())); -} - -/** - * Generate a deterministic UUID v4 from the seeded PRNG. - * Conforms to RFC 4122 §4.4 (variant bits set correctly). - */ -function uuid(): string { - const hex = (n: number) => n.toString(16).padStart(2, "0"); - const bytes = Array.from({ length: 16 }, () => Math.floor(rand() * 256)); - bytes[6] = ((bytes[6]! & 0x0f) | 0x40); // version 4 - bytes[8] = ((bytes[8]! & 0x3f) | 0x80); // variant bits - return [ - bytes.slice(0, 4).map(hex).join(""), - bytes.slice(4, 6).map(hex).join(""), - bytes.slice(6, 8).map(hex).join(""), - bytes.slice(8, 10).map(hex).join(""), - bytes.slice(10, 16).map(hex).join(""), - ].join("-"); -} - -// ── Data pools ─────────────────────────────────────────────────────────────── - -const firstNames = [ - "Emma", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Sophia", "Mason", - "Isabella", "Lucas", "Mia", "Logan", "Charlotte", "Aiden", "Amelia", - "James", "Harper", "Benjamin", "Evelyn", "Elijah", "Abigail", "William", - "Emily", "Sebastian", "Elizabeth", "Henry", "Sofia", "Alexander", "Avery", - "Daniel", "Scarlett", "Michael", "Grace", "Jackson", "Chloe", "Owen", - "Victoria", "Jack", "Riley", "Caleb", "Aria", "Luke", "Luna", "Ryan", - "Zoey", "Nathan", "Penelope", "Carter", "Layla", "Dylan", "Nora", - "Andrew", "Lily", "Gabriel", "Eleanor", "Samuel", "Hannah", "David", - "Lillian", "Matthew", "Addison", "Joseph", "Aubrey", "Isaac", "Stella", - "Joshua", "Natalie", "Wyatt", "Zoe", "John", "Leah", "Leo", "Hazel", - "Julian", "Violet", "Christopher", "Aurora", "Jonathan", "Savannah", - "Lincoln", "Audrey", "Thomas", "Brooklyn", "Asher", "Bella", "Theodore", - "Claire", "Jaxon", "Skylar", "Robert", "Lucy", "Charles", "Paisley", - "Adrian", "Anna", "Miles", "Caroline", "Dominic", "Genesis", "Connor", -]; - -const lastNames = [ - "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", - "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", - "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", - "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", - "Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", - "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", "Green", - "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", - "Carter", "Roberts", "Gomez", "Phillips", "Evans", "Turner", "Diaz", - "Parker", "Cruz", "Edwards", "Collins", "Reyes", "Stewart", "Morris", - "Morales", "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", - "Cooper", "Peterson", "Bailey", "Reed", "Kelly", "Howard", "Ramos", - "Kim", "Cox", "Ward", "Richardson", "Watson", "Brooks", "Chavez", - "Wood", "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes", - "Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", "Long", - "Ross", "Foster", "Jimenez", -]; - -const dogNames = [ - "Buddy", "Max", "Charlie", "Cooper", "Rocky", "Bear", "Duke", "Tucker", - "Jack", "Oliver", "Milo", "Bentley", "Zeus", "Winston", "Beau", "Finn", - "Leo", "Teddy", "Louie", "Toby", "Harley", "Bailey", "Murphy", "Rex", - "Bruno", "Gus", "Diesel", "Moose", "Henry", "Archie", "Luna", "Bella", - "Daisy", "Lucy", "Sadie", "Molly", "Maggie", "Chloe", "Sophie", "Stella", - "Penny", "Zoey", "Ruby", "Rosie", "Lola", "Willow", "Nala", "Ginger", - "Coco", "Roxy", "Ellie", "Piper", "Gracie", "Millie", "Lady", "Pepper", - "Hazel", "Dixie", "Winnie", "Bonnie", "Maple", "Ivy", "Pearl", "Olive", -]; - -const dogBreeds = [ - "Golden Retriever", "Labrador Retriever", "Poodle", "German Shepherd", - "Bulldog", "Beagle", "Rottweiler", "Dachshund", "Yorkshire Terrier", - "Boxer", "Siberian Husky", "Cavalier King Charles Spaniel", - "Doberman Pinscher", "Great Dane", "Miniature Schnauzer", - "Shih Tzu", "Boston Terrier", "Bernese Mountain Dog", "Pomeranian", - "Havanese", "Cocker Spaniel", "Border Collie", "Shetland Sheepdog", - "Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise", - "West Highland White Terrier", "Vizsla", "Chihuahua", "Collie", - "Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd", - "Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle", - "Mixed Breed", "Mixed Breed", "Mixed Breed", -]; - -const cutStyles = [ - "Puppy Cut", "Teddy Bear Cut", "Lion Cut", "Breed Standard", - "Summer Shave", "Kennel Cut", "Lamb Cut", "Continental Clip", - "Sporting Clip", "Sanitary Trim", "Face & Feet Trim", "Full Groom", - null, -]; - -const shampoos = [ - "Oatmeal Sensitive", "Whitening Formula", "Flea & Tick", "Hypoallergenic", - "De-shedding", "Puppy Gentle", "Medicated", "Coconut Oil", - "Lavender Calm", null, -]; - -const healthAlerts = [ - null, null, null, null, null, // Most pets have none - "Sensitive skin — avoid harsh shampoos", - "Ear infection prone — dry ears thoroughly", - "Hip dysplasia — handle with care", - "Anxious — needs slow approach", - "Seizure history — avoid stress triggers", - "Skin allergies — use hypoallergenic products only", - "Aggressive when nails trimmed — muzzle required", - "Heart murmur — monitor during grooming", - "Diabetic — owner brings treats", -]; - -const streetNames = [ - "Main St", "Oak Ave", "Maple Dr", "Cedar Ln", "Elm St", "Pine Rd", - "Birch Way", "Walnut Ct", "Cherry Blvd", "Willow Pl", "Spruce Ter", - "Chestnut Cir", "Hickory Ln", "Magnolia Ave", "Sycamore Dr", - "Dogwood Rd", "Aspen Way", "Redwood Ct", "Juniper Blvd", "Poplar St", -]; - -const cities = [ - "Springfield", "Riverside", "Fairview", "Madison", "Georgetown", - "Clinton", "Salem", "Greenville", "Franklin", "Bristol", - "Manchester", "Oakland", "Burlington", "Arlington", "Ashland", -]; - -const states = ["CA", "TX", "NY", "FL", "IL", "PA", "OH", "GA", "NC", "MI"]; - -const groomingNotes = [ - null, null, null, - "Matting prone — brush out before bath", - "Loves the dryer", - "Nippy around paws", - "Very calm, easy to handle", - "Needs extra time for drying (thick coat)", - "Sensitive around face — use caution", - "Doesn't like water, use minimal bath time", - "Loves belly rubs — great way to calm down", - "Double coat — needs thorough de-shedding", - "Previous clipper burn — be gentle on belly", -]; - -const appointmentNotes = [ - null, null, null, null, - "Client requested extra brushing", - "Nail trim only — no bath", - "Teeth brushing added", - "Ear cleaning requested", - "New puppy — first groom, be gentle", - "Matted — may need extra time", - "Owner wants shorter cut than usual", - "Anal glands need expressing", - "Use gentle shampoo per vet recommendation", - "Client running late, pushed start by 15min", -]; - -const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9]; - -const temperamentFlags = [ - [], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"], - ["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"], - ["friendly", "nippy"], ["anxious", "territorial"], -]; - -const medicalAlertsList = [ - [] as MedicalAlert[], - [] as MedicalAlert[], - [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], - [{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }], - [{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }], - [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], - [{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }], - [{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }], - [{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }], - [{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }], - [{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }], -]; - -const preferredCutsList = [ - [], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"], - ["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"], - ["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"], - ["Breed Standard", "Sanitary Trim"], ["Summer Shave"], - ["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"], -]; - -const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"]; - -const visitLogNotes = [ - null, null, - "Coat in great condition", - "Found a small mat behind left ear, brushed out", - "Nails were very long, trimmed carefully", - "Light shedding, used de-shedding tool", - "Slight skin irritation noticed on belly — flagged to owner", - "Pet was very well-behaved today", - "Required two rinse cycles — very dirty", - "Applied conditioning treatment for dry coat", -]; - -const productsUsed = [ - null, - "Oatmeal shampoo, conditioner", - "Whitening shampoo, detangler", - "De-shedding shampoo, FURminator", - "Hypoallergenic shampoo, ear cleaner", - "Flea & tick shampoo, nail grinder", - "Puppy shampoo, gentle conditioner", - "Medicated shampoo (vet prescribed), moisturizer", - "Coconut oil shampoo, leave-in conditioner, cologne", -]; - -const demoPetImages = [ - "/demo-pets/dog-golden-after.png", - "/demo-pets/dog-poodle-groomed.png", - "/demo-pets/dog-black-lab.png", - "/demo-pets/dog-shih-tzu.png", - "/demo-pets/dog-cocker-spaniel.png", - "/demo-pets/dog-schnauzer.png", - "/demo-pets/dog-maltese.png", - "/demo-pets/dog-dachshund.png", - "/demo-pets/dog-pomeranian.png", - "/demo-pets/dog-bichon-frise.png", - "/demo-pets/dog-golden-retriever.png", - "/demo-pets/dog-labrador.png", - "/demo-pets/dog-mixed-breed.png", - "/demo-pets/dog-poodle.png", - "/demo-pets/dog-terrier.png", - "/demo-pets/dog-afghan-hound.png", - "/demo-pets/dog-basset-brown-white.png", - "/demo-pets/dog-bichon-white-groomed.png", - "/demo-pets/dog-boxer-fawn-athletic.png", - "/demo-pets/dog-cavalier-cream-gentle.png", - "/demo-pets/dog-cocker-buff-friendly.png", - "/demo-pets/dog-corgi.png", - "/demo-pets/dog-dachshund-black-tan.png", - "/demo-pets/dog-golden-before.png", - "/demo-pets/dog-pomeranian-white-studio.png", - "/demo-pets/dog-schnauzer-black-groomed.png", - "/demo-pets/dog-setter-red-sunlit.png", - "/demo-pets/dog-sheepdog-merle-running.png", -]; - -const puggleImages = [ - "/demo-pets/dog-puggle-fawn-playful.png", - "/demo-pets/dog-puggle-black-sitting.png", - "/demo-pets/dog-puggle-cream-groomed.png", - "/demo-pets/dog-puggle-fawn-grooming.png", -]; - -// ── Service definitions ────────────────────────────────────────────────────── -// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent: -// first run inserts, subsequent runs update existing rows via ON CONFLICT (name). -const servicesDef = [ - { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 }, - { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 }, - { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 }, - { id: "b0000001-0000-0000-0000-000000000004", name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 }, - { id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 }, - { id: "b0000001-0000-0000-0000-000000000006", name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 }, - { id: "b0000001-0000-0000-0000-000000000007", name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 }, - { id: "b0000001-0000-0000-0000-000000000008", name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 }, - { id: "b0000001-0000-0000-0000-000000000009", name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 }, - { id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, -]; - -// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── - -/** - * Seeds only the minimal known users for prod/demo environments. - * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. - * Idempotent: skips creation if records already exist. - */ -async function seedKnownUsers() { - const url = process.env.DATABASE_URL; - if (!url) { - console.error("DATABASE_URL is not set"); - process.exit(1); - } - - const client = postgres(url, { max: 5 }); - const db = drizzle(client, { schema }); - - console.log("Seeding known users (prod/demo mode)...\n"); - - const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; - const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; - const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; - - // ── Staff: Demo Manager ── - const [existingStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "demo-manager@groombook.dev")) - .limit(1); - - if (existingStaff) { - console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: KNOWN_STAFF_ID, - name: "Demo Manager", - email: "demo-manager@groombook.dev", - oidcSub: "demo-manager-001", - role: "manager", - isSuperUser: true, - active: true, - }); - console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); - } - - // ── Staff: SEED_ADMIN_EMAIL admin ── - const adminEmail = process.env.SEED_ADMIN_EMAIL; - if (adminEmail) { - const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; - const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; - const [existingAdmin] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, adminEmail)) - .limit(1); - - if (existingAdmin) { - console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: ADMIN_STAFF_ID, - name: adminName, - email: adminEmail, - oidcSub: adminEmail, - role: "manager", - isSuperUser: true, - active: true, - }); - console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`); - } - } - - // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── - const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; - if (uatSuperOidcSub) { - const UAT_SUPER_STAFF_ID = "00000000-0000-0000-0000-000000000003"; - const [existingUatSuper] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "uat-super@groombook.dev")) - .limit(1); - - if (existingUatSuper) { - console.log(`✓ Staff 'UAT Super User' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: UAT_SUPER_STAFF_ID, - name: "UAT Super User", - email: "uat-super@groombook.dev", - oidcSub: uatSuperOidcSub, - role: "manager", - isSuperUser: true, - active: true, - }); - console.log(`✓ Created staff 'UAT Super User' (oidcSub: ${uatSuperOidcSub})`); - } - } - - // ── Staff: UAT Staff Groomer (oidcSub from SEED_UAT_STAFF_OIDC_SUB env var) ── - const uatStaffOidcSub = process.env.SEED_UAT_STAFF_OIDC_SUB; - if (uatStaffOidcSub) { - const UAT_STAFF_STAFF_ID = "00000000-0000-0000-0000-000000000004"; - const [existingUatStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) - .limit(1); - - if (existingUatStaff) { - console.log(`✓ Staff 'UAT Staff Groomer' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: UAT_STAFF_STAFF_ID, - name: "UAT Staff Groomer", - email: "uat-groomer@groombook.dev", - oidcSub: uatStaffOidcSub, - role: "groomer", - isSuperUser: false, - active: true, - }); - console.log(`✓ Created staff 'UAT Staff Groomer' (oidcSub: ${uatStaffOidcSub})`); - } - } - - // ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ── - const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB; - if (uatTesterOidcSub) { - const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007"; - const [existingUatTester] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "uat-tester@groombook.dev")) - .limit(1); - - if (existingUatTester) { - console.log(`✓ Staff 'UAT Tester' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: UAT_TESTER_STAFF_ID, - name: "UAT Tester", - email: "uat-tester@groombook.dev", - oidcSub: uatTesterOidcSub, - role: "groomer", - isSuperUser: false, - active: true, - }); - console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`); - } - } - - // ── 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) ?? []; - const groomerCount = Math.min(groomerEmails.length, groomerNames.length); - for (let i = 0; i < groomerCount; i++) { - const email = groomerEmails[i]!; - const name = groomerNames[i]!; - // Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range - const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; - const [existingGroomer] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, email)) - .limit(1); - - if (existingGroomer) { - console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: staffId, - name, - email, - oidcSub: email, - role: "groomer", - isSuperUser: false, - active: true, - }); - console.log(`✓ Created staff groomer '${name}' (${email})`); - } - } - - // ── Better-Auth email+password credentials for UAT accounts ────────────────── - // Provisions Better-Auth user + account records so UAT testers can log in - // via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO. - const uatPasswordAccounts = [ - { email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@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-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" }, - ]; - - for (const acct of uatPasswordAccounts) { - const password = process.env[acct.passwordEnv]; - if (!password) { - console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`); - continue; - } - - // 1. Find or create the Better-Auth user - const [existingUser] = await db - .select() - .from(schema.user) - .where(eq(schema.user.email, acct.email)) - .limit(1); - - let userId: string; - if (existingUser) { - userId = existingUser.id; - console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`); - } else { - userId = uuid(); - await db.insert(schema.user).values({ - id: userId, - name: acct.name, - email: acct.email, - emailVerified: true, - }); - console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`); - } - - // 2. Check if credential account already exists - const [existingAccount] = await db - .select() - .from(schema.account) - .where(and( - eq(schema.account.userId, userId), - eq(schema.account.providerId, "credential") - )) - .limit(1); - - if (existingAccount) { - // Re-hash and update the password so that re-seeding rotates credentials - // when the env var changes (e.g. after a password rotation). Previously - // this branch skipped entirely, freezing the hash at first-seed. - const { hashPassword } = await import("better-auth/crypto"); - const passwordHash = await hashPassword(password); - await db.update(schema.account) - .set({ password: passwordHash }) - .where(eq(schema.account.id, existingAccount.id)); - console.log(`✓ Updated credential account password for '${acct.email}'`); - } else { - // Use Better-Auth's own hashPassword to guarantee parameter/encoding match. - // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random - // hex string, key hex-encoded, format saltHex:keyHex. - const { hashPassword } = await import("better-auth/crypto"); - const passwordHash = await hashPassword(password); - - await db.insert(schema.account).values({ - id: uuid(), - accountId: userId, - providerId: "credential", - userId, - password: passwordHash, - }); - console.log(`✓ Created credential account for '${acct.email}'`); - } - - // 3. Link staff record to Better-Auth user (for accounts that have staff records) - if (acct.staffEmail) { - const [existingStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, acct.staffEmail)) - .limit(1); - if (existingStaff && !existingStaff.userId) { - await db.update(schema.staff) - .set({ userId }) - .where(eq(schema.staff.id, existingStaff.id)); - console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`); - } - } - } - - // ── Services: idempotent upsert keyed on `id` ───────────────────────────── - // GRO-2064: previously keyed on `services.name` while writing a - // deterministic `id`. If a stale row existed with the same `id` but a - // different `name`, PostgreSQL raised `services_pkey` (id collision) - // before the name-targeted ON CONFLICT could fire. Switch the conflict - // target to `services.id` so deterministic ids always win; pair with - // `TRUNCATE services … CASCADE` above so each reset rebuilds the - // catalogue from `servicesDef` cleanly. GRO-2033 close-out. - // Id↔name map MUST stay in sync with `servicesDef` (the canonical source - // of truth in the main `seed()` function). - const demoSvcs = [ - { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, - { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, - { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, - { id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, - ]; - for (const svc of demoSvcs) { - await db.insert(schema.services) - .values({ ...svc, active: true }) - .onConflictDoUpdate({ - target: schema.services.id, - set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, - }); - } - console.log(`✓ Seeded ${demoSvcs.length} services`); - - // ── Client: Demo Client ── - const [existingClient] = await db - .select() - .from(schema.clients) - .where(eq(schema.clients.email, "demo-client@example.com")) - .limit(1); - - let clientId: string; - if (existingClient) { - clientId = existingClient.id; - console.log(`✓ Client '${existingClient.name}' already exists — skipping`); - } else { - const [created] = await db - .insert(schema.clients) - .values({ - id: DEMO_CLIENT_ID, - name: "Demo Client", - email: "demo-client@example.com", - phone: "555-0001", - address: "1 Demo Street, Demo City, CA 90210", - }) - .returning(); - clientId = created!.id; - console.log("✓ Created client 'Demo Client'"); - } - - // ── Pets: Demo Dogs & Cats ── - const demoPets = [ - { id: DEMO_PET_ID, name: "Demo Dog", species: "Dog", breed: "Golden Retriever", weight: "30.00", dob: "2020-06-15", image: "/demo-pets/dog-golden-after.png" }, - { id: uuid(), name: "Fluffy", species: "Dog", breed: "Poodle", weight: "8.50", dob: "2019-03-22", image: "/demo-pets/dog-poodle-groomed.png" }, - { id: uuid(), name: "Shadow", species: "Dog", breed: "Black Labrador", weight: "35.00", dob: "2018-11-10", image: "/demo-pets/dog-black-lab.png" }, - { id: uuid(), name: "Bella", species: "Dog", breed: "Shih Tzu", weight: "4.50", dob: "2021-02-14", image: "/demo-pets/dog-shih-tzu.png" }, - { id: uuid(), name: "Max", species: "Dog", breed: "Cocker Spaniel", weight: "15.00", dob: "2019-07-08", image: "/demo-pets/dog-cocker-spaniel.png" }, - { id: uuid(), name: "Buddy", species: "Dog", breed: "Schnauzer", weight: "12.00", dob: "2020-05-20", image: "/demo-pets/dog-schnauzer.png" }, - { id: uuid(), name: "Daisy", species: "Dog", breed: "Maltese", weight: "3.50", dob: "2021-09-03", image: "/demo-pets/dog-maltese.png" }, - { id: uuid(), name: "Charlie", species: "Dog", breed: "Dachshund", weight: "6.00", dob: "2020-01-15", image: "/demo-pets/dog-dachshund.png" }, - { id: uuid(), name: "Lucy", species: "Dog", breed: "Pomeranian", weight: "2.50", dob: "2022-04-10", image: "/demo-pets/dog-pomeranian.png" }, - ]; - - for (const pet of demoPets) { - const [existing] = await db - .select() - .from(schema.pets) - .where(eq(schema.pets.id, pet.id)) - .limit(1); - - if (existing) { - console.log(`✓ Pet '${existing.name}' already exists — skipping`); - } else { - await db.insert(schema.pets).values({ - id: pet.id, - clientId, - name: pet.name, - species: pet.species, - breed: pet.breed, - weightKg: pet.weight, - dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), - image: pet.image, - }); - console.log(`✓ Created pet '${pet.name}'`); - } - } - - console.log("\nKnown-users seed complete!"); - await client.end(); -} - -// ── Main seed ──────────────────────────────────────────────────────────────── - -async function seed() { - const url = process.env.DATABASE_URL; - if (!url) { - console.error("DATABASE_URL is not set"); - process.exit(1); - } - - if (process.env.SEED_KNOWN_USERS_ONLY === "true") { - await seedKnownUsers(); - return; - } - - const profile = getProfile(); - const cfg = profiles[profile]; - const client = postgres(url, { max: 5 }); - const db = drizzle(client, { schema }); - - console.log(`Seeding Groom Book database (profile: ${profile})...\n`); - - // ── Staff ── - const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => - ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) - ); - const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => - ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) - ); - const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) => - ({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) - ); - const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) => - ({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) - ); - - // GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue - // from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows - // (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the - // same `id`) would otherwise cause the deterministic upsert to PK-collide - // on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE - // CASCADE handles appointments/invoices FKs to services.id. - await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`); - - const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers]; - for (const s of allStaff) { - await db.insert(schema.staff) - .values({ - id: s.id, - name: s.name, - email: s.email, - role: s.role, - isSuperUser: s.isSuperUser, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true }, - }); - } - const staffLabel = cfg.staffCount.bather > 0 - ? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)` - : `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`; - console.log(`✓ Created ${staffLabel}`); - - // ── SEED_ADMIN_EMAIL admin ── - const adminEmail = process.env.SEED_ADMIN_EMAIL; - if (adminEmail) { - const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; - const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; - await db.insert(schema.staff) - .values({ - id: ADMIN_STAFF_ID, - name: adminName, - email: adminEmail, - oidcSub: adminEmail, - role: "manager", - isSuperUser: true, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: ADMIN_STAFF_ID, name: adminName, role: "manager", isSuperUser: true, active: true }, - }); - console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); - } - - // ── 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) ?? []; - const groomerCount = Math.min(groomerEmails.length, groomerNames.length); - for (let i = 0; i < groomerCount; i++) { - const email = groomerEmails[i]!; - const name = groomerNames[i]!; - const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; - await db.insert(schema.staff) - .values({ - id: staffId, - name, - email, - oidcSub: email, - role: "groomer", - isSuperUser: false, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true }, - }); - console.log(`✓ Upserted groomer '${name}' (${email})`); - } - - // ── Services ── - // GRO-2064: key the upsert on `services.id` (not `name`) so deterministic - // ids always win, and rely on the TRUNCATE above to clear stale rows before - // the catalogue is rebuilt. The previous name-targeted upsert failed with - // `services_pkey` when a prior run had left a row with the same id but a - // different name (CTO review on infra PR #605, rev #4230). - const serviceIds: string[] = []; - for (const s of servicesDef) { - serviceIds.push(s.id); - await db.insert(schema.services) - .values({ - id: s.id, - name: s.name, - description: s.desc, - basePriceCents: s.price, - durationMinutes: s.dur, - active: true, - }) - .onConflictDoUpdate({ - target: schema.services.id, - set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true }, - }); - } - console.log(`✓ Created ${servicesDef.length} services`); - - // ── Clients & Pets ── - const now = new Date(); - const appointmentsBackDate = new Date(now); - appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays); - const appointmentsForwardDate = new Date(now); - appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays); - - interface ClientRecord { id: string; name: string } - interface PetRecord { id: string; clientId: string } - - const clientRecords: ClientRecord[] = []; - const petRecords: PetRecord[] = []; - - let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets - const clientBatchSize = 50; - for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) { - const clientBatch: (typeof schema.clients.$inferInsert)[] = []; - const petBatch: (typeof schema.pets.$inferInsert)[] = []; - - for (let i = 0; i < clientBatchSize; i++) { - const clientId = uuid(); - const first = pick(firstNames); - const last = pick(lastNames); - const name = `${first} ${last}`; - const emailDomain = pick(["gmail.com", "yahoo.com", "outlook.com", "icloud.com", "hotmail.com"]); - const email = `${first.toLowerCase()}.${last.toLowerCase()}${randInt(1, 99)}@${emailDomain}`; - const phone = `(${randInt(200, 999)}) ${randInt(200, 999)}-${String(randInt(1000, 9999))}`; - const addr = `${randInt(100, 9999)} ${pick(streetNames)}, ${pick(cities)}, ${pick(states)} ${String(randInt(10000, 99999))}`; - - clientBatch.push({ - id: clientId, - name, - email, - phone, - address: addr, - notes: rand() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null, - emailOptOut: rand() < 0.1, - }); - - clientRecords.push({ id: clientId, name }); - - // 1-3 pets per client - const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3; - for (let p = 0; p < petCount; p++) { - const petId = uuid(); - const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds); - const dob = new Date(now); - dob.setFullYear(dob.getFullYear() - randInt(1, 14)); - dob.setMonth(randInt(0, 11)); - - petBatch.push({ - id: petId, - clientId, - name: pick(dogNames), - species: "Dog", - breed, - weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)), - dateOfBirth: dob, - healthAlerts: pick(healthAlerts), - groomingNotes: pick(groomingNotes), - cutStyle: pick(cutStyles), - shampooPreference: pick(shampoos), - specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, - coatType: pick(coatTypes), - temperamentScore: pick(temperamentScores), - temperamentFlags: pick(temperamentFlags), - medicalAlerts: pick(medicalAlertsList), - preferredCuts: pick(preferredCutsList), - customFields: {}, - image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), - }); - - petRecords.push({ id: petId, clientId }); - petIndex++; - } - } - - for (const client of clientBatch) { - await db.insert(schema.clients) - .values(client) - .onConflictDoUpdate({ - target: schema.clients.id, - set: { name: client.name, email: client.email, phone: client.phone, address: client.address, notes: client.notes, emailOptOut: client.emailOptOut }, - }); - } - - for (const pet of petBatch) { - await db.insert(schema.pets) - .values(pet) - .onConflictDoUpdate({ - target: schema.pets.id, - set: { - clientId: pet.clientId, - name: pet.name, - species: pet.species, - breed: pet.breed, - weightKg: pet.weightKg, - dateOfBirth: pet.dateOfBirth, - healthAlerts: pet.healthAlerts, - groomingNotes: pet.groomingNotes, - cutStyle: pet.cutStyle, - shampooPreference: pet.shampooPreference, - specialCareNotes: pet.specialCareNotes, - coatType: pet.coatType, - temperamentScore: pet.temperamentScore, - temperamentFlags: pet.temperamentFlags, - medicalAlerts: pet.medicalAlerts, - preferredCuts: pet.preferredCuts, - customFields: pet.customFields, - image: pet.image, - }, - }); - } - } - - console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`); - - // ── UAT test clients (guaranteed pending invoices) ───────────────────────────── - // These 5 clients are deterministic and documented in Shedward AGENTS.md so - // UAT can reliably find billing test data without searching. - if (cfg.includeUatClients) { - interface UatClient { - id: string; - name: string; - email: string; - phone: string; - address: string; - petId: string; - petName: string; - petBreed: string; - petCoatType: string; - petTemperamentScore: number; - petTemperamentFlags: string[]; - petMedicalAlerts: MedicalAlert[]; - petPreferredCuts: string[]; - } - const uatClients: UatClient[] = [ - { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] }, - { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] }, - { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] }, - { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] }, - { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] }, - ]; - - for (const uc of uatClients) { - await db.insert(schema.clients) - .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) - .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); - await db.insert(schema.pets) - .values({ - id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, - weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), - coatType: uc.petCoatType, - temperamentScore: uc.petTemperamentScore, - temperamentFlags: uc.petTemperamentFlags, - medicalAlerts: uc.petMedicalAlerts, - preferredCuts: uc.petPreferredCuts, - image: pick(demoPetImages), - }) - .onConflictDoUpdate({ target: schema.pets.id, set: { - clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, - weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), - coatType: uc.petCoatType, - temperamentScore: uc.petTemperamentScore, - temperamentFlags: uc.petTemperamentFlags, - medicalAlerts: uc.petMedicalAlerts, - preferredCuts: uc.petPreferredCuts, - image: pick(demoPetImages), - } }); - // Create one completed appointment for this client - const apptId = uuid(); - const svcIdx = 0; - const svc = servicesDef[svcIdx]!; - const completedTime = randDate(appointmentsBackDate, now); - completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); - const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000); - const uatGroomer = groomers[0]!; - const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer; - await db.insert(schema.appointments).values({ - id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id, - batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price, - }); - // Create a PENDING invoice for that appointment - const invoiceId = uuid(); - const taxCents = Math.round(svc.price * 0.08); - const totalCents = svc.price + taxCents; - await db.insert(schema.invoices).values({ - id: invoiceId, appointmentId: apptId, clientId: uc.id, subtotalCents: svc.price, - taxCents, tipCents: 0, totalCents, status: "pending" as const, - paymentMethod: null, paidAt: null, notes: null, - }); - await db.insert(schema.invoiceLineItems).values({ - id: uuid(), invoiceId, description: svc.name, quantity: 1, unitPriceCents: svc.price, totalCents: svc.price, - }); - await db.insert(schema.groomingVisitLogs).values({ - id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id, - cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime, - }); - } - console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`); - } - - // ── Appointments, Invoices, Visit Logs ── - // Generate ~5 appointments per client on average = ~2500 total - const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [ - "completed", "completed", "completed", "completed", "completed", - "completed", "completed", "scheduled", "confirmed", "cancelled", "no_show", - ]; - - let appointmentCount = 0; - let invoiceCount = 0; - let visitLogCount = 0; - let paidInvoiceCounter = 0; - - // Process in batches per client to keep memory manageable - const apptBatchSize = 100; - let apptBatch: (typeof schema.appointments.$inferInsert)[] = []; - let invoiceBatch: (typeof schema.invoices.$inferInsert)[] = []; - let lineItemBatch: (typeof schema.invoiceLineItems.$inferInsert)[] = []; - let tipSplitBatch: (typeof schema.invoiceTipSplits.$inferInsert)[] = []; - let visitLogBatch: (typeof schema.groomingVisitLogs.$inferInsert)[] = []; - - async function flushBatches() { - if (apptBatch.length > 0) { - await db.insert(schema.appointments).values(apptBatch); - apptBatch = []; - } - if (invoiceBatch.length > 0) { - await db.insert(schema.invoices).values(invoiceBatch); - invoiceBatch = []; - } - if (lineItemBatch.length > 0) { - await db.insert(schema.invoiceLineItems).values(lineItemBatch); - lineItemBatch = []; - } - if (tipSplitBatch.length > 0) { - await db.insert(schema.invoiceTipSplits).values(tipSplitBatch); - tipSplitBatch = []; - } - if (visitLogBatch.length > 0) { - await db.insert(schema.groomingVisitLogs).values(visitLogBatch); - visitLogBatch = []; - } - } - - // Group pets by client for efficient appointment generation - const petsByClient = new Map(); - for (const pet of petRecords) { - const arr = petsByClient.get(pet.clientId) ?? []; - arr.push(pet.id); - petsByClient.set(pet.clientId, arr); - } - - for (const client of clientRecords) { - const pets = petsByClient.get(client.id) ?? []; - // Each client visits ~3-8 times over the year - const visitCount = randInt(3, 8); - - for (let v = 0; v < visitCount; v++) { - // Pick a random pet for this visit - const petId = pick(pets); - const serviceIdx = randInt(0, serviceIds.length - 1); - const serviceId = serviceIds[serviceIdx]!; - const svc = servicesDef[serviceIdx]!; - const groomer = pick(groomers); - const bather = rand() < 0.6 ? pick(bathers) : null; - const status = pick(statuses); - - // Schedule within the configured appointment window - let startTime: Date; - if (status === "scheduled" || status === "confirmed") { - startTime = randDate(now, appointmentsForwardDate); - } else { - startTime = randDate(appointmentsBackDate, now); - } - // Snap to business hours (8am - 5pm) - startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); - const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); - - const apptId = uuid(); - const priceCents = rand() < 0.2 ? svc.price + randInt(-500, 1000) : null; - const effectivePrice = priceCents ?? svc.price; - - apptBatch.push({ - id: apptId, - clientId: client.id, - petId, - serviceId, - staffId: groomer.id, - batherStaffId: bather?.id ?? null, - status, - startTime, - endTime, - notes: pick(appointmentNotes), - priceCents, - }); - appointmentCount++; - - // Create invoice for completed appointments - if (status === "completed") { - const invoiceId = uuid(); - const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; - const taxCents = Math.round(effectivePrice * 0.08); - const totalCents = effectivePrice + taxCents + tipCents; - - const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; - const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; - paidInvoiceCounter++; - const stripePaymentIntentId = invoiceStatus === "paid" - ? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}` - : null; - - invoiceBatch.push({ - id: invoiceId, - appointmentId: apptId, - clientId: client.id, - subtotalCents: effectivePrice, - taxCents, - tipCents, - totalCents, - status: invoiceStatus, - paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, - paidAt, - stripePaymentIntentId, - notes: rand() < 0.05 ? "Added extra service at checkout" : null, - }); - - // Line item - lineItemBatch.push({ - id: uuid(), - invoiceId, - description: svc.name, - quantity: 1, - unitPriceCents: effectivePrice, - totalCents: effectivePrice, - }); - - // Tip splits for paid invoices with tips - if (tipCents > 0 && invoiceStatus === "paid") { - if (bather) { - // 60/40 split groomer/bather - const groomerShare = Math.round(tipCents * 0.6); - const batherShare = tipCents - groomerShare; - tipSplitBatch.push( - { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, - { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, - ); - } else { - tipSplitBatch.push({ - id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents, - }); - } - } - - invoiceCount++; - - // Visit log - visitLogBatch.push({ - id: uuid(), - petId, - appointmentId: apptId, - staffId: groomer.id, - cutStyle: pick(cutStyles), - productsUsed: pick(productsUsed), - notes: pick(visitLogNotes), - groomedAt: endTime, - }); - visitLogCount++; - } - - // Flush periodically - if (apptBatch.length >= apptBatchSize) { - await flushBatches(); - } - } - } - - // Final flush - await flushBatches(); - - console.log(`✓ Created ${appointmentCount} appointments`); - console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); - - // ── Enforce target invoice count ─────────────────────────────────────────── - // If current invoice count is below target (due to profile having fewer - // clients/appointments than the target ratio), generate supplemental - // completed appointments for existing clients to fill the gap. - if (invoiceCount < cfg.invoiceCount) { - const additionalNeeded = cfg.invoiceCount - invoiceCount; - console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`); - - const existingClientIds = clientRecords.map(c => c.id); - const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20); - let supplementalCount = 0; - let supplementalInvoices = 0; - - for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) { - const clientId = pick(existingClientIds); - const pets = petsByClient.get(clientId) ?? []; - if (pets.length === 0) continue; - - const petId = pick(pets); - const serviceIdx = randInt(0, serviceIds.length - 1); - const serviceId = serviceIds[serviceIdx]!; - const svc = servicesDef[serviceIdx]!; - const groomer = pick(groomers); - const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null; - - const startTime = randDate(appointmentsBackDate, now); - startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); - const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); - const effectivePrice = svc.price; - - const apptId = uuid(); - apptBatch.push({ - id: apptId, clientId, petId, serviceId, - staffId: groomer.id, batherStaffId: bather?.id ?? null, - status: "completed", startTime, endTime, notes: null, priceCents: null, - }); - appointmentCount++; - supplementalCount++; - - const invoiceId = uuid(); - const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; - const taxCents = Math.round(effectivePrice * 0.08); - const totalCents = effectivePrice + taxCents + tipCents; - const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); - paidInvoiceCounter++; - - invoiceBatch.push({ - id: invoiceId, appointmentId: apptId, clientId, - subtotalCents: effectivePrice, taxCents, tipCents, totalCents, - status: "paid" as const, - paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", - paidAt, - stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`, - notes: null, - }); - lineItemBatch.push({ - id: uuid(), invoiceId, description: svc.name, quantity: 1, - unitPriceCents: effectivePrice, totalCents: effectivePrice, - }); - if (tipCents > 0) { - if (bather) { - const groomerShare = Math.round(tipCents * 0.6); - const batherShare = tipCents - groomerShare; - tipSplitBatch.push( - { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, - { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, - ); - } else { - tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents }); - } - } - visitLogBatch.push({ - id: uuid(), petId, appointmentId: apptId, staffId: groomer.id, - cutStyle: pick(cutStyles), productsUsed: pick(productsUsed), - notes: pick(visitLogNotes), groomedAt: endTime, - }); - invoiceCount++; - supplementalInvoices++; - visitLogCount++; - - if (apptBatch.length >= apptBatchSize) { - await flushBatches(); - } - } - - await flushBatches(); - console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`); - console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); - } - console.log(`✓ Created ${visitLogCount} grooming visit logs`); - console.log("\nSeed complete!"); - - await client.end(); -} - -seed().catch((err) => { - console.error("Seed failed:", err); - process.exit(1); -}); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index c647098..0959be0 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -401,7 +401,9 @@ const servicesDef = [ * * In seedKnownUsers() this replaces the inline UAT-staff block. */ -async function seedUatStaffAccounts(db: ReturnType) { +async function seedUatStaffAccounts( + db: ReturnType, +): Promise { // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; if (uatSuperOidcSub) { @@ -668,6 +670,132 @@ async function seedUatStaffAccounts(db: ReturnType) { console.log(`✓ Created UAT pet '${pet.name}' with extended fields`); } } + + // ── GRO-2100: deterministic uat-groomer ↔ pet linkage ─────────────────────── + // The UAT groomer (`uat-groomer@groombook.dev`, staffId 00000000-0000-0000-0000-000000000004) + // needs at least one linked pet/appointment or GRO-1987 TC-UAT-2/3 cannot run + // (the pet profile-summary endpoint returns 404 instead of 200/403). + // + // We deterministically link the UAT groomer to the UAT customer's first pet + // ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so + // TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds. + // + // The linkage call itself is performed by the caller AFTER the `services` + // catalogue has been seeded (this helper runs before services exist, + // which previously caused the linkage to be silently skipped on every + // reset). GRO-2100 follow-up. + return uatCustomerClientId; +} + +/** + * GRO-2100: create a deterministic completed appointment linking the UAT groomer + * to "UAT Pup Alpha" (c0000001-0000-0000-0000-000000000002). "UAT Pup Beta" + * (c0000001-0000-0000-0000-000000000003) is intentionally left UNLINKED so + * GRO-1987 TC-UAT-3 can verify the 403 forbidden response. + * + * Idempotent: the deterministic appointment id (`a0000001-…-0001`) is the + * upsert key, so re-running the seed on every reset-demo-data CronJob + * (hourly per apps/overlays/uat/reset-cronjob.yaml) is safe. + */ +async function seedUatGroomerLinkage( + db: ReturnType, + customerClientId: string | null, +): Promise { + const uatGroomerEmail = "uat-groomer@groombook.dev"; + const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha + const APPT_ID = "a0000001-0000-0000-0000-000000000001"; + + // Skip silently if the UAT Customer client wasn't created (non-UAT seed + // profile, e.g. seedKnownUsers() in an env without the UAT personas). + if (!customerClientId) { + return; + } + + // Only run if the UAT groomer staff record actually exists — dev/test seeds + // that don't set SEED_UAT_STAFF_OIDC_SUB should not crash. + const [uatGroomerStaff] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, uatGroomerEmail)) + .limit(1); + if (!uatGroomerStaff) { + return; + } + + // Skip if this exact appointment already exists (idempotent on re-seed). + const [existing] = await db + .select({ id: schema.appointments.id }) + .from(schema.appointments) + .where(eq(schema.appointments.id, APPT_ID)) + .limit(1); + if (existing) { + console.log(`✓ GRO-2100: uat-groomer linkage appointment already exists — skipping`); + return; + } + + // Skip if the linked pet hasn't been seeded yet (defensive: caller should + // ensure pets exist; if the helper is re-ordered later we don't want to + // crash here). + const [linkedPet] = await db + .select({ id: schema.pets.id }) + .from(schema.pets) + .where(eq(schema.pets.id, LINKED_PET_ID)) + .limit(1); + if (!linkedPet) { + console.warn(`⚠ GRO-2100: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping uat-groomer linkage`); + return; + } + + // The "Bath & Brush" service id is stable across the reset; falls back to + // any active service if it has not been seeded yet (e.g. seedKnownUsers + // runs in isolation). + 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-2100: no active services found — skipping uat-groomer linkage`); + return; + } + serviceId = fallback.id; + } + + // Schedule the completed appointment 7 days ago so the profile-summary's + // "recentGroomingHistory" window (last 10) reliably includes it. + const startTime = new Date(); + startTime.setDate(startTime.getDate() - 7); + startTime.setHours(10, 0, 0, 0); + const endTime = new Date(startTime.getTime() + 45 * 60 * 1000); + + await db.insert(schema.appointments).values({ + id: APPT_ID, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + staffId: uatGroomerStaff.id, + batherStaffId: null, + status: "completed", + startTime, + endTime, + notes: "GRO-2100: deterministic uat-groomer linkage for TC-UAT-2/3.", + priceCents: null, + confirmationStatus: "confirmed", + }); + console.log( + `✓ GRO-2100: linked uat-groomer (${uatGroomerStaff.id}) → UAT Pup Alpha (${LINKED_PET_ID}) via appointment ${APPT_ID}`, + ); } // ── Known-users-only seed (prod/demo) ─────────────────────────────────────── @@ -745,7 +873,7 @@ async function seedKnownUsers() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers() // and the full seed() UAT branch. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services: idempotent upsert keyed on `id` ───────────────────────────── // GRO-2064: previously keyed on `services.name` while writing a @@ -773,6 +901,12 @@ async function seedKnownUsers() { } console.log(`✓ Seeded ${demoSvcs.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Client: Demo Client ── const [existingClient] = await db .select() @@ -842,6 +976,63 @@ async function seedKnownUsers() { // ── Main seed ──────────────────────────────────────────────────────────────── +// ── GRO-2123: serialize reset+seed with a Postgres advisory lock ──────── +// The reset-demo-data CronJob runs on an hourly schedule. With +// concurrencyPolicy=Replace, a new pod can start while the previous one +// is still mid-seed; the new pod's TRUNCATE then deletes rows the old pod +// is still inserting, producing FK 23503 errors non-deterministically +// (see GRO-2123: invoice_tip_splits → invoices). +// +// We hold a session-level advisory lock for the full duration of the +// seed so that overlapping invocations block then proceed in order — +// not skip. The key is a stable 32-bit constant so it can be referenced +// from runbooks without ambiguity and binds to the single-argument +// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain +// number (no bigint type plumbing required). +const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable + +/** + * Reserve a dedicated connection from `pool`, take the seed advisory lock + * on it, run `fn`, and release the lock + connection in a try/finally. + * + * CRITICAL: with postgres-js connection pooling, a session-level + * `pg_advisory_lock(KEY)` acquired on one pooled connection and released + * on a *different* one is a no-op (the lock is bound to the session / + * pg-backend that took it). We therefore reserve a dedicated connection + * for the lock and release it from the same reserved connection. The + * seed work itself still runs on the pooled connections. + */ +async function withSeedAdvisoryLock( + pool: ReturnType, + fn: () => Promise, +): Promise { + const lockConnection = await pool.reserve(); + let lockHeld = false; + try { + await lockConnection`SELECT pg_advisory_lock(${SEED_ADVISORY_LOCK_KEY})`; + lockHeld = true; + console.log(`✓ Acquired seed advisory lock (key=${SEED_ADVISORY_LOCK_KEY})`); + const result = await fn(); + await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`; + lockHeld = false; + console.log(`✓ Released seed advisory lock`); + return result; + } finally { + if (lockHeld) { + try { + await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`; + } catch (err) { + console.error("Failed to release seed advisory lock during cleanup:", err); + } + } + try { + lockConnection.release(); + } catch (err) { + console.error("Failed to release reserved lock connection:", err); + } + } +} + async function seed() { const url = process.env.DATABASE_URL; if (!url) { @@ -859,6 +1050,22 @@ async function seed() { const client = postgres(url, { max: 5 }); const db = drizzle(client, { schema }); + // GRO-2123: hold the seed advisory lock for the full body of runSeedBody. + // See the withSeedAdvisoryLock comment for why a reserved connection is + // required (postgres-js pooling would silently drop the lock otherwise). + await withSeedAdvisoryLock(client, async () => { + return await runSeedBody(client, db, profile, cfg); + }); + + await client.end(); +} + +async function runSeedBody( + client: ReturnType, + db: ReturnType, + profile: SeedProfile, + cfg: ProfileConfig, +): Promise { console.log(`Seeding Groom Book database (profile: ${profile})...\n`); // ── Staff ── @@ -929,7 +1136,7 @@ async function seed() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials. // Must run AFTER random staff are created so upserts land correctly. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services ── // GRO-2064: key the upsert on `services.id` (not `name`) so deterministic @@ -956,6 +1163,12 @@ async function seed() { } console.log(`✓ Created ${servicesDef.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); @@ -1474,8 +1687,6 @@ async function seed() { } console.log(`✓ Created ${visitLogCount} grooming visit logs`); console.log("\nSeed complete!"); - - await client.end(); } seed().catch((err) => {