diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7a067..4fdc3dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,7 +347,7 @@ jobs: if [ -z "$TAG" ]; then TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}" fi - SHORT_SHA="${SHA::7}" + export 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 @@ -363,7 +363,7 @@ jobs: 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" + yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB" fi # Update seed Job name to include short SHA (immutable template fix) @@ -372,7 +372,7 @@ jobs: 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" + yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB" fi git -C /tmp/infra diff --stat diff --git a/apps/e2e/tests/impersonation.spec.ts b/apps/e2e/tests/impersonation.spec.ts index 3588412..ef2f5d8 100644 --- a/apps/e2e/tests/impersonation.spec.ts +++ b/apps/e2e/tests/impersonation.spec.ts @@ -2,7 +2,6 @@ import { test, expect } from "./fixtures.js"; /** * E2E tests for customer portal impersonation flow. - * Tests ImpersonationBanner display, actions, and session management. */ const MOCK_SESSION = { @@ -19,6 +18,7 @@ const MOCK_SESSION = { test.describe("ImpersonationBanner", () => { test.beforeEach(async ({ page }) => { + // Only mock impersonation endpoints - portal/me is NOT called in impersonation flow await page.route("**/api/impersonation/sessions/session-1", (route) => route.fulfill({ json: MOCK_SESSION }) ); @@ -31,6 +31,8 @@ test.describe("ImpersonationBanner", () => { await page.route("**/api/impersonation/sessions/session-1/audit-log", (route) => route.fulfill({ json: { logs: [] } }) ); + // NOTE: NOT mocking portal/me - this endpoint is only called in the CLIENT + // dev user flow (devUser.type === "client"), NOT in the impersonation flow }); test("banner displays when session is active", async ({ page }) => { diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 80d4e5b..5bc454e 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useSearchParams, Navigate } from "react-router-dom"; import { Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare, Settings, LogOut, Shield, @@ -38,6 +38,10 @@ export function CustomerPortal() { const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); const [clientName, setClientName] = useState(""); + const [initComplete, setInitComplete] = useState(false); + // Track whether an impersonation session fetch from URL param is in-flight + // Dashboard will not redirect while this is true, allowing the session to load + const [isImpersonating, setIsImpersonating] = useState(false); const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); @@ -50,6 +54,7 @@ export function CustomerPortal() { const sessionId = searchParams.get("sessionId"); if (sessionId) { + setIsImpersonating(true); // Real impersonation session from URL param fetch(`/api/impersonation/sessions/${sessionId}`) .then((r) => { @@ -68,7 +73,8 @@ export function CustomerPortal() { }) .catch(() => { setSearchParams({}, { replace: true }); - }); + }) + .finally(() => { setInitComplete(true); setIsImpersonating(false); }); return; } @@ -90,7 +96,10 @@ export function CustomerPortal() { setClientName(devUser.name); } }) - .catch(() => {}); + .finally(() => setInitComplete(true)); + } else { + // No valid session: staff dev users and unauthenticated users fall through here + setInitComplete(true); } }, []); @@ -150,7 +159,7 @@ export function CustomerPortal() { const sessionId = session?.id ?? null; switch (activeSection) { case "dashboard": - return ; + return ; case "appointments": return ; case "pets": @@ -168,6 +177,24 @@ export function CustomerPortal() { const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase(); + // After init completes, redirect unauthenticated users to /login and staff to /admin. + // The portal chrome must NEVER be visible to users without a valid client session. + // For client dev users, we stay on the portal even if session is null — the dev-session + // response may not have id set immediately, or there may be timing issues with the + // session state. Dev users are verified via localStorage and the dev-session flow. + if (initComplete && !session) { + const devUser = getDevUser(); + if (devUser && devUser.type === "staff") { + return ; + } + if (devUser && devUser.type === "client") { + // Don't redirect — dev session creation may have failed or session.id may be null + // The portal should still render for client dev users + } else { + return ; + } + } + return (
void; readOnly: boolean; onReschedule: (appointmentId: string) => void; + /** True when a sessionId param was in the URL and the session is still loading */ + isImpersonating?: boolean; } interface Appointment { @@ -72,6 +76,7 @@ export function Dashboard({ onNavigate, readOnly, onReschedule, + isImpersonating, }: DashboardProps) { const [appointments, setAppointments] = useState([]); const [pets, setPets] = useState([]); @@ -182,14 +187,12 @@ export function Dashboard({ ); } - if (!sessionId) { - return ( -
-
-

Please sign in to view your dashboard.

-
-
- ); + // Don't redirect to /login if we have a dev user — dev sessions may not have + // sessionId set immediately after creation (session?.id may be null due to + // timing or API response issues). Dev users are stored in localStorage and + // verified via the dev-session flow, so they should see the portal. + if (!sessionId && !isImpersonating && !getDevUser()) { + return ; } const upcomingAppointments = getUpcomingAppointments(); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 2f01a3b..2cb6ffb 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -18,7 +18,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import * as schema from "./schema.js"; // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── @@ -234,18 +234,18 @@ const productsUsed = [ ]; // ── Service definitions ────────────────────────────────────────────────────── - +// Deterministic service IDs so seed is idempotent (ON CONFLICT targets id, not name). const servicesDef = [ - { name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 }, - { name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 }, - { name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 }, - { name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 }, - { name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 }, - { name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 }, - { name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 }, - { name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 }, - { name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 }, - { name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, + { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 }, + { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 }, + { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 }, + { id: "b0000001-0000-0000-0000-000000000004", name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 }, + { id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 }, + { id: "b0000001-0000-0000-0000-000000000006", name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 }, + { id: "b0000001-0000-0000-0000-000000000007", name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 }, + { id: "b0000001-0000-0000-0000-000000000008", name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 }, + { id: "b0000001-0000-0000-0000-000000000009", name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 }, + { id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, ]; // ── Known-users-only seed (prod/demo) ─────────────────────────────────────── @@ -424,13 +424,19 @@ async function seed() { console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`); // ── Services ── + // Deduplicate existing services (keep lowest id per name) before inserting. + await db.execute(sql` + DELETE FROM services WHERE id NOT IN ( + SELECT MIN(id) FROM services GROUP BY name + ) + `); + const serviceIds: string[] = []; for (const s of servicesDef) { - const id = uuid(); - serviceIds.push(id); + serviceIds.push(s.id); await db.insert(schema.services) .values({ - id, + id: s.id, name: s.name, description: s.desc, basePriceCents: s.price,