1aab3bf4e8
The uc reference in the random pet batch (lines 970/973) is a regression from GRO-1949 — uc is only defined in the UAT client loop context (line 1056), not in the surrounding random pet generation loop. Deterministic UAT pet alerts are already correctly implemented in the uatClients loop (lines 1073-1078) where uc is in scope. This removes the undefined uc references from the random batch IIFE, restoring typecheck compliance. The deterministic UAT seeding for TestCooper/TestRocky remains intact in the uAT client loop.
1414 lines
58 KiB
TypeScript
1414 lines
58 KiB
TypeScript
/**
|
|
* 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>) {
|
|
// ── 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 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) {
|
|
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) {
|
|
console.log(`✓ UAT Pet '${existing.name}' already exists — skipping`);
|
|
} 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,
|
|
});
|
|
console.log(`✓ Created UAT pet '${pet.name}'`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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.
|
|
await seedUatStaffAccounts(db);
|
|
|
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
|
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-000000000004", 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.name,
|
|
set: { 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 })
|
|
);
|
|
|
|
await db.execute(sql`TRUNCATE 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.
|
|
await seedUatStaffAccounts(db);
|
|
|
|
// ── Services ──
|
|
// Upsert services using name as unique key. With deterministic IDs in
|
|
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
|
// idempotent: first run inserts, subsequent runs update existing rows.
|
|
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.name,
|
|
set: { 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,
|
|
customFields: {},
|
|
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
|
temperamentScore: randInt(1, 5),
|
|
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
|
medicalAlerts: (() => {
|
|
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: (() => {
|
|
// Deterministic alerts for UAT AC pets
|
|
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() }));
|
|
}
|
|
// Other UAT pets: random
|
|
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: (() => {
|
|
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!");
|
|
|
|
await client.end();
|
|
}
|
|
|
|
seed().catch((err) => {
|
|
console.error("Seed failed:", err);
|
|
process.exit(1);
|
|
});
|