From 853c55fd042330f00c2e0fe57557bac5bef3dbee Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 14:50:50 +0000 Subject: [PATCH 1/5] fix(staff): count only active super users in last-super-user guardrail Add active=true filter to all 3 superUserCount queries in staff.ts (revoke, deactivate, delete) so inactive super users aren't counted, preventing false positives when checking the last-super-user guardrail. Co-Authored-By: Paperclip --- apps/api/src/routes/staff.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/staff.ts b/apps/api/src/routes/staff.ts index 7a4a8b5..bf5c8c2 100644 --- a/apps/api/src/routes/staff.ts +++ b/apps/api/src/routes/staff.ts @@ -65,7 +65,7 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { const superUserCount = await db .select({ id: staff.id }) .from(staff) - .where(eq(staff.isSuperUser, true)) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) .limit(2); // just need count; fetch 2 to know if > 1 if (superUserCount.length <= 1) { return c.json( @@ -86,7 +86,7 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { const superUserCount = await db .select({ id: staff.id }) .from(staff) - .where(eq(staff.isSuperUser, true)) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) .limit(2); if (superUserCount.length <= 1) { return c.json( @@ -142,7 +142,7 @@ staffRouter.delete("/:id", async (c) => { const superUserCount = await db .select({ id: staff.id }) .from(staff) - .where(eq(staff.isSuperUser, true)) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) .limit(2); if (superUserCount.length <= 1) { return c.json( From 51431c7bc1d3d7a14568bd10d4c5842f72e9a171 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:07:49 +0000 Subject: [PATCH 2/5] fix(portal): wire dev client login to portal session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client user selects their account from the dev login selector, the portal previously had no way to establish an authenticated session — it only checked for a ?sessionId= URL param (used by the real staff impersonation flow). This caused the portal to always show "Hi, Guest". Changes: - POST /api/portal/dev-session: new endpoint (auth-disabled only) that creates an impersonation session for a given clientId, using a fixed dev staff ID to avoid conflicts with the one-active-session-per-staff rule in the real impersonation flow. Sessions are long-lived (24h). - CustomerPortal: on mount, after checking for ?sessionId=, also check for a dev client user in localStorage and call /api/portal/dev-session to obtain a session. This mirrors the real impersonation flow so all existing portal API calls (which require X-Impersonation-Session-Id) work without modification. cc @cpfarhood Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 50 +++++++++++++++++++- apps/web/src/portal/CustomerPortal.tsx | 65 +++++++++++++++++--------- 2 files changed, 93 insertions(+), 22 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 135e129..4d0b704 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -446,4 +446,52 @@ portalRouter.delete("/waitlist/:id", async (c) => { .returning(); return c.json({ ok: true }); -}); \ No newline at end of file +}); + +// ─── Dev-mode session creation ────────────────────────────────────────────── +// Allows the dev login selector to vend an impersonation session for a client +// without requiring manager auth. Only available when AUTH_DISABLED=true. + +const devSessionSchema = z.object({ + clientId: z.string().uuid(), +}); + +portalRouter.post( + "/dev-session", + zValidator("json", devSessionSchema), + async (c) => { + if (process.env.AUTH_DISABLED !== "true") { + return c.json({ error: "Not available when auth is enabled" }, 403); + } + + const db = getDb(); + const body = c.req.valid("json"); + + // Verify client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, body.clientId)) + .limit(1); + if (!client) { + return c.json({ error: "Client not found" }, 404); + } + + // Create a long-lived impersonation session for the dev client. + // Use a fixed "dev-staff" staffId so multiple dev sessions don't conflict + // with the one-active-session-per-staff rule in the real impersonation flow. + const DEV_STAFF_ID = "00000000-0000-0000-0000-000000000000"; + + const [session] = await db + .insert(impersonationSessions) + .values({ + staffId: DEV_STAFF_ID, + clientId: body.clientId, + reason: "dev-mode-client-portal", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + }) + .returning(); + + return c.json(session, 201); + } +); \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 2a5e8e1..80d4e5b 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -14,6 +14,7 @@ import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; import { useBranding } from "../BrandingContext.js"; +import { getDevUser } from "../pages/DevLoginSelector.js"; import type { ImpersonationSession } from "@groombook/types"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; @@ -40,35 +41,57 @@ export function CustomerPortal() { const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); - // On mount: load session from ?sessionId= URL param + // On mount: load session from ?sessionId= URL param OR from dev user in localStorage const initDone = useRef(false); useEffect(() => { if (initDone.current) return; initDone.current = true; const sessionId = searchParams.get("sessionId"); - if (!sessionId) return; - fetch(`/api/impersonation/sessions/${sessionId}`) - .then((r) => { - if (!r.ok) return null; - return r.json() as Promise; + if (sessionId) { + // Real impersonation session from URL param + fetch(`/api/impersonation/sessions/${sessionId}`) + .then((r) => { + if (!r.ok) return null; + return r.json() as Promise; + }) + .then((s) => { + if (s && s.status === "active") { + setSession(s); + fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data?.name) setClientName(data.name); }) + .catch(() => {}); + } + setSearchParams({}, { replace: true }); + }) + .catch(() => { + setSearchParams({}, { replace: true }); + }); + return; + } + + // Dev mode: check for dev user in localStorage and create a dev session + const devUser = getDevUser(); + if (devUser && devUser.type === "client") { + fetch("/api/portal/dev-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: devUser.id }), }) - .then((s) => { - if (s && s.status === "active") { - setSession(s); - // Fetch client name for display - fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) - .then(r => r.ok ? r.json() : null) - .then(data => { if (data?.name) setClientName(data.name); }) - .catch(() => {}); - } - // Clean sessionId from URL - setSearchParams({}, { replace: true }); - }) - .catch(() => { - setSearchParams({}, { replace: true }); - }); + .then((r) => { + if (!r.ok) return null; + return r.json() as Promise; + }) + .then((s) => { + if (s && s.id) { + setSession(s); + setClientName(devUser.name); + } + }) + .catch(() => {}); + } }, []); const handleEnd = useCallback(async () => { From 08e2f8c1ab3352d51ddd406e9b1fa6338d20621f Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:10:58 +0000 Subject: [PATCH 3/5] fix(web): add missing PWA icon and favicon assets Adds pwa-192x192.png, pwa-512x512.png, and favicon.svg to the web public directory. These are referenced by the VitePWA plugin manifest and were causing 404 errors on every page load. cc @cpfarhood Co-Authored-By: Paperclip --- apps/web/public/favicon.svg | 4 ++++ apps/web/public/pwa-192x192.png | Bin 0 -> 547 bytes apps/web/public/pwa-512x512.png | Bin 0 -> 1881 bytes 3 files changed, 4 insertions(+) create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/public/pwa-192x192.png create mode 100644 apps/web/public/pwa-512x512.png diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..fe558ed --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + 🐾 + diff --git a/apps/web/public/pwa-192x192.png b/apps/web/public/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..40c71324e6f5b1e4456e76b6570f14496ac4e455 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS-8IT;if4me0l z9hF Date: Mon, 30 Mar 2026 18:09:09 +0000 Subject: [PATCH 4/5] fix(api): use valid staff ID for dev-session impersonation The hardcoded DEV_STAFF_ID (all zeros) did not exist in the staff table, causing a foreign-key violation and 500 error. Now falls back to the demo-manager (KNOWN_STAFF_ID from seed) or any active staff record instead. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 4d0b704..7d3289c 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -477,15 +477,36 @@ portalRouter.post( return c.json({ error: "Client not found" }, 404); } - // Create a long-lived impersonation session for the dev client. - // Use a fixed "dev-staff" staffId so multiple dev sessions don't conflict - // with the one-active-session-per-staff rule in the real impersonation flow. - const DEV_STAFF_ID = "00000000-0000-0000-0000-000000000000"; + // Find a staff record to associate with the dev impersonation session. + // Use the demo-manager if it exists (created by seed with known ID), + // otherwise fall back to the first active staff record. + // This avoids hardcoding a UUID that may not exist in all environments. + const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + + let staffId = DEMO_STAFF_ID; + const [demoStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.id, DEMO_STAFF_ID)) + .limit(1); + + if (!demoStaff) { + // Fall back to any active staff member + const [firstStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.active, true)) + .limit(1); + if (!firstStaff) { + return c.json({ error: "No staff records found. Run the database seed." }, 500); + } + staffId = firstStaff.id; + } const [session] = await db .insert(impersonationSessions) .values({ - staffId: DEV_STAFF_ID, + staffId, clientId: body.clientId, reason: "dev-mode-client-portal", expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours From 0d610f5114d079102778f624e95f19fbc2fb7a9c Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 02:29:35 +0000 Subject: [PATCH 5/5] fix(ci): use unique Job names per deploy to prevent Flux immutability errors (GRO-311) Since Kubernetes Job spec.template is immutable, Flux cannot update a completed Job with a new image tag. This change ensures the CI workflow updates both the image newTag AND the Job metadata.name to include the short SHA (e.g., migrate-schema-026a2c8), making each deploy's Job unique and allowing Flux to reconcile consecutive deploys without immutable field errors. Co-authored-by: Barkley Trimsworth Co-authored-by: Paperclip --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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}"