fc6c6ef752
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 28s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Successful in 39s
1485 lines
62 KiB
TypeScript
1485 lines
62 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) {
|
||
// 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`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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 keyed on `id` ─────────────────────────────
|
||
// GRO-2064: previously keyed on `services.name` while writing a
|
||
// deterministic `id`. If a stale row existed with the same `id` but a
|
||
// different `name`, PostgreSQL raised `services_pkey` (id collision)
|
||
// before the name-targeted ON CONFLICT could fire. Switch the conflict
|
||
// target to `services.id` so deterministic ids always win; pair with
|
||
// `TRUNCATE services … CASCADE` above so each reset rebuilds the
|
||
// catalogue from `servicesDef` cleanly. GRO-2033 close-out.
|
||
// Id↔name map MUST stay in sync with `servicesDef` (the canonical source
|
||
// of truth in the main `seed()` function).
|
||
const demoSvcs = [
|
||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
|
||
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||
];
|
||
for (const svc of demoSvcs) {
|
||
await db.insert(schema.services)
|
||
.values({ ...svc, active: true })
|
||
.onConflictDoUpdate({
|
||
target: schema.services.id,
|
||
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
||
});
|
||
}
|
||
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
||
|
||
// ── Client: Demo Client ──
|
||
const [existingClient] = await db
|
||
.select()
|
||
.from(schema.clients)
|
||
.where(eq(schema.clients.email, "demo-client@example.com"))
|
||
.limit(1);
|
||
|
||
let clientId: string;
|
||
if (existingClient) {
|
||
clientId = existingClient.id;
|
||
console.log(`✓ Client '${existingClient.name}' already exists — skipping`);
|
||
} else {
|
||
const [created] = await db
|
||
.insert(schema.clients)
|
||
.values({
|
||
id: DEMO_CLIENT_ID,
|
||
name: "Demo Client",
|
||
email: "demo-client@example.com",
|
||
phone: "555-0001",
|
||
address: "1 Demo Street, Demo City, CA 90210",
|
||
})
|
||
.returning();
|
||
clientId = created!.id;
|
||
console.log("✓ Created client 'Demo Client'");
|
||
}
|
||
|
||
// ── Pets: Demo Dogs & Cats ──
|
||
const demoPets = [
|
||
{ id: DEMO_PET_ID, name: "Demo Dog", species: "Dog", breed: "Golden Retriever", weight: "30.00", dob: "2020-06-15", image: "/demo-pets/dog-golden-after.png" },
|
||
{ id: uuid(), name: "Fluffy", species: "Dog", breed: "Poodle", weight: "8.50", dob: "2019-03-22", image: "/demo-pets/dog-poodle-groomed.png" },
|
||
{ id: uuid(), name: "Shadow", species: "Dog", breed: "Black Labrador", weight: "35.00", dob: "2018-11-10", image: "/demo-pets/dog-black-lab.png" },
|
||
{ id: uuid(), name: "Bella", species: "Dog", breed: "Shih Tzu", weight: "4.50", dob: "2021-02-14", image: "/demo-pets/dog-shih-tzu.png" },
|
||
{ id: uuid(), name: "Max", species: "Dog", breed: "Cocker Spaniel", weight: "15.00", dob: "2019-07-08", image: "/demo-pets/dog-cocker-spaniel.png" },
|
||
{ id: uuid(), name: "Buddy", species: "Dog", breed: "Schnauzer", weight: "12.00", dob: "2020-05-20", image: "/demo-pets/dog-schnauzer.png" },
|
||
{ id: uuid(), name: "Daisy", species: "Dog", breed: "Maltese", weight: "3.50", dob: "2021-09-03", image: "/demo-pets/dog-maltese.png" },
|
||
{ id: uuid(), name: "Charlie", species: "Dog", breed: "Dachshund", weight: "6.00", dob: "2020-01-15", image: "/demo-pets/dog-dachshund.png" },
|
||
{ id: uuid(), name: "Lucy", species: "Dog", breed: "Pomeranian", weight: "2.50", dob: "2022-04-10", image: "/demo-pets/dog-pomeranian.png" },
|
||
];
|
||
|
||
for (const pet of demoPets) {
|
||
const [existing] = await db
|
||
.select()
|
||
.from(schema.pets)
|
||
.where(eq(schema.pets.id, pet.id))
|
||
.limit(1);
|
||
|
||
if (existing) {
|
||
console.log(`✓ Pet '${existing.name}' already exists — skipping`);
|
||
} else {
|
||
await db.insert(schema.pets).values({
|
||
id: pet.id,
|
||
clientId,
|
||
name: pet.name,
|
||
species: pet.species,
|
||
breed: pet.breed,
|
||
weightKg: pet.weight,
|
||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||
image: pet.image,
|
||
});
|
||
console.log(`✓ Created pet '${pet.name}'`);
|
||
}
|
||
}
|
||
|
||
console.log("\nKnown-users seed complete!");
|
||
await client.end();
|
||
}
|
||
|
||
// ── Main seed ────────────────────────────────────────────────────────────────
|
||
|
||
async function seed() {
|
||
const url = process.env.DATABASE_URL;
|
||
if (!url) {
|
||
console.error("DATABASE_URL is not set");
|
||
process.exit(1);
|
||
}
|
||
|
||
if (process.env.SEED_KNOWN_USERS_ONLY === "true") {
|
||
await seedKnownUsers();
|
||
return;
|
||
}
|
||
|
||
const profile = getProfile();
|
||
const cfg = profiles[profile];
|
||
const client = postgres(url, { max: 5 });
|
||
const db = drizzle(client, { schema });
|
||
|
||
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
|
||
|
||
// ── Staff ──
|
||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
||
);
|
||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||
);
|
||
const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) =>
|
||
({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
|
||
);
|
||
const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) =>
|
||
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
|
||
);
|
||
|
||
// GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue
|
||
// from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows
|
||
// (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the
|
||
// same `id`) would otherwise cause the deterministic upsert to PK-collide
|
||
// on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE
|
||
// CASCADE handles appointments/invoices FKs to services.id.
|
||
await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
|
||
|
||
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||
for (const s of allStaff) {
|
||
await db.insert(schema.staff)
|
||
.values({
|
||
id: s.id,
|
||
name: s.name,
|
||
email: s.email,
|
||
role: s.role,
|
||
isSuperUser: s.isSuperUser,
|
||
active: true,
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: schema.staff.email,
|
||
set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
|
||
});
|
||
}
|
||
const staffLabel = cfg.staffCount.bather > 0
|
||
? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)`
|
||
: `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`;
|
||
console.log(`✓ Created ${staffLabel}`);
|
||
|
||
// ── SEED_ADMIN_EMAIL admin ──
|
||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||
if (adminEmail) {
|
||
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
|
||
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
|
||
await db.insert(schema.staff)
|
||
.values({
|
||
id: ADMIN_STAFF_ID,
|
||
name: adminName,
|
||
email: adminEmail,
|
||
oidcSub: adminEmail,
|
||
role: "manager",
|
||
isSuperUser: true,
|
||
active: true,
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: schema.staff.email,
|
||
set: { id: ADMIN_STAFF_ID, name: adminName, role: "manager", isSuperUser: true, active: true },
|
||
});
|
||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||
}
|
||
|
||
// ── UAT 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 ──
|
||
// GRO-2064: key the upsert on `services.id` (not `name`) so deterministic
|
||
// ids always win, and rely on the TRUNCATE above to clear stale rows before
|
||
// the catalogue is rebuilt. The previous name-targeted upsert failed with
|
||
// `services_pkey` when a prior run had left a row with the same id but a
|
||
// different name (CTO review on infra PR #605, rev #4230).
|
||
const serviceIds: string[] = [];
|
||
for (const s of servicesDef) {
|
||
serviceIds.push(s.id);
|
||
await db.insert(schema.services)
|
||
.values({
|
||
id: s.id,
|
||
name: s.name,
|
||
description: s.desc,
|
||
basePriceCents: s.price,
|
||
durationMinutes: s.dur,
|
||
active: true,
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: schema.services.id,
|
||
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
|
||
});
|
||
}
|
||
console.log(`✓ Created ${servicesDef.length} services`);
|
||
|
||
// ── Clients & Pets ──
|
||
const now = new Date();
|
||
const appointmentsBackDate = new Date(now);
|
||
appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays);
|
||
const appointmentsForwardDate = new Date(now);
|
||
appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays);
|
||
|
||
interface ClientRecord { id: string; name: string }
|
||
interface PetRecord { id: string; clientId: string }
|
||
|
||
const clientRecords: ClientRecord[] = [];
|
||
const petRecords: PetRecord[] = [];
|
||
|
||
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
|
||
const clientBatchSize = 50;
|
||
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
|
||
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
|
||
const petBatch: (typeof schema.pets.$inferInsert)[] = [];
|
||
|
||
for (let i = 0; i < clientBatchSize; i++) {
|
||
const clientId = uuid();
|
||
const first = pick(firstNames);
|
||
const last = pick(lastNames);
|
||
const name = `${first} ${last}`;
|
||
const emailDomain = pick(["gmail.com", "yahoo.com", "outlook.com", "icloud.com", "hotmail.com"]);
|
||
const email = `${first.toLowerCase()}.${last.toLowerCase()}${randInt(1, 99)}@${emailDomain}`;
|
||
const phone = `(${randInt(200, 999)}) ${randInt(200, 999)}-${String(randInt(1000, 9999))}`;
|
||
const addr = `${randInt(100, 9999)} ${pick(streetNames)}, ${pick(cities)}, ${pick(states)} ${String(randInt(10000, 99999))}`;
|
||
|
||
clientBatch.push({
|
||
id: clientId,
|
||
name,
|
||
email,
|
||
phone,
|
||
address: addr,
|
||
notes: rand() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null,
|
||
emailOptOut: rand() < 0.1,
|
||
});
|
||
|
||
clientRecords.push({ id: clientId, name });
|
||
|
||
// 1-3 pets per client
|
||
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
||
for (let p = 0; p < petCount; p++) {
|
||
const petId = uuid();
|
||
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds);
|
||
const dob = new Date(now);
|
||
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
|
||
dob.setMonth(randInt(0, 11));
|
||
|
||
petBatch.push({
|
||
id: petId,
|
||
clientId,
|
||
name: pick(dogNames),
|
||
species: "Dog",
|
||
breed,
|
||
weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)),
|
||
dateOfBirth: dob,
|
||
healthAlerts: pick(healthAlerts),
|
||
groomingNotes: pick(groomingNotes),
|
||
cutStyle: pick(cutStyles),
|
||
shampooPreference: pick(shampoos),
|
||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||
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 25–35% 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!");
|
||
|
||
await client.end();
|
||
}
|
||
|
||
seed().catch((err) => {
|
||
console.error("Seed failed:", err);
|
||
process.exit(1);
|
||
});
|