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
3 changed files with 251 additions and 38 deletions
+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: d43a016f3f...e8bd35499d
+157 -37
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";
@@ -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<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. */
@@ -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);