diff --git a/docs/seed-strategy.md b/docs/seed-strategy.md new file mode 100644 index 0000000..4feb20f --- /dev/null +++ b/docs/seed-strategy.md @@ -0,0 +1,93 @@ +# Seed Strategy Runbook + +This document describes the GroomBook seeding system across environments. + +## Environment Profiles + +| Profile | Staff | Clients | Invoices | Appointment Window | Auth | +|---------|-------|---------|----------|-------------------|------| +| `dev` | 4 (1 manager, 1 receptionist, 2 groomers) | ~100 | ~1,000 | 7 days back / 30 days forward | Disabled | +| `uat` | 8 (1 manager, 1 receptionist, 3 groomers, 3 bathers) | ~500 | ~4,000 | 30 days back / 90 days forward | Enabled | +| `demo` | 8 (1 manager, 1 receptionist, 3 groomers, 3 bathers) | ~500 | ~4,000 | 30 days back / 90 days forward | Enabled, OOBE enabled | + +## Seed Script Environment Variables + +| Variable | Values | Effect | +|----------|--------|--------| +| `SEED_PROFILE` | `dev`, `uat`, `demo` | Selects data volume profile (see above). Defaults to `uat` if unset. | +| `SEED_KNOWN_USERS_ONLY` | `true` | Minimal prod/demo seed with demo users only. Overrides `SEED_PROFILE`. | +| `SEED_ADMIN_EMAIL` | email address | Creates an admin staff account with the given email. | +| `SEED_ADMIN_NAME` | name | Display name for admin account. Defaults to "Admin". | + +## Re-seeding Environments + +### Dev + +```bash +# Run seed job manually +kubectl -n groombook-dev exec -it deploy/groombook-api -- \ + sh -c 'DATABASE_URL=$DATABASE_URL SEED_PROFILE=dev npm run db:seed' +``` + +Dev uses `AUTH_DISABLED=true` and accepts the `X-Dev-User-Id` header for staff impersonation. + +### UAT + +```bash +# Run seed job manually +kubectl -n groombook-uat exec -it deploy/groombook-api -- \ + sh -c 'DATABASE_URL=$DATABASE_URL SEED_PROFILE=uat npm run db:seed' +``` + +UAT uses Authentik OIDC. See Authentik UAT Personas below. + +### Demo (Production-like) + +Demo uses the same data volume as UAT but with `SEED_KNOWN_USERS_ONLY=true` or is provisioned via the standard seed with OOBE enabled. + +```bash +# Trigger seed CronJob +kubectl -n groombook cronjob trigger seed-job --latest +``` + +## Authentik UAT User Personas + +Credentials are stored in sealed secrets — never use plaintext values. + +| Persona | Email | Role | Access Level | +|---------|-------|------|--------------| +| UAT Super User | `uat-super@groombook.dev` | Super User | Full admin access | +| UAT Staff | `uat-staff@groombook.dev` | Staff | Standard staff operations | +| UAT Customer | `uat-customer@groombook.dev` | Customer | Customer portal access | + +Sealed secret: `authentik-credentials` in `groombook-uat` namespace. + +## OOBE (Out-of-Box Experience) Flag + +The OOBE flag controls first-run setup flow in Demo/Production environments. + +- **Demo/Production**: OOBE is enabled, users see setup wizard on first login +- **Dev/UAT**: OOBE is disabled, full access granted immediately + +When `SEED_KNOWN_USERS_ONLY=true`, the demo users are created but OOBE state must be initialized separately. + +## Dev-Mode Access + +Dev environment disables authentication for local development convenience. + +```bash +AUTH_DISABLED=true +``` + +To impersonate a specific staff user, use the `X-Dev-User-Id` header: + +```bash +curl -H "X-Dev-User-Id: " http://localhost:3000/api/... +``` + +## Seed Idempotency + +The seed script is idempotent and deterministic: +- Same `SEED_PROFILE` produces identical data with same IDs +- Re-running seed updates existing records rather than creating duplicates +- Appointments, invoices, and visit logs are truncated before each seed to ensure clean state diff --git a/infra b/infra index d43a016..e8bd354 160000 --- a/infra +++ b/infra @@ -1 +1 @@ -Subproject commit d43a016f3f304d34bdbda82a48a8865399d420fd +Subproject commit e8bd35499d7df5714c6eedd3c10142b0c2687c61 diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 09351bb..795cdc1 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -1,19 +1,20 @@ /** * 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 + * Supports three profiles via SEED_PROFILE env var: + * - dev: 4 staff, 100 clients, ~1000 invoices, appointments 7d back / 30d forward + * - uat: 8 staff, 500 clients, ~4000 invoices, appointments 30d back / 90d forward + * - demo: Same data volume as UAT (for production-like demo environments) + * + * Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility. + * + * SEED_KNOWN_USERS_ONLY=true: Minimal prod/demo seed with demo users only. * * 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 + * DATABASE_URL=postgres://... SEED_PROFILE=dev npx tsx packages/db/src/seed.ts */ import postgres from "postgres"; @@ -39,6 +40,50 @@ function createPrng(seed: number): () => number { const rand = createPrng(42); +// ── Seed profile configuration ─────────────────────────────────────────────── + +type SeedProfile = "dev" | "uat" | "demo"; + +interface ProfileConfig { + staff: { + manager: number; + receptionist: number; + groomer: number; + bather: number; + }; + clients: number; + appointments: { + daysBack: number; + daysForward: number; + }; + targetInvoices: number; +} + +function getProfileConfig(profile: SeedProfile | undefined): ProfileConfig { + const profiles: Record = { + dev: { + staff: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, + clients: 100, + appointments: { daysBack: 7, daysForward: 30 }, + targetInvoices: 1000, + }, + uat: { + staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, + clients: 500, + appointments: { daysBack: 30, daysForward: 90 }, + targetInvoices: 4000, + }, + demo: { + staff: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, + clients: 500, + appointments: { daysBack: 30, daysForward: 90 }, + targetInvoices: 4000, + }, + }; + if (!profile || profile === "uat") return profiles.uat; + return profiles[profile] ?? profiles.uat; +} + // ── Helpers ────────────────────────────────────────────────────────────────── /** Return a random element from an array using the seeded PRNG. */ @@ -320,6 +365,58 @@ async function seedKnownUsers() { } } + // ── 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})`); + } + } + // ── 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. @@ -421,33 +518,50 @@ async function seed() { return; } + const rawProfile = process.env.SEED_PROFILE?.toLowerCase(); + const profile: SeedProfile | undefined = (rawProfile === "dev" || rawProfile === "uat" || rawProfile === "demo") + ? rawProfile + : undefined; + const config = getProfileConfig(profile); + const client = postgres(url, { max: 5 }); const db = drizzle(client, { schema }); - console.log("Seeding Groom Book database...\n"); + const profileLabel = profile ? ` (${profile})` : ""; + console.log(`Seeding Groom Book database${profileLabel}...\n`); // ── Staff ── // Deterministic staff IDs so they can be referenced in scripts/tests - const managerStaff = [ - { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: false }, + const staffNames = [ + { name: "Jordan Lee", email: "jordan@groombook.dev" }, + { name: "Sam Rivera", email: "sam@groombook.dev" }, + { name: "Sarah Mitchell", email: "sarah@groombook.dev" }, + { name: "James Park", email: "james@groombook.dev" }, + { name: "Maria Gonzalez", email: "maria@groombook.dev" }, + { name: "Tyler Johnson", email: "tyler@groombook.dev" }, + { name: "Ashley Chen", email: "ashley@groombook.dev" }, + { name: "Devon Williams", email: "devon@groombook.dev" }, ]; - const receptionistStaff = [ - { id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false }, - ]; + const managerStaff = staffNames.slice(0, config.staff.manager).map( + (s) => ({ id: uuid(), name: s.name, email: s.email, role: "manager" as const, isSuperUser: false }), + ); - const groomers = [ - { id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false }, - ]; + const receptionistStaff = staffNames.slice(config.staff.manager, config.staff.manager + config.staff.receptionist).map( + (s) => ({ id: uuid(), name: s.name, email: s.email, role: "receptionist" as const, isSuperUser: false }), + ); + + const groomers = staffNames.slice(config.staff.manager + config.staff.receptionist, config.staff.manager + config.staff.receptionist + config.staff.groomer).map( + (s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }), + ); // 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, isSuperUser: false }, - { id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false }, - ]; + const bathers = staffNames.slice(config.staff.manager + config.staff.receptionist + config.staff.groomer, config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather).map( + (s) => ({ id: uuid(), name: s.name, email: s.email, role: "groomer" as const, isSuperUser: false }), + ); + + const totalStaff = config.staff.manager + config.staff.receptionist + config.staff.groomer + config.staff.bather; + console.log(`✓ Creating ${totalStaff} staff (${config.staff.manager} manager, ${config.staff.receptionist} receptionist, ${config.staff.groomer} groomers, ${config.staff.bather} bathers)`); // Truncate downstream tables before staff upsert — clears stale impersonation // sessions from prior seed runs so the FK constraint on staff_id is never @@ -471,7 +585,6 @@ async function seed() { set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true }, }); } - console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`); // ── SEED_ADMIN_EMAIL admin ── const adminEmail = process.env.SEED_ADMIN_EMAIL; @@ -519,8 +632,10 @@ async function seed() { // ── Clients & Pets ── const now = new Date(); - const oneYearAgo = new Date(now); - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + const appointmentsBack = new Date(now); + appointmentsBack.setDate(appointmentsBack.getDate() - config.appointments.daysBack); + const appointmentsForward = new Date(now); + appointmentsForward.setDate(appointmentsForward.getDate() + config.appointments.daysForward); interface ClientRecord { id: string; name: string } interface PetRecord { id: string; clientId: string } @@ -530,7 +645,7 @@ async function seed() { // Batch insert clients and pets const clientBatchSize = 50; - for (let batch = 0; batch < 500 / clientBatchSize; batch++) { + for (let batch = 0; batch < Math.ceil(config.clients / clientBatchSize); batch++) { const clientBatch: (typeof schema.clients.$inferInsert)[] = []; const petBatch: (typeof schema.pets.$inferInsert)[] = []; @@ -617,7 +732,7 @@ async function seed() { } } - console.log(`✓ Created 500 clients with ${petRecords.length} pets`); + console.log(`✓ Created ${config.clients} clients with ${petRecords.length} pets`); // ── UAT test clients (guaranteed pending invoices) ───────────────────────────── // These 5 clients are deterministic and documented in Shedward AGENTS.md so @@ -651,7 +766,7 @@ async function seed() { const apptId = uuid(); const svcIdx = 0; const svc = servicesDef[svcIdx]!; - const completedTime = randDate(oneYearAgo, now); + const completedTime = randDate(appointmentsBack, now); completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000); await db.insert(schema.appointments).values({ @@ -678,7 +793,13 @@ async function seed() { 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 + // Calculate visit count to achieve targetInvoices based on ~65% completion rate + const completedRatio = 0.65; + const totalVisitsNeeded = Math.ceil(config.targetInvoices / completedRatio); + const avgVisitsPerClient = Math.ceil(totalVisitsNeeded / clientRecords.length); + const visitCountMin = Math.max(1, Math.floor(avgVisitsPerClient * 0.7)); + const visitCountMax = Math.max(visitCountMin + 1, Math.ceil(avgVisitsPerClient * 1.3)); + const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [ "completed", "completed", "completed", "completed", "completed", "completed", "completed", "scheduled", "confirmed", "cancelled", "no_show", @@ -729,8 +850,7 @@ async function seed() { 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); + const visitCount = randInt(visitCountMin, visitCountMax); for (let v = 0; v < visitCount; v++) { // Pick a random pet for this visit @@ -739,15 +859,15 @@ async function seed() { const serviceId = serviceIds[serviceIdx]!; const svc = servicesDef[serviceIdx]!; const groomer = pick(groomers); - const bather = rand() < 0.6 ? pick(bathers) : null; + const bather = rand() < 0.6 && bathers.length > 0 ? pick(bathers) : null; const status = pick(statuses); - // Schedule within the past year, or next 2 weeks for upcoming + // Schedule within the configured appointment window let startTime: Date; if (status === "scheduled" || status === "confirmed") { - startTime = randDate(now, new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000)); + startTime = randDate(now, appointmentsForward); } else { - startTime = randDate(oneYearAgo, now); + startTime = randDate(appointmentsBack, now); } // Snap to business hours (8am - 5pm) startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);