Compare commits

..

4 Commits

Author SHA1 Message Date
Paperclip 39e72a1441 fix(gro-527): update infra submodule to SEED_PROFILE wiring
Updates infra submodule to e8bd354 which wires SEED_PROFILE env var
into seed-job patches for dev/uat/prod overlays.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 15:22:49 +00:00
Flea Flicker 16fb887bbf feat(GRO-537): add UAT Super User and Staff Groomer to seed script
In seedKnownUsers(), add staff records for UAT Super User
(manager, superuser) and UAT Staff Groomer (groomer) with oidcSub
read from SEED_UAT_SUPER_OIDC_SUB and SEED_UAT_STAFF_OIDC_SUB
env vars. Only creates records when the env vars are present.
Idempotent: skips if email already exists.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 15:16:01 +00:00
Pawla Abdul c01c8d93d7 docs(GRO-530): Add seed strategy runbook
Documents seed system across environments:
- Environment profiles table (dev/UAT/demo data volumes)
- Seed script env vars (SEED_PROFILE, SEED_KNOWN_USERS_ONLY, etc.)
- How to re-seed each environment (kubectl commands)
- Authentik UAT user personas (references sealed secrets)
- OOBE flag behavior
- Dev-mode access (AUTH_DISABLED, X-Dev-User-Id header)

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 01:26:57 +00:00
Pawla Abdul e8c81bfccd Parameterize seed script with SEED_PROFILE env var
Implements GRO-526: Add SEED_PROFILE env var accepting dev/uat/demo values.

- dev profile: 4 staff (1 manager, 1 receptionist, 2 groomers), 100 clients,
  ~1000 invoices, appointments 7d back / 30d forward
- uat profile: 8 staff (1 manager, 1 receptionist, 3 groomers, 3 bathers),
  500 clients, ~4000 invoices, appointments 30d back / 90d forward
- demo profile: Same data volume as UAT

Default (SEED_PROFILE unset): UAT-like behavior for backwards compatibility.
Existing SEED_KNOWN_USERS_ONLY=true path unchanged.

All appointment dates are computed relative to NOW() at seed time.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 01:21:58 +00:00
7 changed files with 244 additions and 333 deletions
-2
View File
@@ -62,8 +62,6 @@ jobs:
fi
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
+1 -6
View File
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
import { getAuth, initAuth } from "./lib/auth.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
@@ -92,11 +92,6 @@ app.get("/api/setup/status", async (c) => {
return c.json({ needsSetup: !superUser });
});
// Public auth providers endpoint — no auth required, tells frontend which login options are available
app.get("/api/auth/providers", async (c) => {
return c.json({ providers: getActiveProviders() });
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
+1 -35
View File
@@ -1,7 +1,6 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { google, github } from "better-auth/social-providers";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
@@ -28,21 +27,6 @@ export function getAuthPromise() {
return authInitPromise;
}
/** Returns which OAuth/social providers are configured via env vars. */
export function getActiveProviders(): string[] {
const providers: string[] = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push("google");
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
providers.push("github");
}
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
providers.push("authentik");
}
return providers;
}
/**
* Re-initializes the Better-Auth instance after auth config changes.
*
@@ -168,23 +152,6 @@ export async function initAuth(): Promise<void> {
console.log("[auth] Using env var config (no DB config found)");
}
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const socialPlugins = [];
if (hasGoogle) {
socialPlugins.push(google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}));
}
if (hasGitHub) {
socialPlugins.push(github({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}));
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
@@ -212,8 +179,7 @@ export async function initAuth(): Promise<void> {
},
],
}),
...socialPlugins,
],
],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
+19 -97
View File
@@ -22,24 +22,12 @@ import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]);
useEffect(() => {
fetch("/api/auth/providers")
.then((r) => r.json())
.then((data) => setProviders(data.providers ?? []))
.catch(() => setProviders([]));
}, []);
const handleSocialLogin = async (provider: string) => {
const handleLogin = async () => {
setIsLoading(true);
await signIn.social({ provider, callbackURL: window.location.origin });
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
};
const isGoogle = providers.includes("google");
const isGitHub = providers.includes("github");
const isAuthentik = providers.includes("authentik");
return (
<div
style={{
@@ -65,89 +53,23 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue
</p>
{isGoogle && (
<button
onClick={() => handleSocialLogin("google")}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#1a202c",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
marginBottom: "0.5rem",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</button>
)}
{isGitHub && (
<button
onClick={() => handleSocialLogin("github")}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#24292f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
marginBottom: isAuthentik ? "0.5rem" : 0,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Sign in with GitHub
</button>
)}
{isAuthentik && (
<button
onClick={() => handleSocialLogin("authentik")}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "none",
background: "#4f8a6f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
}}
>
{isLoading ? "Redirecting…" : "Sign in with SSO"}
</button>
)}
<button
onClick={handleLogin}
disabled={isLoading}
style={{
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "none",
background: "#4f8a6f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
}}
>
{isLoading ? "Redirecting…" : "Sign in with SSO"}
</button>
</div>
</div>
);
+93
View File
@@ -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: <staff-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
+1 -1
Submodule infra updated: aaedafcb9a...e8bd35499d
+129 -192
View File
@@ -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";
@@ -21,54 +22,6 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js";
// ── 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) ──────────────────────────────────────────
/**
@@ -87,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<SeedProfile, ProfileConfig> = {
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. */
@@ -515,32 +512,61 @@ async function seed() {
process.exit(1);
}
// Lean prod/demo seed — known users only, no large dataset
if (process.env.SEED_KNOWN_USERS_ONLY === "true") {
await seedKnownUsers();
return;
}
const profile = getProfile();
const cfg = profiles[profile];
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 (profile: ${profile})...\n`);
const profileLabel = profile ? ` (${profile})` : "";
console.log(`Seeding Groom Book database${profileLabel}...\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: false })
);
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 })
// Deterministic staff IDs so they can be referenced in scripts/tests
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 managerStaff = staffNames.slice(0, config.staff.manager).map(
(s) => ({ id: uuid(), name: s.name, email: s.email, role: "manager" 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 = 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
// violated when ON CONFLICT DO UPDATE touches staff rows that still have
// impersonation_sessions references.
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
@@ -559,10 +585,6 @@ async function seed() {
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;
@@ -610,10 +632,10 @@ async function seed() {
// ── 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);
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 }
@@ -621,8 +643,9 @@ async function seed() {
const clientRecords: ClientRecord[] = [];
const petRecords: PetRecord[] = [];
// Batch insert clients and pets
const clientBatchSize = 50;
for (let batch = 0; batch < Math.ceil(cfg.clientCount / 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)[] = [];
@@ -709,23 +732,22 @@ async function seed() {
}
}
console.log(`✓ Created ${cfg.clientCount} 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
// 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[] = [
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" },
@@ -744,14 +766,12 @@ async function seed() {
const apptId = uuid();
const svcIdx = 0;
const svc = servicesDef[svcIdx]!;
const completedTime = randDate(appointmentsBackDate, 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);
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,
id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: groomers[0]!.id,
batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price,
});
// Create a PENDING invoice for that appointment
const invoiceId = uuid();
@@ -769,12 +789,17 @@ async function seed() {
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`);
}
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",
@@ -825,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
@@ -835,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 configured appointment window
let startTime: Date;
if (status === "scheduled" || status === "confirmed") {
startTime = randDate(now, appointmentsForwardDate);
startTime = randDate(now, appointmentsForward);
} else {
startTime = randDate(appointmentsBackDate, now);
startTime = randDate(appointmentsBack, now);
}
// Snap to business hours (8am - 5pm)
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
@@ -947,93 +971,6 @@ async function seed() {
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);
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, 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!");