diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 725ea20..54c18ea 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; +import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; const app = new Hono(); @@ -121,6 +122,7 @@ api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); +api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); diff --git a/apps/api/src/routes/admin/seed.ts b/apps/api/src/routes/admin/seed.ts new file mode 100644 index 0000000..58fc0db --- /dev/null +++ b/apps/api/src/routes/admin/seed.ts @@ -0,0 +1,138 @@ +/** + * Admin seed endpoint — populates minimal known-user seed data via the API. + * + * This is the canonical way to seed prod/demo data. The old approach (seed.ts + * writing directly to the DB) bypasses API validation and audit trails. + * + * Security: This endpoint is manager-only (enforced via requireRole in index.ts). + * It is disabled when AUTH_DISABLED=true — dev/test seeding should use the + * direct-DB seed.ts in that mode. + */ + +import { Hono } from "hono"; +import { eq, getDb, staff, clients, pets, services } from "@groombook/db"; + +export const adminSeedRouter = new Hono(); + +const KNOWN_STAFF = { + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager" as const, + active: true, +}; + +const KNOWN_CLIENT = { + name: "Demo Client", + email: "demo-client@example.com", + phone: "555-0001", + address: "1 Demo Street, Demo City, CA 90210", +}; + +const DEMO_PET = { + name: "Demo Dog", + species: "Dog", + breed: "Golden Retriever", + weightKg: "30.00", +}; + +const DEMO_SERVICES = [ + { name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, +]; + +adminSeedRouter.post("/seed", async (c) => { + // Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding + if (process.env.AUTH_DISABLED === "true") { + return c.json( + { + error: + "Seed endpoint is not available when AUTH_DISABLED=true. Use direct DB seeding for dev/test environments.", + }, + 403 + ); + } + + const db = getDb(); + const results: string[] = []; + + // ── Staff: Demo Manager ───────────────────────────────────────────────────── + const [existingStaff] = await db + .select() + .from(staff) + .where(eq(staff.email, KNOWN_STAFF.email)); + + if (existingStaff) { + results.push(`Staff '${KNOWN_STAFF.name}' already exists (id: ${existingStaff.id})`); + } else { + const [created] = await db.insert(staff).values(KNOWN_STAFF).returning(); + results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`); + } + + // ── Services: only seed if none exist ───────────────────────────────────── + const existingServices = await db.select().from(services).limit(1); + if (existingServices.length > 0) { + results.push("Services already exist — skipping"); + } else { + const created: { id: string; name: string }[] = []; + for (const svc of DEMO_SERVICES) { + const [row] = await db.insert(services).values({ ...svc, active: true }).returning(); + created.push(row!); + } + results.push(`Created ${created.length} services: ${created.map((s) => s.name).join(", ")}`); + } + + // ── Client: Demo Client ─────────────────────────────────────────────────── + const [existingClient] = await db + .select() + .from(clients) + .where(eq(clients.email, KNOWN_CLIENT.email)); + + let clientId: string; + if (existingClient) { + clientId = existingClient.id; + results.push(`Client '${KNOWN_CLIENT.name}' already exists (id: ${clientId})`); + } else { + const [created] = await db.insert(clients).values(KNOWN_CLIENT).returning(); + clientId = created!.id; + results.push(`Created client '${KNOWN_CLIENT.name}' (id: ${clientId})`); + } + + // ── Pet: Demo Dog ────────────────────────────────────────────────────────── + const existingPets = await db + .select() + .from(pets) + .where(eq(pets.clientId, clientId)); + + const demoDog = existingPets.find( + (p) => p.name === DEMO_PET.name && p.species === DEMO_PET.species + ); + + if (demoDog) { + results.push(`Pet '${DEMO_PET.name}' already exists for Demo Client (id: ${demoDog.id})`); + } else { + const [created] = await db + .insert(pets) + .values({ + clientId, + name: DEMO_PET.name, + species: DEMO_PET.species, + breed: DEMO_PET.breed, + weightKg: DEMO_PET.weightKg, + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + }) + .returning(); + results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`); + } + + return c.json({ + message: "Seed complete", + details: results, + credentials: { + note: "For dev-mode access, use X-Dev-User-Id: demo-manager-001 header", + staffOidcSub: KNOWN_STAFF.oidcSub, + }, + }); +}); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 55ea5c6..cd68a31 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -18,6 +18,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; +import { eq } from "drizzle-orm"; import * as schema from "./schema.js"; // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── @@ -247,6 +248,119 @@ const servicesDef = [ { name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, ]; +// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── + +/** + * Seeds only the minimal known users for prod/demo environments. + * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. + * Idempotent: skips creation if records already exist. + */ +async function seedKnownUsers() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + const client = postgres(url, { max: 5 }); + const db = drizzle(client, { schema }); + + console.log("Seeding known users (prod/demo mode)...\n"); + + const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; + const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; + + // ── Staff: Demo Manager ── + const [existingStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "demo-manager@groombook.dev")) + .limit(1); + + if (existingStaff) { + console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: KNOWN_STAFF_ID, + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager", + active: true, + }); + console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); + } + + // ── Services: only seed if none exist ── + const existingServices = await db.select().from(schema.services).limit(1); + if (existingServices.length > 0) { + console.log("✓ Services already exist — skipping"); + } else { + const demoSvcs = [ + { name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, + ]; + for (const svc of demoSvcs) { + await db.insert(schema.services).values({ ...svc, active: true }); + } + console.log(`✓ Created ${demoSvcs.length} services`); + } + + // ── Client: Demo Client ── + const [existingClient] = await db + .select() + .from(schema.clients) + .where(eq(schema.clients.email, "demo-client@example.com")) + .limit(1); + + let clientId: string; + if (existingClient) { + clientId = existingClient.id; + console.log(`✓ Client '${existingClient.name}' already exists — skipping`); + } else { + const [created] = await db + .insert(schema.clients) + .values({ + id: DEMO_CLIENT_ID, + name: "Demo Client", + email: "demo-client@example.com", + phone: "555-0001", + address: "1 Demo Street, Demo City, CA 90210", + }) + .returning(); + clientId = created!.id; + console.log("✓ Created client 'Demo Client'"); + } + + // ── Pet: Demo Dog ── + const [existingPet] = await db + .select() + .from(schema.pets) + .where(eq(schema.pets.id, DEMO_PET_ID)) + .limit(1); + + if (existingPet) { + console.log(`✓ Pet '${existingPet.name}' already exists — skipping`); + } else { + await db.insert(schema.pets).values({ + id: DEMO_PET_ID, + clientId, + name: "Demo Dog", + species: "Dog", + breed: "Golden Retriever", + weightKg: "30.00", + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + }); + console.log("✓ Created pet 'Demo Dog'"); + } + + console.log("\nKnown-users seed complete!"); + await client.end(); +} + // ── Main seed ──────────────────────────────────────────────────────────────── async function seed() { @@ -256,6 +370,12 @@ 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 client = postgres(url, { max: 5 }); const db = drizzle(client, { schema });