diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd48fc6..08f9243 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,17 +309,39 @@ jobs: - name: Update dev overlay image tags env: TAG: ${{ needs.docker.outputs.tag }} + SHA: ${{ github.sha }} run: | if [ -z "$TAG" ]; then TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}" fi + SHORT_SHA="${SHA::7}" echo "Updating dev overlay image tags to: $TAG" + echo "Updating migration/seed Job names with SHA: $SHORT_SHA" cd /tmp/infra DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml" yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST" + + # Update migrate Job name to include short SHA (immutable template fix) + MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + if [ -f "$MIGRATE_JOB" ]; then + yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" + # Ensure ttlSecondsAfterFinished is set for automatic cleanup + yq -i '.spec.ttlSecondsAfterFinished //= 86400' "$MIGRATE_JOB" + fi + + # Update seed Job name to include short SHA (immutable template fix) + SEED_JOB="apps/groombook/base/seed-job.yaml" + if [ -f "$SEED_JOB" ]; then + yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + # Ensure ttlSecondsAfterFinished is set for automatic cleanup + yq -i '.spec.ttlSecondsAfterFinished //= 86400' "$SEED_JOB" + fi + git -C /tmp/infra diff --stat - name: Create PR on groombook/infra @@ -335,8 +357,8 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "chore/update-image-tags-${TAG}" - git add apps/groombook/overlays/dev/ - git commit -m "chore: update image tags to ${TAG}" + git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml + git commit -m "chore: update image tags and migration/seed Job names to ${TAG}" git push -u origin "chore/update-image-tags-${TAG}" 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/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 diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index b1ba8cb..2cb6ffb 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