Files
api/packages/db/src/seed.ts
T
Flea Flicker e9ad92de01
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Successful in 28s
uat→main (PROD): GRO-2157 nav export + GRO-2225/2235 (frozen @4868f18) (#192)
feat: nav export + conflict guard + UAT seed (GRO-2157, GRO-2225, GRO-2235)

Squash-merges PR #192: uat→main PROD promotion.
Freezes at validated SHA 4868f18 (UAT regression GRO-2261 11/11 PASS).
Bundles: GRO-2157 (nav export), GRO-2225 (UAT seed), GRO-2235 (conflict guard).
CTO-reviewed and approved (review #4542).

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-09 01:23:06 +00:00

1906 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } from "@groombook/types";
// ── 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<SeedProfile, ProfileConfig> = {
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<T>(arr: T[]): T {
return arr[Math.floor(rand() * arr.length)]!;
}
/** Return n distinct random elements from an array. */
function pickN<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => rand() - 0.5);
return shuffled.slice(0, n);
}
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",
];
// ── Extended pet profile pools ─────────────────────────────────────────────────
const temperamentFlagPool: string[] = [
"friendly",
"anxious-with-strangers",
"good-with-kids",
"leash-reactive",
"vocal",
"high-energy",
"calm-on-table",
"treat-motivated",
];
const medicalAlertPool: MedicalAlert[] = [
{ id: "", type: "allergies", description: "Seasonal allergies — monitor skin", severity: "low" },
{ id: "", type: "allergies", description: "Chicken allergy — avoid poultry-based treats", severity: "high" },
{ id: "", type: "joint", description: "Hip dysplasia — handle with care", severity: "medium" },
{ id: "", type: "joint", description: "Arthritis — anti-inflammatory medication on file", severity: "medium" },
{ id: "", type: "dental", description: "Dental disease — extractions in history", severity: "medium" },
{ id: "", type: "dental", description: "Baby teeth retained — vet monitor", severity: "low" },
{ id: "", type: "heart", description: "Heart murmur grade II — avoid stress", severity: "high" },
{ id: "", type: "heart", description: "Murmur cleared by vet last year", severity: "low" },
{ id: "", type: "other", description: "Eye ulcer history — be careful around face", severity: "medium" },
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
{ id: "", type: "other", description: "Luxating patella — short walks only", severity: "medium" },
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
{ id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" },
{ id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" },
{ id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" },
{ id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" },
];
const preferredCutPool: string[] = [
"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",
];
type CoatType = (typeof schema.coatTypeEnum.enumValues)[number];
type PetSizeCategory = (typeof schema.petSizeCategoryEnum.enumValues)[number];
const coatTypePool: CoatType[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"];
const petSizeCategoryPool: PetSizeCategory[] = ["small", "medium", "large", "extra_large"];
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 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 },
];
// ── UAT staff account seeding (shared between seed paths) ─────────────────────
/**
* Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs
* from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars.
*
* In the full seed path this must run AFTER random staff are created so the
* deterministic upserts land on the correct rows (groomers referenced by the
* UAT test-client appointment logic use groomers[0] etc.).
*
* In seedKnownUsers() this replaces the inline UAT-staff block.
*/
async function seedUatStaffAccounts(
db: ReturnType<typeof drizzle>,
): Promise<string | null> {
// ── 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 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) ?? [];
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" },
// 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) {
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) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
} else {
// Use Better-Auth's own hashPassword so the hash format matches what
// better-auth validates at sign-in time.
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64.
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`);
}
}
}
// ── Client: UAT Customer ─────────────────────────────────────────────────────
// Only uat-customer is a real end-user who needs a clients row.
// uat-groomer and uat-super are staff — they have staff records, not client records.
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
const [uatCustomerRow] = await db
.select()
.from(schema.clients)
.where(eq(schema.clients.email, "uat-customer@groombook.dev"))
.limit(1);
let uatCustomerClientId: string;
if (uatCustomerRow) {
uatCustomerClientId = uatCustomerRow.id;
console.log(`✓ UAT Customer client record already exists — skipping`);
} else {
const [created] = await db
.insert(schema.clients)
.values({
id: UAT_CUSTOMER_ID,
email: "uat-customer@groombook.dev",
name: "UAT Customer",
phone: "555-0102",
address: "1 UAT Lane, Test City, CA 90210",
})
.returning();
uatCustomerClientId = created!.id;
console.log(`✓ Created client 'UAT Customer' for SSO bridge`);
}
// ── Pets: UAT Customer's dogs ────────────────────────────────────────────────
const uatCustomerPets = [
{ id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" },
{ id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" },
];
for (const pet of uatCustomerPets) {
const [existing] = await db
.select()
.from(schema.pets)
.where(eq(schema.pets.id, pet.id))
.limit(1);
if (existing) {
// Upsert so extended fields are always populated on re-runs
await db.insert(schema.pets)
.values({
id: pet.id,
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: [],
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
})
.onConflictDoUpdate({
target: schema.pets.id,
set: {
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: [],
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
},
});
console.log(`✓ Upserted UAT pet '${pet.name}' with extended fields`);
} else {
await db.insert(schema.pets).values({
id: pet.id,
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: [],
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
});
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<typeof drizzle>,
customerClientId: string | null,
): Promise<void> {
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}`,
);
}
// ── 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) ───────────────────────────────────────
/**
* 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})`);
}
}
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers()
// and the full seed() UAT branch.
const uatCustomerClientId = await seedUatStaffAccounts(db);
// ── 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`);
// 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()
.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 ────────────────────────────────────────────────────────────────
// ── 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<T>(
pool: ReturnType<typeof postgres>,
fn: () => Promise<T>,
): Promise<T> {
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) {
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 });
// 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<typeof postgres>,
db: ReturnType<typeof drizzle>,
profile: SeedProfile,
cfg: ProfileConfig,
): Promise<void> {
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 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.
const uatCustomerClientId = await seedUatStaffAccounts(db);
// ── 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`);
// 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);
// 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);
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,
customFields: {},
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// ~30% of random-pool pets have alerts — lands squarely in the 2535% AC band
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
}
return [];
})(),
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
});
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,
customFields: pet.customFields,
image: pet.image,
temperamentScore: pet.temperamentScore,
temperamentFlags: pet.temperamentFlags,
medicalAlerts: pet.medicalAlerts,
preferredCuts: pet.preferredCuts,
coatType: pet.coatType,
petSizeCategory: pet.petSizeCategory,
},
});
}
}
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;
}
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" },
{ 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" },
{ 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" },
{ 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" },
{ 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" },
];
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"),
image: pick(demoPetImages),
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// TestCooper always has a behavioral alert; TestRocky always has a skin alert.
// All other UAT test pets follow the 30% random distribution.
// Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift
// the overall distribution from the 25-35% target band.
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
}
return [];
})(),
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
})
.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"),
image: pick(demoPetImages),
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// TestCooper always has a behavioral alert; TestRocky always has a skin alert.
// All other UAT test pets follow the 30% random distribution.
// Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift
// the overall distribution from the 25-35% target band.
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
}
return [];
})(),
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
},
});
// 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;
// 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<string, string[]>();
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;
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : 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;
let 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);
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
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, 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!");
}
seed().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});