From e37316cef882a1db1fddf3fbe6117ecaf3b84362 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:12:07 +0000 Subject: [PATCH 1/2] fix(db): use deterministic IDs + ON CONFLICT DO UPDATE for services seed seedKnownUsers() inserted demo services with random UUIDs and no idempotency guard, causing duplicates when the seed ran alongside or after the main seed (which uses deterministic IDs + ON CONFLICT DO UPDATE on id). The admin seed API had the same issue. Now both paths use deterministic UUIDs with ON CONFLICT DO UPDATE on id, making the upsert idempotent regardless of seed order or repetition. Same pattern already used for clients (40143c4). Co-Authored-By: Paperclip --- apps/api/src/routes/admin/seed.ts | 28 +++++++++++++--------------- packages/db/src/seed.ts | 30 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/api/src/routes/admin/seed.ts b/apps/api/src/routes/admin/seed.ts index 58fc0db..d755f02 100644 --- a/apps/api/src/routes/admin/seed.ts +++ b/apps/api/src/routes/admin/seed.ts @@ -37,10 +37,10 @@ const DEMO_PET = { }; 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 }, + { id: "a0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { id: "a0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { id: "a0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { id: "a0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, ]; adminSeedRouter.post("/seed", async (c) => { @@ -71,18 +71,16 @@ adminSeedRouter.post("/seed", async (c) => { 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(", ")}`); + // ── Services: idempotent upsert ───────────────────────────────────────────── + for (const svc of DEMO_SERVICES) { + await db.insert(services) + .values({ ...svc, active: true }) + .onConflictDoUpdate({ + target: services.id, + set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, + }); } + results.push(`Upserted ${DEMO_SERVICES.length} services`); // ── Client: Demo Client ─────────────────────────────────────────────────── const [existingClient] = await db diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 9000d99..2f01a3b 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -293,22 +293,22 @@ async function seedKnownUsers() { 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`); + // ── Services: idempotent upsert using deterministic IDs ── + const demoSvcs = [ + { id: "a0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { id: "a0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { id: "a0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { id: "a0000001-0000-0000-0000-000000000004", 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 }) + .onConflictDoUpdate({ + target: schema.services.id, + set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, + }); } + console.log(`✓ Seeded ${demoSvcs.length} services`); // ── Client: Demo Client ── const [existingClient] = await db -- 2.52.0 From 89641d3c235ccc725271a63090d9c3e32f05e96b Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:22:23 +0000 Subject: [PATCH 2/2] fix(api): use UTC methods in defaultFrom/defaultTo date helpers The server-side date fallback helpers used local-timezone JS Date methods (setDate/setHours), creating a mismatch when clients send explicit UTC dates (e.g. 2026-02-28T00:00:00Z). Changed to UTC methods (setUTCDate/setUTCHours) to ensure consistent UTC behavior. Also fixed the 90-day churn-risk lookback to use UTC consistently. Co-Authored-By: Paperclip --- apps/api/src/routes/reports.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 8be162b..3849d4c 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -31,14 +31,14 @@ function parseDate(value: string | undefined, fallback: Date): Date { function defaultFrom(): Date { const d = new Date(); - d.setDate(d.getDate() - 30); - d.setHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - 30); + d.setUTCHours(0, 0, 0, 0); return d; } function defaultTo(): Date { const d = new Date(); - d.setHours(23, 59, 59, 999); + d.setUTCHours(23, 59, 59, 999); return d; } @@ -283,7 +283,7 @@ reportsRouter.get("/clients", async (c) => { // Clients with no appointment in last 90 days (churn risk) const ninetyDaysAgo = new Date(); - ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); const churnRisk = await db -- 2.52.0