Add test data seed script with 500 clients, 6 staff, and appointments (#36)

Creates packages/db/src/seed.ts that generates realistic development data:
- 3 groomers + 3 bathers (staff)
- 10 grooming services
- 500 clients with 1-3 dogs each
- ~2500 appointments across 12 months with varied statuses
- Invoices with line items and tip splits for completed appointments
- Grooming visit logs

Run via: pnpm db:seed (requires DATABASE_URL)

Co-authored-by: Groom Book CEO <ceo@groombook.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #36.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 23:37:18 +00:00
committed by GitHub
parent 1c54c4264d
commit 20fa4698be
4 changed files with 535 additions and 1 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"db:migrate": "pnpm --filter @groombook/db migrate"
"db:migrate": "pnpm --filter @groombook/db migrate",
"db:seed": "pnpm --filter @groombook/db seed"
},
"engines": {
"node": ">=20",
+2
View File
@@ -7,6 +7,7 @@
"scripts": {
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"seed": "tsx src/seed.ts",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
@@ -17,6 +18,7 @@
"devDependencies": {
"@types/node": "^22.10.7",
"drizzle-kit": "^0.30.4",
"tsx": "^4.19.0",
"typescript": "^5.7.3"
}
}
+528
View File
@@ -0,0 +1,528 @@
/**
* Seed script — generates realistic test data for Groom Book development.
*
* Creates:
* - 3 groomers + 3 bathers (staff)
* - 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
*
* Usage:
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
*/
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "./schema.js";
// ── Helpers ──────────────────────────────────────────────────────────────────
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function pickN<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => Math.random() - 0.5);
return shuffled.slice(0, n);
}
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randDate(start: Date, end: Date): Date {
return new Date(
start.getTime() + Math.random() * (end.getTime() - start.getTime())
);
}
function uuid(): string {
return crypto.randomUUID();
}
// ── 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",
"Mixed Breed", "Mixed Breed", "Mixed Breed",
];
const cutStyles = [
"Puppy Cut", "Teddy Bear Cut", "Lion Cut", "Breed Standard",
"Summer Shave", "Kennel Cut", "Lamb Cut", "Continental Clip",
"Sporting Clip", "Sanitary Trim", "Face & Feet Trim", "Full Groom",
null,
];
const shampoos = [
"Oatmeal Sensitive", "Whitening Formula", "Flea & Tick", "Hypoallergenic",
"De-shedding", "Puppy Gentle", "Medicated", "Coconut Oil",
"Lavender Calm", null,
];
const healthAlerts = [
null, null, null, null, null, // Most pets have none
"Sensitive skin — avoid harsh shampoos",
"Ear infection prone — dry ears thoroughly",
"Hip dysplasia — handle with care",
"Anxious — needs slow approach",
"Seizure history — avoid stress triggers",
"Skin allergies — use hypoallergenic products only",
"Aggressive when nails trimmed — muzzle required",
"Heart murmur — monitor during grooming",
"Diabetic — owner brings treats",
];
const streetNames = [
"Main St", "Oak Ave", "Maple Dr", "Cedar Ln", "Elm St", "Pine Rd",
"Birch Way", "Walnut Ct", "Cherry Blvd", "Willow Pl", "Spruce Ter",
"Chestnut Cir", "Hickory Ln", "Magnolia Ave", "Sycamore Dr",
"Dogwood Rd", "Aspen Way", "Redwood Ct", "Juniper Blvd", "Poplar St",
];
const cities = [
"Springfield", "Riverside", "Fairview", "Madison", "Georgetown",
"Clinton", "Salem", "Greenville", "Franklin", "Bristol",
"Manchester", "Oakland", "Burlington", "Arlington", "Ashland",
];
const states = ["CA", "TX", "NY", "FL", "IL", "PA", "OH", "GA", "NC", "MI"];
const groomingNotes = [
null, null, null,
"Matting prone — brush out before bath",
"Loves the dryer",
"Nippy around paws",
"Very calm, easy to handle",
"Needs extra time for drying (thick coat)",
"Sensitive around face — use caution",
"Doesn't like water, use minimal bath time",
"Loves belly rubs — great way to calm down",
"Double coat — needs thorough de-shedding",
"Previous clipper burn — be gentle on belly",
];
const appointmentNotes = [
null, null, null, null,
"Client requested extra brushing",
"Nail trim only — no bath",
"Teeth brushing added",
"Ear cleaning requested",
"New puppy — first groom, be gentle",
"Matted — may need extra time",
"Owner wants shorter cut than usual",
"Anal glands need expressing",
"Use gentle shampoo per vet recommendation",
"Client running late, pushed start by 15min",
];
const 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",
];
// ── Service definitions ──────────────────────────────────────────────────────
const servicesDef = [
{ name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 },
{ name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 },
{ name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 },
{ name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 },
{ name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 },
{ name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 },
{ name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 },
{ name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 },
{ name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 },
{ name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
];
// ── Main seed ────────────────────────────────────────────────────────────────
async function seed() {
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 Groom Book database...\n");
// ── Staff ──
const groomers = [
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const },
];
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
const bathers = [
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
];
const allStaff = [...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,
active: true,
});
}
console.log(`✓ Created ${allStaff.length} staff (3 groomers, 3 bathers)`);
// ── Services ──
const serviceIds: string[] = [];
for (const s of servicesDef) {
const id = uuid();
serviceIds.push(id);
await db.insert(schema.services).values({
id,
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 oneYearAgo = new Date(now);
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
interface ClientRecord { id: string; name: string }
interface PetRecord { id: string; clientId: string; name: string }
const clientRecords: ClientRecord[] = [];
const petRecords: PetRecord[] = [];
// Batch insert clients and pets
const clientBatchSize = 50;
for (let batch = 0; batch < 500 / 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: Math.random() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null,
emailOptOut: Math.random() < 0.1,
});
clientRecords.push({ id: clientId, name });
// 1-3 pets per client
const petCount = Math.random() < 0.5 ? 1 : Math.random() < 0.7 ? 2 : 3;
for (let p = 0; p < petCount; p++) {
const petId = uuid();
const breed = 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) + Math.random().toFixed(1).slice(1)),
dateOfBirth: dob,
healthAlerts: pick(healthAlerts),
groomingNotes: pick(groomingNotes),
cutStyle: pick(cutStyles),
shampooPreference: pick(shampoos),
specialCareNotes: Math.random() < 0.1 ? "Vet clearance required before grooming" : null,
customFields: {},
});
petRecords.push({ id: petId, clientId, name: "" });
}
}
await db.insert(schema.clients).values(clientBatch);
await db.insert(schema.pets).values(petBatch);
}
console.log(`✓ Created 500 clients with ${petRecords.length} pets`);
// ── 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 = Math.random() < 0.6 ? pick(bathers) : null;
const status = pick(statuses);
// Schedule within the past year, or next 2 weeks for upcoming
let startTime: Date;
if (status === "scheduled" || status === "confirmed") {
startTime = randDate(now, new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000));
} else {
startTime = randDate(oneYearAgo, 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 = Math.random() < 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 = Math.random() < 0.7 ? randInt(200, 3000) : 0;
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const invoiceStatus = Math.random() < 0.95 ? "paid" as const : "pending" as const;
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : 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,
notes: Math.random() < 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`);
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);
});
+3
View File
@@ -124,6 +124,9 @@ importers:
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3