Merge branch 'main' into feat/gro-105-waitlist
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user