Merge pull request #92 from groombook/feat/dev-data-strategy-gro-110
feat: deterministic seed, impersonation migration, test factories (GRO-110)
This commit was merged in pull request #92.
This commit is contained in:
@@ -2,22 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { JwtPayload } from "../middleware/auth.js";
|
import type { JwtPayload } from "../middleware/auth.js";
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||||
|
import { buildStaff } from "@groombook/db/factories";
|
||||||
|
|
||||||
// ─── Mock data ───────────────────────────────────────────────────────────────
|
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
|
||||||
|
|
||||||
const MANAGER_STAFF = {
|
const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" });
|
||||||
id: "staff-manager-id",
|
const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" });
|
||||||
oidcSub: "oidc-manager-sub",
|
|
||||||
role: "manager",
|
|
||||||
name: "Manager",
|
|
||||||
};
|
|
||||||
|
|
||||||
const GROOMER_STAFF = {
|
|
||||||
id: "staff-groomer-id",
|
|
||||||
oidcSub: "oidc-groomer-sub",
|
|
||||||
role: "groomer",
|
|
||||||
name: "Groomer",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
|
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
"@groombook/db/factories": path.resolve(__dirname, "../../packages/db/src/factories.ts"),
|
||||||
"@groombook/db": path.resolve(__dirname, "../../packages/db/src/index.ts"),
|
"@groombook/db": path.resolve(__dirname, "../../packages/db/src/index.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck",
|
||||||
"test": "pnpm -r --filter='!@groombook/e2e' test",
|
"test": "pnpm -r --filter='!@groombook/e2e' test",
|
||||||
"db:migrate": "pnpm --filter @groombook/db migrate",
|
"db:migrate": "pnpm --filter @groombook/db migrate",
|
||||||
"db:seed": "pnpm --filter @groombook/db seed"
|
"db:seed": "pnpm --filter @groombook/db seed",
|
||||||
|
"db:reset": "pnpm --filter @groombook/db reset"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20",
|
"node": ">=20",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Create impersonation_session_status enum and tables
|
||||||
|
CREATE TYPE "impersonation_session_status" AS ENUM ('active', 'ended', 'expired');
|
||||||
|
|
||||||
|
CREATE TABLE "impersonation_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"staff_id" uuid NOT NULL,
|
||||||
|
"client_id" uuid NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"status" "impersonation_session_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"ended_at" timestamp,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "staff"("id") ON DELETE restrict,
|
||||||
|
CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE restrict
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "impersonation_audit_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_id" uuid NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"page_visited" text,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "impersonation_sessions"("id") ON DELETE cascade
|
||||||
|
);
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
"when": 1773993600000,
|
"when": 1773993600000,
|
||||||
"tag": "0009_client_soft_delete",
|
"tag": "0009_client_soft_delete",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1742500800000,
|
||||||
|
"tag": "0010_impersonation_sessions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
".": {
|
".": {
|
||||||
"default": "./dist/index.js",
|
"default": "./dist/index.js",
|
||||||
"types": "./src/index.ts"
|
"types": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./factories": {
|
||||||
|
"default": "./src/factories.ts",
|
||||||
|
"types": "./src/factories.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"migrate": "drizzle-kit migrate",
|
"migrate": "drizzle-kit migrate",
|
||||||
"seed": "tsx src/seed.ts",
|
"seed": "tsx src/seed.ts",
|
||||||
|
"reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Test factories — build typed in-memory entities for unit tests.
|
||||||
|
*
|
||||||
|
* Each factory returns a fully-populated object with valid defaults.
|
||||||
|
* Pass an overrides object to customise specific fields.
|
||||||
|
*
|
||||||
|
* IDs are generated with a deterministic counter so tests produce stable,
|
||||||
|
* readable values (e.g. "staff-1", "client-2") without needing crypto.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { buildStaff, buildClient, buildPet } from "@groombook/db/factories";
|
||||||
|
*
|
||||||
|
* const manager = buildStaff({ role: "manager" });
|
||||||
|
* const client = buildClient({ name: "Alice Smith" });
|
||||||
|
* const pet = buildPet({ clientId: client.id });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { staff, clients, pets, services, appointments } from "./schema.js";
|
||||||
|
|
||||||
|
// ── Counter-based ID factory ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const counters: Record<string, number> = {};
|
||||||
|
|
||||||
|
function nextId(prefix: string): string {
|
||||||
|
counters[prefix] = (counters[prefix] ?? 0) + 1;
|
||||||
|
return `${prefix}-${counters[prefix]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all counters. Call in beforeEach() to keep tests independent. */
|
||||||
|
export function resetFactoryCounters(): void {
|
||||||
|
for (const key of Object.keys(counters)) {
|
||||||
|
delete counters[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Type aliases ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
|
export type ClientRow = typeof clients.$inferSelect;
|
||||||
|
export type PetRow = typeof pets.$inferSelect;
|
||||||
|
export type ServiceRow = typeof services.$inferSelect;
|
||||||
|
export type AppointmentRow = typeof appointments.$inferSelect;
|
||||||
|
|
||||||
|
// ── Factories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
|
||||||
|
const id = nextId("staff");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Staff Member ${id}`,
|
||||||
|
email: `${id}@groombook.test`,
|
||||||
|
oidcSub: `oidc-${id}`,
|
||||||
|
role: "groomer",
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||||
|
const id = nextId("client");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Client ${id}`,
|
||||||
|
email: `${id}@example.com`,
|
||||||
|
phone: "555-0100",
|
||||||
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
|
notes: null,
|
||||||
|
emailOptOut: false,
|
||||||
|
status: "active",
|
||||||
|
disabledAt: null,
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPet(overrides: Partial<PetRow> & { clientId: string }): PetRow {
|
||||||
|
const id = nextId("pet");
|
||||||
|
const defaults: PetRow = {
|
||||||
|
id,
|
||||||
|
clientId: overrides.clientId,
|
||||||
|
name: `Pet ${id}`,
|
||||||
|
species: "Dog",
|
||||||
|
breed: "Mixed Breed",
|
||||||
|
weightKg: "15.00",
|
||||||
|
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
|
||||||
|
healthAlerts: null,
|
||||||
|
groomingNotes: null,
|
||||||
|
cutStyle: null,
|
||||||
|
shampooPreference: null,
|
||||||
|
specialCareNotes: null,
|
||||||
|
customFields: {},
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
return { ...defaults, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
|
||||||
|
const id = nextId("service");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Service ${id}`,
|
||||||
|
description: "A grooming service",
|
||||||
|
basePriceCents: 6500,
|
||||||
|
durationMinutes: 60,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAppointment(
|
||||||
|
overrides: Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }
|
||||||
|
): AppointmentRow {
|
||||||
|
const id = nextId("appointment");
|
||||||
|
const startTime = new Date("2025-06-01T10:00:00Z");
|
||||||
|
const endTime = new Date("2025-06-01T11:00:00Z");
|
||||||
|
const defaults: AppointmentRow = {
|
||||||
|
id,
|
||||||
|
clientId: overrides.clientId,
|
||||||
|
petId: overrides.petId,
|
||||||
|
serviceId: overrides.serviceId,
|
||||||
|
staffId: overrides.staffId,
|
||||||
|
batherStaffId: null,
|
||||||
|
seriesId: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
groupId: null,
|
||||||
|
status: "scheduled",
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
notes: null,
|
||||||
|
priceCents: null,
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
return { ...defaults, ...overrides };
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||||
|
*
|
||||||
|
* Intended for local development only. Never run against production.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("DATABASE_URL is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
console.error("[FATAL] db:reset must not be run in production.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = postgres(url, { max: 1 });
|
||||||
|
|
||||||
|
console.log("Dropping all application tables...\n");
|
||||||
|
|
||||||
|
// Drop in dependency order (children before parents)
|
||||||
|
await client`
|
||||||
|
DO $$ DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN (
|
||||||
|
SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Drop custom enums
|
||||||
|
await client`
|
||||||
|
DO $$ DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN (
|
||||||
|
SELECT typname FROM pg_type
|
||||||
|
WHERE typtype = 'e' AND typnamespace = (
|
||||||
|
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
||||||
|
)
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Drop the drizzle migrations tracking table
|
||||||
|
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
||||||
|
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
||||||
|
|
||||||
|
console.log("✓ All tables and enums dropped\n");
|
||||||
|
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset().catch((err) => {
|
||||||
|
console.error("Reset failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+71
-27
@@ -1,14 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Seed script — generates realistic test data for Groom Book development.
|
* Seed script — generates deterministic, PII-free test data for Groom Book.
|
||||||
*
|
*
|
||||||
* Creates:
|
* Creates:
|
||||||
* - 3 groomers + 3 bathers (staff)
|
* - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total)
|
||||||
* - 10 services
|
* - 10 services
|
||||||
* - 500 clients, each with 1-3 dogs
|
* - 500 clients, each with 1-3 dogs
|
||||||
* - ~2 500 appointments spread across the past 12 months
|
* - ~2 500 appointments spread across the past 12 months
|
||||||
* - Invoices for completed appointments with line items and tip splits
|
* - Invoices for completed appointments with line items and tip splits
|
||||||
* - Grooming visit logs for completed appointments
|
* - Grooming visit logs for completed appointments
|
||||||
*
|
*
|
||||||
|
* Output is fully deterministic: the same seed value always produces the
|
||||||
|
* same rows with the same IDs.
|
||||||
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
|
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
|
||||||
*/
|
*/
|
||||||
@@ -17,29 +20,61 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||||
|
|
||||||
function pick<T>(arr: T[]): T {
|
/**
|
||||||
return arr[Math.floor(Math.random() * arr.length)]!;
|
* 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[] {
|
function pickN<T>(arr: T[], n: number): T[] {
|
||||||
const shuffled = [...arr].sort(() => Math.random() - 0.5);
|
const shuffled = [...arr].sort(() => rand() - 0.5);
|
||||||
return shuffled.slice(0, n);
|
return shuffled.slice(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
function randInt(min: number, max: number): number {
|
function randInt(min: number, max: number): number {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(rand() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
|
|
||||||
function randDate(start: Date, end: Date): Date {
|
function randDate(start: Date, end: Date): Date {
|
||||||
return new Date(
|
return new Date(start.getTime() + rand() * (end.getTime() - start.getTime()));
|
||||||
start.getTime() + Math.random() * (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 {
|
function uuid(): string {
|
||||||
return crypto.randomUUID();
|
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 ───────────────────────────────────────────────────────────────
|
// ── Data pools ───────────────────────────────────────────────────────────────
|
||||||
@@ -227,6 +262,15 @@ async function seed() {
|
|||||||
console.log("Seeding Groom Book database...\n");
|
console.log("Seeding Groom Book database...\n");
|
||||||
|
|
||||||
// ── Staff ──
|
// ── 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const receptionistStaff = [
|
||||||
|
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const },
|
||||||
|
];
|
||||||
|
|
||||||
const groomers = [
|
const groomers = [
|
||||||
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
|
{ 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: "James Park", email: "james@groombook.dev", role: "groomer" as const },
|
||||||
@@ -240,7 +284,7 @@ async function seed() {
|
|||||||
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
|
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const allStaff = [...groomers, ...bathers];
|
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||||||
for (const s of allStaff) {
|
for (const s of allStaff) {
|
||||||
await db.insert(schema.staff).values({
|
await db.insert(schema.staff).values({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -250,7 +294,7 @@ async function seed() {
|
|||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`✓ Created ${allStaff.length} staff (3 groomers, 3 bathers)`);
|
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
||||||
|
|
||||||
// ── Services ──
|
// ── Services ──
|
||||||
const serviceIds: string[] = [];
|
const serviceIds: string[] = [];
|
||||||
@@ -274,7 +318,7 @@ async function seed() {
|
|||||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||||
|
|
||||||
interface ClientRecord { id: string; name: string }
|
interface ClientRecord { id: string; name: string }
|
||||||
interface PetRecord { id: string; clientId: string; name: string }
|
interface PetRecord { id: string; clientId: string }
|
||||||
|
|
||||||
const clientRecords: ClientRecord[] = [];
|
const clientRecords: ClientRecord[] = [];
|
||||||
const petRecords: PetRecord[] = [];
|
const petRecords: PetRecord[] = [];
|
||||||
@@ -301,14 +345,14 @@ async function seed() {
|
|||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
address: addr,
|
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,
|
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: Math.random() < 0.1,
|
emailOptOut: rand() < 0.1,
|
||||||
});
|
});
|
||||||
|
|
||||||
clientRecords.push({ id: clientId, name });
|
clientRecords.push({ id: clientId, name });
|
||||||
|
|
||||||
// 1-3 pets per client
|
// 1-3 pets per client
|
||||||
const petCount = Math.random() < 0.5 ? 1 : Math.random() < 0.7 ? 2 : 3;
|
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
||||||
for (let p = 0; p < petCount; p++) {
|
for (let p = 0; p < petCount; p++) {
|
||||||
const petId = uuid();
|
const petId = uuid();
|
||||||
const breed = pick(dogBreeds);
|
const breed = pick(dogBreeds);
|
||||||
@@ -322,17 +366,17 @@ async function seed() {
|
|||||||
name: pick(dogNames),
|
name: pick(dogNames),
|
||||||
species: "Dog",
|
species: "Dog",
|
||||||
breed,
|
breed,
|
||||||
weightKg: String(randInt(3, 60) + Math.random().toFixed(1).slice(1)),
|
weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)),
|
||||||
dateOfBirth: dob,
|
dateOfBirth: dob,
|
||||||
healthAlerts: pick(healthAlerts),
|
healthAlerts: pick(healthAlerts),
|
||||||
groomingNotes: pick(groomingNotes),
|
groomingNotes: pick(groomingNotes),
|
||||||
cutStyle: pick(cutStyles),
|
cutStyle: pick(cutStyles),
|
||||||
shampooPreference: pick(shampoos),
|
shampooPreference: pick(shampoos),
|
||||||
specialCareNotes: Math.random() < 0.1 ? "Vet clearance required before grooming" : null,
|
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||||
customFields: {},
|
customFields: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
petRecords.push({ id: petId, clientId, name: "" });
|
petRecords.push({ id: petId, clientId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,13 +431,13 @@ async function seed() {
|
|||||||
// Group pets by client for efficient appointment generation
|
// Group pets by client for efficient appointment generation
|
||||||
const petsByClient = new Map<string, string[]>();
|
const petsByClient = new Map<string, string[]>();
|
||||||
for (const pet of petRecords) {
|
for (const pet of petRecords) {
|
||||||
const arr = petsByClient.get(pet.clientId) || [];
|
const arr = petsByClient.get(pet.clientId) ?? [];
|
||||||
arr.push(pet.id);
|
arr.push(pet.id);
|
||||||
petsByClient.set(pet.clientId, arr);
|
petsByClient.set(pet.clientId, arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const client of clientRecords) {
|
for (const client of clientRecords) {
|
||||||
const pets = petsByClient.get(client.id) || [];
|
const pets = petsByClient.get(client.id) ?? [];
|
||||||
// Each client visits ~3-8 times over the year
|
// Each client visits ~3-8 times over the year
|
||||||
const visitCount = randInt(3, 8);
|
const visitCount = randInt(3, 8);
|
||||||
|
|
||||||
@@ -404,7 +448,7 @@ async function seed() {
|
|||||||
const serviceId = serviceIds[serviceIdx]!;
|
const serviceId = serviceIds[serviceIdx]!;
|
||||||
const svc = servicesDef[serviceIdx]!;
|
const svc = servicesDef[serviceIdx]!;
|
||||||
const groomer = pick(groomers);
|
const groomer = pick(groomers);
|
||||||
const bather = Math.random() < 0.6 ? pick(bathers) : null;
|
const bather = rand() < 0.6 ? pick(bathers) : null;
|
||||||
const status = pick(statuses);
|
const status = pick(statuses);
|
||||||
|
|
||||||
// Schedule within the past year, or next 2 weeks for upcoming
|
// Schedule within the past year, or next 2 weeks for upcoming
|
||||||
@@ -419,7 +463,7 @@ async function seed() {
|
|||||||
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
||||||
|
|
||||||
const apptId = uuid();
|
const apptId = uuid();
|
||||||
const priceCents = Math.random() < 0.2 ? svc.price + randInt(-500, 1000) : null;
|
const priceCents = rand() < 0.2 ? svc.price + randInt(-500, 1000) : null;
|
||||||
const effectivePrice = priceCents ?? svc.price;
|
const effectivePrice = priceCents ?? svc.price;
|
||||||
|
|
||||||
apptBatch.push({
|
apptBatch.push({
|
||||||
@@ -440,11 +484,11 @@ async function seed() {
|
|||||||
// Create invoice for completed appointments
|
// Create invoice for completed appointments
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
const invoiceId = uuid();
|
const invoiceId = uuid();
|
||||||
const tipCents = Math.random() < 0.7 ? randInt(200, 3000) : 0;
|
const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0;
|
||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
|
|
||||||
const invoiceStatus = Math.random() < 0.95 ? "paid" as const : "pending" as const;
|
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 paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
@@ -458,7 +502,7 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
notes: Math.random() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Line item
|
// Line item
|
||||||
|
|||||||
Reference in New Issue
Block a user