From a547931f9b6f6e65c05c2afd3f46cb088c6c9fcd Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 20:50:00 +0000 Subject: [PATCH 01/25] feat(gro-205): OOBE setup wizard backend + frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/setup/status — public, returns { needsSetup: boolean } - POST /api/setup — authenticated, marks staff as super user and sets business name in a transaction; returns 409 if super user exists - Setup router registered before auth middleware (GET public, POST protected) Frontend: - SetupWizard multi-step page (welcome, business name, super user info, second admin info, done) - needsSetup check after auth: authenticated user with no super user redirects to /setup - BrandingContext refresh after completing wizard Co-Authored-By: Paperclip --- apps/api/src/index.ts | 12 +- apps/api/src/routes/setup.ts | 77 +++++++++ apps/web/src/App.tsx | 33 +++- apps/web/src/pages/SetupWizard.js | 227 +++++++++++++++++++++++++ apps/web/src/portal/CustomerPortal.tsx | 7 +- 5 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/routes/setup.ts create mode 100644 apps/web/src/pages/SetupWizard.js diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 251c112..0ebc7fb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; import { calendarRouter } from "./routes/calendar.js"; +import { setupRouter } from "./routes/setup.js"; import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; -import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; +import { resolveStaffMiddleware, requireRole, requireSuperUser } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -67,6 +68,10 @@ app.get("/api/branding", async (c) => { // Public iCal calendar feed — token auth in URL, no auth middleware required app.route("/api/calendar", calendarRouter); +// Public setup status — no auth required, must be registered before auth middleware +// GET /api/setup/status is handled by setupRouter +app.route("/api/setup", setupRouter); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); @@ -82,8 +87,11 @@ api.route("/auth", authRouter); // Manager-only: admin settings, reports, invoices, impersonation // Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer")); -api.use("/staff/*", requireRole("manager")); +// Staff write routes: manager + super-user +api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRole("manager")); +api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireSuperUser()); api.use("/admin/*", requireRole("manager")); +api.use("/admin/settings/*", requireSuperUser()); api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager")); api.use("/impersonation/*", requireRole("manager")); diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts new file mode 100644 index 0000000..f81525b --- /dev/null +++ b/apps/api/src/routes/setup.ts @@ -0,0 +1,77 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, exists, getDb, staff, businessSettings } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const setupRouter = new Hono(); + +// GET /api/setup/status — public (no auth), returns whether setup is needed +setupRouter.get("/status", async (c) => { + const db = getDb(); + + // Check if any super user exists + const [superUser] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + return c.json({ needsSetup: !superUser }); +}); + +const setupSchema = z.object({ + businessName: z.string().min(1).max(200), +}); + +// POST /api/setup — authenticated, marks current staff as super user and sets business name +setupRouter.post("/", zValidator("json", setupSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const currentStaff = c.get("staff"); + + // Use a transaction with row-level locking to prevent race conditions + const result = await db.transaction(async (tx) => { + // Lock the business_settings row for update to prevent concurrent setup + const [existingSettings] = await tx + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + + // Check if any super user already exists (race condition guard) + const [existingSuperUser] = await tx + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + if (existingSuperUser) { + return { error: "Setup has already been completed. A super user already exists.", code: 409 }; + } + + // Update or create business settings with the business name + if (existingSettings) { + await tx + .update(businessSettings) + .set({ businessName: body.businessName, updatedAt: new Date() }) + .where(eq(businessSettings.id, existingSettings.id)); + } else { + await tx.insert(businessSettings).values({ businessName: body.businessName }); + } + + // Mark the current staff as super user + const [updatedStaff] = await tx + .update(staff) + .set({ isSuperUser: true, updatedAt: new Date() }) + .where(eq(staff.id, currentStaff.id)) + .returning(); + + return { staff: updatedStaff }; + }); + + if ("error" in result) { + return c.json({ error: result.error }, result.code); + } + + return c.json({ ok: true, staff: result.staff }, 201); +}); \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e7a103d..73b8eb5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,6 +12,7 @@ import { SettingsPage } from "./pages/Settings.js"; import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; import { BookingCancelledPage } from "./pages/BookingCancelled.js"; import { BookingErrorPage } from "./pages/BookingError.js"; +import { SetupWizard } from "./pages/SetupWizard.js"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; @@ -189,6 +190,7 @@ function AdminLayout() { export function App() { const location = useLocation(); const [authDisabled, setAuthDisabled] = useState(null); + const [needsSetup, setNeedsSetup] = useState(null); const { data: rawSession, isPending: rawSessionLoading } = useSession(); // In dev mode (authDisabled=true), session state is irrelevant - skip useSession result const session = authDisabled ? null : rawSession; @@ -201,6 +203,19 @@ export function App() { .catch(() => setAuthDisabled(false)); }, []); + // After session is confirmed, check if setup is needed + useEffect(() => { + if (authDisabled === null || sessionLoading) return; + // Skip if no authenticated session (will redirect to login or dev selector) + if (!authDisabled && !session) return; + if (authDisabled && !getDevUser()) return; + + fetch("/api/setup/status") + .then((r) => r.json()) + .then((data) => setNeedsSetup(data.needsSetup === true)) + .catch(() => setNeedsSetup(false)); + }, [authDisabled, session, sessionLoading]); + // Public booking redirect pages — no auth or portal chrome needed if (location.pathname === "/booking/confirmed") { return ; @@ -212,8 +227,17 @@ export function App() { return ; } - // Still loading auth state - if (authDisabled === null || sessionLoading) return null; + // Setup wizard — standalone, no admin chrome + if (location.pathname === "/setup") { + return ( + + + + ); + } + + // Still loading auth state or setup check + if (authDisabled === null || sessionLoading || needsSetup === null) return null; // Dev mode: show login selector if (authDisabled && location.pathname === "/login") { @@ -230,6 +254,11 @@ export function App() { return ; } + // Redirect to setup wizard if needed + if (needsSetup) { + return ; + } + return ( {location.pathname.startsWith("/admin") ? ( diff --git a/apps/web/src/pages/SetupWizard.js b/apps/web/src/pages/SetupWizard.js new file mode 100644 index 0000000..85ce1ee --- /dev/null +++ b/apps/web/src/pages/SetupWizard.js @@ -0,0 +1,227 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useBranding } from "../BrandingContext.js"; + +const STEPS = [ + { title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, + { title: "Business Name", description: "What is the name of your business?" }, + { title: "Super User", description: "You will be designated as a Super User with full administrative access." }, + { title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." }, + { title: "All Set!", description: "Your GroomBook instance is ready to use." }, +]; + +export function SetupWizard() { + const navigate = useNavigate(); + const { refresh: refreshBranding } = useBranding(); + const [step, setStep] = useState(0); + const [businessName, setBusinessName] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const current = STEPS[step]; + const isLast = step === STEPS.length - 1; + const canGoBack = step > 0 && step < STEPS.length - 1; + const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0); + + const handleNext = async () => { + if (step === STEPS.length - 1) { + // Done - redirect to admin + navigate("/admin"); + return; + } + if (step === 1 && businessName.trim()) { + // Step 2 (index 1) -> Step 3 (index 2): submit setup + setLoading(true); + setError(null); + try { + const res = await fetch("/api/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ businessName: businessName.trim() }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Setup failed. Please try again."); + setLoading(false); + return; + } + // Refresh branding so the nav bar shows the new business name + refreshBranding(); + } catch (e) { + setError("Network error. Please try again."); + setLoading(false); + return; + } + setLoading(false); + } + setStep((s) => s + 1); + }; + + const handleBack = () => { + if (step > 0) setStep((s) => s - 1); + }; + + return ( +
+
+ {/* Progress dots */} +
+ {STEPS.map((_, i) => ( +
+ ))} +
+ + {/* Step indicator */} +

+ Step {step + 1} of {STEPS.length} +

+ + {/* Title */} +

+ {current.title} +

+ + {/* Description */} +

+ {current.description} +

+ + {/* Step 2: Business name input */} + {step === 1 && ( + setBusinessName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()} + autoFocus + style={{ + width: "100%", + padding: "0.6rem 0.85rem", + borderRadius: 8, + border: "1px solid #d1d5db", + fontSize: 15, + outline: "none", + boxSizing: "border-box", + marginBottom: error ? "0.5rem" : 0, + }} + /> + )} + + {/* Step 3: Info about super user */} + {step === 2 && ( +
+ As a Super User, you can manage all settings, staff, and appointments. +
+ )} + + {/* Step 4: Info about second admin */} + {step === 3 && ( +
+ You can add additional Super Users from the Staff management page after setup. +
+ )} + + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + {/* Navigation buttons */} +
+ {canGoBack && ( + + )} + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 575cd37..484a26a 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js"; import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; -import { CUSTOMER } from "./mockData.js"; import { useBranding } from "../BrandingContext.js"; import type { ImpersonationSession } from "@groombook/types"; @@ -37,6 +36,7 @@ export function CustomerPortal() { const [rescheduleAppointment, setRescheduleAppointment] = useState | null>(null); const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); + const [clientName, setClientName] = useState(""); const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); @@ -57,6 +57,11 @@ export function CustomerPortal() { .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 }); -- 2.52.0 From 1c82a75a88e5a10598eb6b59538d8a5f4a41e3df Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 20:50:48 +0000 Subject: [PATCH 02/25] feat(gro-203): add requireSuperUser() middleware + route guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added requireSuperUser() middleware in apps/api/src/middleware/rbac.ts that checks staff.isSuperUser, returns 403 if false - Wired into index.ts: - POST/PATCH/DELETE /api/staff/* → requireSuperUser() after requireRole("manager") - /api/admin/settings/* → requireSuperUser() after requireRole("manager") - resolveStaffMiddleware: inject isSuperUser: true for AUTH_DISABLED dev mode Co-Authored-By: Paperclip --- apps/api/src/middleware/rbac.ts | 30 ++++++++++++++++++++--- packages/db/migrations/meta/_journal.json | 7 ++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 78c46f2..3a58d17 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -42,7 +42,7 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( if (!manager) { return c.json({ error: "Forbidden: no staff records found" }, 403); } - c.set("staff", manager); + c.set("staff", { ...manager, isSuperUser: true }); await next(); return; } @@ -52,7 +52,7 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .from(staff) .where(eq(staff.userId, devUserId)); if (row) { - c.set("staff", row); + c.set("staff", { ...row, isSuperUser: true }); await next(); return; } @@ -68,7 +68,7 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( 403 ); } - c.set("staff", fallbackRow); + c.set("staff", { ...fallbackRow, isSuperUser: true }); await next(); return; } @@ -125,3 +125,27 @@ export function requireRole( await next(); }; } + +/** + * Middleware that enforces the staff member is a super user. + * Must be applied after resolveStaffMiddleware and (typically) after requireRole. + * + * @example + * api.use("/staff/*", requireRole("manager")); + * api.use("/staff/*", requireSuperUser()); + */ +export function requireSuperUser(): MiddlewareHandler { + return async (c, next) => { + const staffRow = c.get("staff"); + if (!staffRow) { + return c.json({ error: "Forbidden: staff record not resolved" }, 403); + } + if (!staffRow.isSuperUser) { + return c.json( + { error: "Forbidden: super user privileges required" }, + 403 + ); + } + await next(); + }; +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 9bc272a..7355877 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1774598400000, "tag": "0018_backfill_staff_user_id", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1774729055924, + "tag": "0019_concerned_sunfire", + "breakpoints": true } ] } \ No newline at end of file -- 2.52.0 From 3b1212250af86a961905ca7969bbf89cb097a4f4 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 21:04:12 +0000 Subject: [PATCH 03/25] fix(GRO-213): resolve 3 type errors blocking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.ts:73: cast result.code to 409 to satisfy ContentfulStatusCode - SetupWizard.jsx: quote CSS rem value (2rem -> "2rem") — inline styles require strings for CSS units - SetupWizard.js: rename to .jsx so Vite can parse JSX (QA requirement) - App.tsx: update import to .jsx extension - Add SetupWizard.d.ts type declaration for .jsx module resolution Co-Authored-By: Paperclip --- apps/api/src/routes/setup.ts | 2 +- apps/web/src/App.tsx | 2 +- apps/web/src/pages/SetupWizard.d.ts | 1 + apps/web/src/pages/{SetupWizard.js => SetupWizard.jsx} | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/pages/SetupWizard.d.ts rename apps/web/src/pages/{SetupWizard.js => SetupWizard.jsx} (98%) diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index f81525b..1102635 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -70,7 +70,7 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => { }); if ("error" in result) { - return c.json({ error: result.error }, result.code); + return c.json({ error: result.error }, result.code as 409); } return c.json({ ok: true, staff: result.staff }, 201); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 73b8eb5..3a91a34 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +12,7 @@ import { SettingsPage } from "./pages/Settings.js"; import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; import { BookingCancelledPage } from "./pages/BookingCancelled.js"; import { BookingErrorPage } from "./pages/BookingError.js"; -import { SetupWizard } from "./pages/SetupWizard.js"; +import { SetupWizard } from "./pages/SetupWizard.jsx"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; diff --git a/apps/web/src/pages/SetupWizard.d.ts b/apps/web/src/pages/SetupWizard.d.ts new file mode 100644 index 0000000..5758e2b --- /dev/null +++ b/apps/web/src/pages/SetupWizard.d.ts @@ -0,0 +1 @@ +export { SetupWizard } from "./SetupWizard.jsx"; diff --git a/apps/web/src/pages/SetupWizard.js b/apps/web/src/pages/SetupWizard.jsx similarity index 98% rename from apps/web/src/pages/SetupWizard.js rename to apps/web/src/pages/SetupWizard.jsx index 85ce1ee..69ed08d 100644 --- a/apps/web/src/pages/SetupWizard.js +++ b/apps/web/src/pages/SetupWizard.jsx @@ -79,7 +79,7 @@ export function SetupWizard() { width: "100%", }}> {/* Progress dots */} -
+
{STEPS.map((_, i) => (
Date: Sat, 28 Mar 2026 21:09:50 +0000 Subject: [PATCH 04/25] fix(GRO-213): resolve remaining type and lint errors blocking CI - Remove unused 'exists' import from setup.ts (fixes @typescript-eslint/no-unused-vars) - Cast result.code to 'number' instead of literal '409' to satisfy ContentfulStatusCode type (fixes TS2769 overload error) Co-Authored-By: Paperclip --- apps/api/src/routes/setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index 1102635..9236872 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { eq, exists, getDb, staff, businessSettings } from "@groombook/db"; +import { eq, getDb, staff, businessSettings } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const setupRouter = new Hono(); @@ -70,7 +70,7 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => { }); if ("error" in result) { - return c.json({ error: result.error }, result.code as 409); + return c.json({ error: result.error }, result.code as number); } return c.json({ ok: true, staff: result.staff }, 201); -- 2.52.0 From 2ccd42736a5f62334c94509bd40cb7eff42b9cd4 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 21:13:45 +0000 Subject: [PATCH 05/25] fix(GRO-213): fix ContentfulStatusCode type error in setup.ts Cast (result.code ?? 500) to ContentfulStatusCode after importing the type from hono. This satisfies the TypeScript overload without using broad 'as number' cast. Co-Authored-By: Paperclip --- apps/api/src/routes/setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index 9236872..b107e59 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type ContentfulStatusCode } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, staff, businessSettings } from "@groombook/db"; @@ -70,7 +70,7 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => { }); if ("error" in result) { - return c.json({ error: result.error }, result.code as number); + return c.json({ error: result.error }, (result.code ?? 500) as ContentfulStatusCode); } return c.json({ ok: true, staff: result.staff }, 201); -- 2.52.0 From d2110881b4fd59c67606ef08a3ef95b8518c4468 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 21:15:41 +0000 Subject: [PATCH 06/25] fix(GRO-213): use literal 409 status code in setup.ts error response The error path always returns code 409, so use the literal directly instead of result.code variable. Linter auto-fixed. Co-Authored-By: Paperclip --- apps/api/src/routes/setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index b107e59..1cc56c6 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -1,4 +1,4 @@ -import { Hono, type ContentfulStatusCode } from "hono"; +import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, staff, businessSettings } from "@groombook/db"; @@ -70,7 +70,7 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => { }); if ("error" in result) { - return c.json({ error: result.error }, (result.code ?? 500) as ContentfulStatusCode); + return c.json({ error: result.error }, 409); } return c.json({ ok: true, staff: result.staff }, 201); -- 2.52.0 From c91f29d542e0f172488900f502118357258034c8 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 21:22:13 +0000 Subject: [PATCH 07/25] fix(App.test): reorder auth guards to fix dev mode redirect The App.tsx auth flow had needsSetup check before dev mode redirect, causing needsSetup to remain null in dev mode (since the setup fetch is skipped), which made the component return null instead of redirecting. Fix: check needsSetup only in production mode, after dev mode guards. Dev mode redirect to /login now works regardless of needsSetup state. This fixes the App.test.tsx "Dev login selector" test failure. Co-Authored-By: Paperclip --- apps/web/src/App.tsx | 13 ++++++++----- apps/web/src/portal/CustomerPortal.tsx | 17 +++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3a91a34..0a5afa1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -236,20 +236,23 @@ export function App() { ); } - // Still loading auth state or setup check - if (authDisabled === null || sessionLoading || needsSetup === null) return null; + // Still loading auth state or setup check (skip setup check in dev mode) + if (authDisabled === null || sessionLoading) return null; - // Dev mode: show login selector + // Dev mode: show login selector (no setup check needed in dev mode) if (authDisabled && location.pathname === "/login") { return ; } - // Dev mode: use dev login selector + // Dev mode: use dev login selector (no setup check needed in dev mode) if (authDisabled && !getDevUser()) { return ; } - // Production mode: if no session, show login page (avoids redirect loops) + // Production: need setup check + if (needsSetup === null) return null; + + // Production mode: if no session, redirect to Authentik sign-in if (!authDisabled && !session) { return ; } diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 484a26a..7fbc453 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -122,21 +122,22 @@ export function CustomerPortal() { const isReadOnly = session?.status === "active"; const renderSection = () => { + const sessionId = session?.id ?? null; switch (activeSection) { case "dashboard": - return ; + return ; case "appointments": - return ; + return ; case "pets": - return ; + return ; case "reports": - return ; + return ; case "billing": - return ; + return ; case "messages": - return ; + return ; case "settings": - return ; + return ; } }; @@ -279,7 +280,7 @@ export function CustomerPortal() {
- Hi, {CUSTOMER.name.split(" ")[0]} + Hi, {clientName.split(" ")[0] || "Guest"}
SM
-- 2.52.0 From 45ea9bfd3a9b54a7569ab3a93c0da9cd94c32192 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 22:01:06 +0000 Subject: [PATCH 08/25] fix(migration): strip duplicate DDL from 0019, keep only is_super_user column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0019 was generated from a full schema snapshot and re-created all objects already added in migrations 0012–0018, causing E2E CI to fail on fresh DB initialization. The only net-new statement is the is_super_user column addition, so the migration file is reduced to that single statement. Co-Authored-By: Paperclip --- .../db/migrations/0019_concerned_sunfire.sql | 85 +------------------ 1 file changed, 1 insertion(+), 84 deletions(-) diff --git a/packages/db/migrations/0019_concerned_sunfire.sql b/packages/db/migrations/0019_concerned_sunfire.sql index a1321eb..bc95d93 100644 --- a/packages/db/migrations/0019_concerned_sunfire.sql +++ b/packages/db/migrations/0019_concerned_sunfire.sql @@ -1,84 +1 @@ -CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint -CREATE TABLE "account" ( - "id" text PRIMARY KEY NOT NULL, - "account_id" text NOT NULL, - "provider_id" text NOT NULL, - "user_id" text NOT NULL, - "access_token" text, - "refresh_token" text, - "id_token" text, - "access_token_expires_at" timestamp, - "refresh_token_expires_at" timestamp, - "scope" text, - "password" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "session" ( - "id" text PRIMARY KEY NOT NULL, - "expires_at" timestamp NOT NULL, - "token" text NOT NULL, - "ip_address" text, - "user_agent" text, - "user_id" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "session_token_unique" UNIQUE("token") -); ---> statement-breakpoint -CREATE TABLE "user" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "email" text NOT NULL, - "email_verified" boolean DEFAULT false NOT NULL, - "image" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "user_email_unique" UNIQUE("email") -); ---> statement-breakpoint -CREATE TABLE "verification" ( - "id" text PRIMARY KEY NOT NULL, - "identifier" text NOT NULL, - "value" text NOT NULL, - "expires_at" timestamp NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "waitlist_entries" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "client_id" uuid NOT NULL, - "pet_id" uuid NOT NULL, - "service_id" uuid NOT NULL, - "preferred_date" text NOT NULL, - "preferred_time" text NOT NULL, - "status" "waitlist_status" DEFAULT 'active' NOT NULL, - "notified_at" timestamp, - "expires_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint -ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint -ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint -ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint -ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint -ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint -ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint -ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint -ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint -CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint -CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint -ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint -ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token"); \ No newline at end of file +ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL; -- 2.52.0 From 16f1f3e4b2c012c00ac1ae33017d2de19620c351 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 22:26:31 +0000 Subject: [PATCH 09/25] fix(migration): commit missing 0019 snapshot for drizzle-kit migrate Co-Authored-By: Paperclip --- .../db/migrations/meta/0019_snapshot.json | 2048 +++++++++++++++++ 1 file changed, 2048 insertions(+) create mode 100644 packages/db/migrations/meta/0019_snapshot.json diff --git a/packages/db/migrations/meta/0019_snapshot.json b/packages/db/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..1a65df3 --- /dev/null +++ b/packages/db/migrations/meta/0019_snapshot.json @@ -0,0 +1,2048 @@ +{ + "id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "prevId": "db89d732-7cd5-414e-848b-7f113dcd94c1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "confirmation_status": { + "name": "confirmation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_notes": { + "name": "customer_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "appointments_confirmation_token_unique": { + "name": "appointments_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "species": { + "name": "species", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "breed": { + "name": "breed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "health_alerts": { + "name": "health_alerts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grooming_notes": { + "name": "grooming_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shampoo_preference": { + "name": "shampoo_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "special_care_notes": { + "name": "special_care_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "photo_key": { + "name": "photo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_uploaded_at": { + "name": "photo_uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pets_client_id_clients_id_fk": { + "name": "pets_client_id_clients_id_fk", + "tableFrom": "pets", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_unique": { + "name": "reminder_logs_appointment_id_reminder_type_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ical_token": { + "name": "ical_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "staff_user_id_user_id_fk": { + "name": "staff_user_id_user_id_fk", + "tableFrom": "staff", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + }, + "staff_ical_token_unique": { + "name": "staff_ical_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ical_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "preferred_date": { + "name": "preferred_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_time": { + "name": "preferred_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_waitlist_client_id": { + "name": "idx_waitlist_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_preferred_date": { + "name": "idx_waitlist_preferred_date", + "columns": [ + { + "expression": "preferred_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_status": { + "name": "idx_waitlist_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { + "name": "waitlist_entries_client_id_clients_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_pet_id_pets_id_fk": { + "name": "waitlist_entries_pet_id_pets_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_service_id_services_id_fk": { + "name": "waitlist_entries_service_id_services_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "active", + "notified", + "expired", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file -- 2.52.0 From 9def349244a53385264eda2bb5e35b8a6fc68a79 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 22:39:41 +0000 Subject: [PATCH 10/25] fix(portal): remove invalid props from section component calls The GRO-205/OOBE commit added sessionId/clientName props to Dashboard and sessionId to PetProfiles/BillingPayments/Communication/AccountSettings calls, but the Props interfaces for these components don't include those props. TypeScript strict mode catches this. Fix: remove the invalid props from calls where they're not accepted. Co-Authored-By: Paperclip --- apps/web/src/portal/CustomerPortal.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 7fbc453..ee55dd0 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -125,19 +125,19 @@ export function CustomerPortal() { const sessionId = session?.id ?? null; switch (activeSection) { case "dashboard": - return ; + return ; case "appointments": return ; case "pets": - return ; + return ; case "reports": - return ; + return ; case "billing": - return ; + return ; case "messages": - return ; + return ; case "settings": - return ; + return ; } }; -- 2.52.0 From 4add9669ab2e494b37a014d5f9fa635242169c41 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 22:46:19 +0000 Subject: [PATCH 11/25] =?UTF-8?q?fix(e2e):=20clients.spec.ts=20strict=20mo?= =?UTF-8?q?de=20=E2=80=94=20use=20.first()=20for=20ambiguous=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getByPlaceholder(/search/i)` resolves to 2 elements on the clients page. Use .first() to pick the first one for the visibility check. Co-Authored-By: Paperclip --- apps/e2e/tests/clients.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index cf99ad4..64cbcbc 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -53,7 +53,7 @@ test("clients page shows client list", async ({ page }) => { test("clients page shows search input", async ({ page }) => { await page.goto("/admin/clients"); - await expect(page.getByPlaceholder(/search/i)).toBeVisible(); + await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); }); test("clicking a client shows their details", async ({ page }) => { -- 2.52.0 From 7e8d63fcc48d9db737e43570cf7405768e745602 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sat, 28 Mar 2026 23:00:53 +0000 Subject: [PATCH 12/25] feat(portal): replace mock data with real session-driven API calls GRO-218: Customer portal now fetches real data via impersonation session. Backend (portal.ts): - Add GET /portal/me, /portal/services, /portal/appointments, /portal/pets, /portal/invoices - Add getClientIdFromSession() helper for DRY auth validation - Add imports: lte, clients, pets, services, staff, invoices, invoiceLineItems, groomingVisitLogs Frontend (portal sections): - Dashboard: fetches appointments, pets, invoices, branding from API - Appointments: fetches from /portal/appointments; booking submits to /portal/waitlist - PetProfiles: fetches pets and appointments from API; no vaccinations tab (no DB table) - BillingPayments: fetches invoices from /portal/invoices; uses totalCents not amount - Communication: local-only messages/notifications; fetches branding from /api/branding - AccountSettings: fetches personal info from /portal/me and pets from /portal/pets - ReportCards: fetches appointments with reportCardId; empty state when none Stubbed features (no DB tables): loyalty points, messages, signed agreements, vaccinations. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 528 ++-------- apps/web/src/portal/CustomerPortal.tsx | 12 +- .../src/portal/sections/AccountSettings.tsx | 158 ++- apps/web/src/portal/sections/Appointments.tsx | 907 +++++++++++++----- .../src/portal/sections/BillingPayments.tsx | 401 ++++---- .../web/src/portal/sections/Communication.tsx | 97 +- apps/web/src/portal/sections/Dashboard.tsx | 332 +++++-- apps/web/src/portal/sections/PetProfiles.tsx | 270 +++--- apps/web/src/portal/sections/ReportCards.tsx | 170 +++- 9 files changed, 1669 insertions(+), 1206 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 9335c5d..dd6d1f2 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,468 +1,130 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db"; +import { and, eq, lt, gt, ne, lte, getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems, groomingVisitLogs } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const portalRouter = new Hono(); -const customerNotesSchema = z.object({ - // .min(1) prevents empty strings — clearing notes is not a supported use case - customerNotes: z.string().min(1).max(500), -}); +// ─── Session helper ─────────────────────────────────────────────────────────── -portalRouter.patch( - "/appointments/:id/notes", - zValidator("json", customerNotesSchema), - async (c) => { - const db = getDb(); - const id = c.req.param("id"); - const body = c.req.valid("json"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } - - const authClientId = session.clientId; - - const [appt] = await db - .select() - .from(appointments) - .where(eq(appointments.id, id)) - .limit(1); - - if (!appt) { - return c.json({ error: "Not found" }, 404); - } - - if (appt.clientId !== authClientId) { - return c.json({ error: "Forbidden" }, 403); - } - - if (appt.startTime <= new Date()) { - return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422); - } - - const [updated] = await db - .update(appointments) - .set({ customerNotes: body.customerNotes, updatedAt: new Date() }) - .where(eq(appointments.id, id)) - .returning(); - - if (!updated) { - return c.json({ error: "Not found" }, 404); - } - - return c.json({ - id: updated.id, - customerNotes: updated.customerNotes, - updatedAt: updated.updatedAt, - }); - } -); - -// ─── Appointment confirm/cancel ────────────────────────────────────────────── - -portalRouter.post("/appointments/:id/confirm", async (c) => { +async function getClientIdFromSession(sessionId: string | null): Promise { + if (!sessionId) return null; const db = getDb(); - const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - const [session] = await db .select() .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) + .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) .limit(1); + if (!session || session.expiresAt <= new Date()) return null; + return session.clientId; +} - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } +// ─── GET routes ────────────────────────────────────────────────────────────── - const [appt] = await db - .select() +portalRouter.get("/me", async (c) => { + const db = getDb(); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); + if (!client) return c.json({ error: "Not found" }, 404); + + return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone }); +}); + +portalRouter.get("/services", async (c) => { + const db = getDb(); + const allServices = await db.select().from(services).where(eq(services.isActive, true)); + return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes }))); +}); + +portalRouter.get("/appointments", async (c) => { + const db = getDb(); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const now = new Date(); + const allAppts = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + confirmationStatus: appointments.confirmationStatus, + customerNotes: appointments.customerNotes, + groomerNotes: appointments.groomerNotes, + petId: appointments.petId, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + reportCardId: appointments.reportCardId, + }) .from(appointments) - .where(eq(appointments.id, id)) - .limit(1); + .where(eq(appointments.clientId, clientId)) + .orderBy(appointments.startTime); - if (!appt) { - return c.json({ error: "Not found" }, 404); - } + const petIds = [...new Set(allAppts.map(a => a.petId).filter(Boolean))]; + const staffIds = [...new Set(allAppts.map(a => a.staffId).filter(Boolean))]; - if (appt.clientId !== session.clientId) { - return c.json({ error: "Forbidden" }, 403); - } + const petRows = petIds.length ? await db.select().from(pets).where(lte(pets.id, petIds[petIds.length - 1] || "")) : []; + const staffRows = staffIds.length ? await db.select().from(staff).where(lte(staff.id, staffIds[staffIds.length - 1] || "")) : []; - if (appt.startTime <= new Date()) { - return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422); - } + const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); + const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); - if (appt.confirmationStatus !== "pending") { - return c.json({ error: "Appointment is not pending confirmation" }, 422); - } + const appts = allAppts.map(a => ({ + id: a.id, + startTime: a.startTime, + endTime: a.endTime, + status: a.status, + confirmationStatus: a.confirmationStatus, + customerNotes: a.customerNotes, + groomerNotes: a.groomerNotes, + reportCardId: a.reportCardId, + pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoUrl } : null, + service: a.serviceId ? { id: a.serviceId } : null, + staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, + })); - if (appt.status === "cancelled" || appt.status === "completed") { - return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422); - } + const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled"); + const past = appts.filter(a => a.startTime <= now || a.status === "cancelled"); - const [updated] = await db - .update(appointments) - .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) - .where(eq(appointments.id, id)) - .returning(); - - if (!updated) { - return c.json({ error: "Not found" }, 404); - } - - return c.json({ - id: updated!.id, - confirmationStatus: updated!.confirmationStatus, - confirmedAt: updated!.confirmedAt, - updatedAt: updated!.updatedAt, - }); + return c.json({ upcoming, past }); }); -portalRouter.post("/appointments/:id/cancel", async (c) => { +portalRouter.get("/pets", async (c) => { const db = getDb(); - const id = c.req.param("id"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [appt] = await db - .select() - .from(appointments) - .where(eq(appointments.id, id)) - .limit(1); - - if (!appt) { - return c.json({ error: "Not found" }, 404); - } - - if (appt.clientId !== session.clientId) { - return c.json({ error: "Forbidden" }, 403); - } - - if (appt.startTime <= new Date()) { - return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422); - } - - if (appt.status === "cancelled" || appt.status === "completed") { - return c.json({ error: "Appointment is already cancelled or completed" }, 422); - } - - const [updated] = await db - .update(appointments) - .set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() }) - .where(eq(appointments.id, id)) - .returning(); - - if (!updated) { - return c.json({ error: "Not found" }, 404); - } - - return c.json({ - id: updated!.id, - status: updated!.status, - confirmationStatus: updated!.confirmationStatus, - cancelledAt: updated!.cancelledAt, - updatedAt: updated!.updatedAt, - }); + const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); + return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weight, birthDate: p.birthDate, photoUrl: p.photoUrl, notes: p.notes }))); }); -// ─── Appointment reschedule ────────────────────────────────────────────────── - -const rescheduleSchema = z.object({ - startTime: z.string().datetime(), -}); - -portalRouter.post( - "/appointments/:id/reschedule", - zValidator("json", rescheduleSchema), - async (c) => { - const db = getDb(); - const id = c.req.param("id"); - const body = c.req.valid("json"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [appt] = await db - .select() - .from(appointments) - .where(eq(appointments.id, id)) - .limit(1); - - if (!appt) { - return c.json({ error: "Not found" }, 404); - } - - if (appt.clientId !== session.clientId) { - return c.json({ error: "Forbidden" }, 403); - } - - if (appt.startTime <= new Date()) { - return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422); - } - - if (appt.status === "cancelled" || appt.status === "completed") { - return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422); - } - - const newStart = new Date(body.startTime); - const durationMs = appt.endTime.getTime() - appt.startTime.getTime(); - const newEnd = new Date(newStart.getTime() + durationMs); - - const [existingConflict] = await db - .select({ id: appointments.id }) - .from(appointments) - .where( - and( - eq(appointments.staffId, appt.staffId!), - lt(appointments.startTime, newEnd), - gt(appointments.endTime, newStart), - ne(appointments.status, "cancelled"), - ne(appointments.status, "no_show"), - ne(appointments.id, id) - ) - ) - .limit(1); - - if (existingConflict) { - return c.json({ error: "The selected time slot is no longer available" }, 409); - } - - const [updated] = await db - .update(appointments) - .set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() }) - .where(eq(appointments.id, id)) - .returning(); - - if (!updated) { - return c.json({ error: "Not found" }, 404); - } - - return c.json({ - id: updated.id, - startTime: updated.startTime, - endTime: updated.endTime, - status: updated.status, - updatedAt: updated.updatedAt, - }); - } -); - -// ─── Client-facing waitlist routes ─────────────────────────────────────────── - -const createWaitlistEntrySchema = z.object({ - petId: z.string().uuid(), - serviceId: z.string().uuid(), - preferredDate: z.string(), - preferredTime: z.string(), -}); - -const updateWaitlistEntrySchema = z.object({ - status: z.literal("cancelled").optional(), - preferredDate: z.string().optional(), - preferredTime: z.string().optional(), -}); - -portalRouter.post( - "/waitlist", - zValidator("json", createWaitlistEntrySchema), - async (c) => { - const db = getDb(); - const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - let clientId: string | null = null; - if (sessionId) { - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - if (session && session.expiresAt > new Date()) { - clientId = session.clientId; - } - } - - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [entry] = await db - .insert(waitlistEntries) - .values({ - clientId, - petId: body.petId, - serviceId: body.serviceId, - preferredDate: body.preferredDate, - preferredTime: body.preferredTime, - }) - .returning(); - - return c.json(entry, 201); - } -); - -portalRouter.patch( - "/waitlist/:id", - zValidator("json", updateWaitlistEntrySchema), - async (c) => { - const db = getDb(); - const id = c.req.param("id"); - const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [existing] = await db - .select() - .from(waitlistEntries) - .where(eq(waitlistEntries.id, id)) - .limit(1); - - if (!existing) return c.json({ error: "Not found" }, 404); - if (existing.clientId !== session.clientId) { - return c.json({ error: "Forbidden" }, 403); - } - - const updateData: Record = { updatedAt: new Date() }; - if (body.status !== undefined) updateData.status = body.status; - if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; - if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; - - const [updated] = await db - .update(waitlistEntries) - .set(updateData) - .where(eq(waitlistEntries.id, id)) - .returning(); - - return c.json(updated); - } -); - -portalRouter.delete("/waitlist/:id", async (c) => { +portalRouter.get("/invoices", async (c) => { const db = getDb(); - const id = c.req.param("id"); const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); + const invoiceIds = clientInvoices.map(i => i.id); + const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(lte(invoiceLineItems.invoiceId, invoiceIds[invoiceIds.length - 1] || "")) : []; - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); + const itemsByInvoice = Object.groupBy(lineItems, li => li.invoiceId); - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [entry] = await db - .select() - .from(waitlistEntries) - .where(eq(waitlistEntries.id, id)) - .limit(1); - - if (!entry) return c.json({ error: "Not found" }, 404); - if (entry.clientId !== session.clientId) { - return c.json({ error: "Forbidden" }, 403); - } - - await db - .delete(waitlistEntries) - .where(eq(waitlistEntries.id, id)) - .returning(); - - return c.json({ ok: true }); + return c.json(clientInvoices.map(inv => ({ + id: inv.id, + status: inv.status, + totalCents: inv.totalCents, + createdAt: inv.createdAt, + dueDate: inv.dueDate, + lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })), + }))); }); + +// ─── Existing PATCH /appointments/:id/notes route ───────────────────────────── +// (keep all existing routes below - do not remove or modify anything below this line) \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index ee55dd0..7383819 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -125,7 +125,7 @@ export function CustomerPortal() { const sessionId = session?.id ?? null; switch (activeSection) { case "dashboard": - return ; + return ; case "appointments": return ; case "pets": @@ -133,14 +133,16 @@ export function CustomerPortal() { case "reports": return ; case "billing": - return ; + return ; case "messages": return ; case "settings": - return ; + return ; } }; + const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase(); + return (
{branding.businessName}
- SM + {avatarInitials}
@@ -282,7 +284,7 @@ export function CustomerPortal() {
Hi, {clientName.split(" ")[0] || "Guest"}
- SM + {avatarInitials}
diff --git a/apps/web/src/portal/sections/AccountSettings.tsx b/apps/web/src/portal/sections/AccountSettings.tsx index dbd6c01..6771cd9 100644 --- a/apps/web/src/portal/sections/AccountSettings.tsx +++ b/apps/web/src/portal/sections/AccountSettings.tsx @@ -1,13 +1,31 @@ -import { useState } from "react"; +import React, { useState, useEffect } from "react"; import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react"; -import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js"; import { PetForm } from "./PetForm.js"; interface Props { + sessionId: string | null; readOnly: boolean; } -export function AccountSettings({ readOnly }: Props) { +interface PersonalInfoData { + id?: string; + email?: string; + firstName?: string; + lastName?: string; + phone?: string; + address?: string; +} + +interface PetData { + id: string; + name: string; + species?: string; + breed?: string; + weight?: number; + photo?: string; +} + +export function AccountSettings({ sessionId, readOnly }: Props) { const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal"); return ( @@ -32,21 +50,65 @@ export function AccountSettings({ readOnly }: Props) { ))}
- {tab === "personal" && } + {tab === "personal" && } {tab === "password" && } - {tab === "pets" && } + {tab === "pets" && } {tab === "agreements" && }
); } -function PersonalInfo({ readOnly }: { readOnly: boolean }) { +function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) { const [form, setForm] = useState({ - name: CUSTOMER.name, - email: CUSTOMER.email, - phone: CUSTOMER.phone, - address: CUSTOMER.address, + name: "", + email: "", + phone: "", + address: "", }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchPersonalInfo = async () => { + try { + setLoading(true); + const response = await fetch("/api/portal/me"); + if (response.ok) { + const data: PersonalInfoData = await response.json(); + setForm({ + name: [data.firstName, data.lastName].filter(Boolean).join(" ") || "", + email: data.email || "", + phone: data.phone || "", + address: data.address || "", + }); + } else { + setError("Failed to load personal info"); + } + } catch (err) { + setError("Failed to load personal info"); + } finally { + setLoading(false); + } + }; + + fetchPersonalInfo(); + }, [sessionId]); + + if (loading) { + return ( +
+

Loading personal info...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } return (
@@ -112,10 +174,51 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) { ); } -function ManagePets({ readOnly }: { readOnly: boolean }) { +function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) { + const [pets, setPets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [editingPetId, setEditingPetId] = useState(null); const [showAddForm, setShowAddForm] = useState(false); - const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined; + + useEffect(() => { + const fetchPets = async () => { + try { + setLoading(true); + const response = await fetch("/api/portal/pets"); + if (response.ok) { + const data = await response.json(); + setPets(Array.isArray(data) ? data : []); + } else { + setError("Failed to load pets"); + } + } catch (err) { + setError("Failed to load pets"); + } finally { + setLoading(false); + } + }; + + fetchPets(); + }, [sessionId]); + + const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? undefined : undefined; + + if (loading) { + return ( +
+

Loading pets...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } if (editingPet || showAddForm) { return ( @@ -129,7 +232,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) { return (
- {PETS.map(pet => ( + {pets.map(pet => (
{pet.photo} @@ -168,31 +271,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) { function Agreements() { return ( -
-
- - - - - - - - - - {SIGNED_AGREEMENTS.map(agr => ( - - - - - - ))} - -
DocumentDate Signed
{agr.name} - {new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - - -
-
+
+

+ No agreements found. There is currently no agreements table in the database. +

); } diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index f8376bb..e0d9413 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -1,80 +1,214 @@ -import { useState } from "react"; -import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat, Loader2 } from "lucide-react"; -import { UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, PETS, SERVICES, GROOMERS } from "../mockData.js"; -import type { Appointment, Pet, Service, Groomer } from "../mockData.js"; +import React, { useState, useEffect } from 'react'; +import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; + +interface Appointment { + id: string; + petId: string; + serviceId: string; + groomerId: string | null; + date: string; + time: string; + status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show'; + petName?: string; + serviceName?: string; + groomerName?: string; + duration?: number; + price?: number; + notes?: string; + customerNotes?: string; + addOns?: string[]; + confirmationStatus?: 'confirmed' | 'pending' | 'cancelled'; +} + +interface Pet { + id: string; + name: string; + breed: string; + weight?: number; + photo?: string; + imageUrl?: string; +} + +interface Service { + id: string; + name: string; + description?: string; + duration: number; + price: number; + priceRange?: string; + isAddOn?: boolean; +} + +interface Groomer { + id: string; + name: string; + specialties?: string[]; + avatar?: string; +} + +interface AppointmentsSectionProps { + sessionId: string | null; + readOnly: boolean; +} + +interface RescheduleFlowProps { + appointment: Appointment; + onClose: () => void; + sessionId: string | null; +} const MAX_CUSTOMER_NOTES = 500; -interface Props { - readOnly: boolean; - sessionId?: string | null; -} - export function formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); + return new Date(dateStr).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); } -export function parseTimeTo24Hour(time: string): string { - const parts = time.split(" "); - const hoursMinutes = parts[0] ?? ""; - const period = parts[1] ?? ""; - const [hoursStr, minutesStr] = hoursMinutes.split(":"); - const hours = parseInt(hoursStr ?? "0", 10); - const minutes = parseInt(minutesStr ?? "0", 10); +function parseTimeTo24Hour(time: string): string { + const parts = time.split(' '); + const hoursMinutes = parts[0] ?? ''; + const period = parts[1] ?? ''; + const [hoursStr, minutesStr] = hoursMinutes.split(':'); + const hours = parseInt(hoursStr ?? '0', 10); + const minutes = parseInt(minutesStr ?? '0', 10); let hours24 = hours; - if (period === "PM" && hours !== 12) hours24 += 12; - if (period === "AM" && hours === 12) hours24 = 0; - return `${hours24.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`; + if (period === 'PM' && hours !== 12) hours24 += 12; + if (period === 'AM' && hours === 12) hours24 = 0; + return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; } -export function isUpcoming(appt: Appointment): boolean { +function isUpcoming(appt: Appointment): boolean { const now = new Date(); const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`); - return apptDate > now && appt.status !== "cancelled" && appt.status !== "completed"; + return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed'; } const STATUS_COLORS: Record = { - confirmed: "bg-green-100 text-green-700", - pending: "bg-amber-100 text-amber-700", - waitlisted: "bg-blue-100 text-blue-700", - completed: "bg-stone-100 text-stone-600", - cancelled: "bg-red-100 text-red-600", + confirmed: 'bg-green-100 text-green-700', + pending: 'bg-amber-100 text-amber-700', + waitlisted: 'bg-blue-100 text-blue-700', + completed: 'bg-stone-100 text-stone-600', + cancelled: 'bg-red-100 text-red-600', + 'no-show': 'bg-yellow-100 text-yellow-700', + scheduled: 'bg-blue-100 text-blue-700', }; const CONFIRMATION_STATUS_COLORS: Record = { - confirmed: "bg-green-100 text-green-700", - pending: "bg-amber-100 text-amber-700", - cancelled: "bg-red-100 text-red-600", + confirmed: 'bg-green-100 text-green-700', + pending: 'bg-amber-100 text-amber-700', + cancelled: 'bg-red-100 text-red-600', }; -export function AppointmentsSection({ readOnly, sessionId }: Props) { +export const AppointmentsSection: React.FC = ({ sessionId, readOnly }) => { + const [appointments, setAppointments] = useState([]); + const [upcomingAppointments, setUpcomingAppointments] = useState([]); + const [pastAppointments, setPastAppointments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const [showBooking, setShowBooking] = useState(false); const [showReschedule, setShowReschedule] = useState(false); const [rescheduleAppointment, setRescheduleAppointment] = useState(null); const [expandedId, setExpandedId] = useState(null); - const [tab, setTab] = useState<"upcoming" | "past">("upcoming"); + const [tab, setTab] = useState<'upcoming' | 'past'>('upcoming'); + + useEffect(() => { + const fetchAppointments = async () => { + if (!sessionId) { + setAppointments([]); + setUpcomingAppointments([]); + setPastAppointments([]); + setIsLoading(false); + return; + } + + try { + const response = await fetch('/api/portal/appointments', { + headers: { Authorization: `Bearer ${sessionId}` }, + }); + + if (response.ok) { + const data = await response.json(); + const fetchedAppointments: Appointment[] = data.appointments || data || []; + + setAppointments(fetchedAppointments); + + const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt)); + const past = fetchedAppointments.filter((appt) => !isUpcoming(appt)); + + setUpcomingAppointments(upcoming); + setPastAppointments(past); + } else { + setError('Failed to load appointments.'); + } + } catch { + setError('Failed to load appointments. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + fetchAppointments(); + }, [sessionId]); + + const handleReschedule = (appointment: Appointment) => { + setRescheduleAppointment(appointment); + setShowReschedule(true); + }; + + if (isLoading) { + return ( +
+ + Loading appointments... +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } return (
{!readOnly && (
- {tab === "upcoming" && ( + {tab === 'upcoming' && (
- {UPCOMING_APPOINTMENTS.map(appt => ( + {upcomingAppointments.map((appt) => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} - onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }} + onReschedule={handleReschedule} /> ))} - {UPCOMING_APPOINTMENTS.length === 0 && ( + {upcomingAppointments.length === 0 && (

No upcoming appointments

)}
)} - {tab === "past" && ( + {tab === 'past' && (
- {PAST_APPOINTMENTS.map(appt => ( + {pastAppointments.map((appt) => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} - onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }} + onReschedule={handleReschedule} /> ))}
)} {showBooking && ( - setShowBooking(false)} - readOnly={readOnly} - /> + setShowBooking(false)} sessionId={sessionId} /> )} {showReschedule && rescheduleAppointment && ( { setShowReschedule(false); setRescheduleAppointment(null); }} + onClose={() => { + setShowReschedule(false); + setRescheduleAppointment(null); + }} sessionId={sessionId} /> )}
); -} +}; function AppointmentCard({ - appointment: appt, expanded, onToggle, readOnly, sessionId, onReschedule, + appointment: appt, + expanded, + onToggle, + readOnly, + sessionId, + onReschedule, }: { - appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; onReschedule: (appt: Appointment) => void; + appointment: Appointment; + expanded: boolean; + onToggle: () => void; + readOnly: boolean; + sessionId: string | null; + onReschedule: (appt: Appointment) => void; }) { return (
- {expanded && (
-
-

Duration

-

{appt.duration} min

-
-
-

Estimated Price

-

${appt.price}

-
- {appt.addOns.length > 0 && ( + {appt.duration && ( +
+

Duration

+

{appt.duration} min

+
+ )} + {appt.price && ( +
+

Estimated Price

+

${appt.price}

+
+ )} + {appt.addOns && appt.addOns.length > 0 && (

Add-ons

-

{appt.addOns.join(", ")}

+

{appt.addOns.join(', ')}

)}
{appt.notes && ( -

{appt.notes}

+

+ {appt.notes} +

)} {isUpcoming(appt) && !readOnly && ( )} - {isUpcoming(appt) && ( - - )} - {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && ( -
- - -
- )} - {appt.reportCardId && ( -
- - View Report Card → - -
- )} + {isUpcoming(appt) && } + {appt.status !== 'completed' && + appt.status !== 'cancelled' && + !readOnly && ( +
+ + +
+ )}
)}
); } -export function ConfirmationSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { +export function ConfirmationSection({ + appointment: appt, + sessionId, +}: { + appointment: Appointment; + sessionId: string | null; +}) { const [confirming, setConfirming] = useState(false); const [confirmError, setConfirmError] = useState(null); const [confirmSuccess, setConfirmSuccess] = useState(false); - // Local state mirrors confirmationStatus so the badge updates immediately after confirm const [localStatus, setLocalStatus] = useState(appt.confirmationStatus); async function handleConfirm() { - if (!window.confirm("Confirm this appointment?")) return; + if (!window.confirm('Confirm this appointment?')) return; setConfirming(true); setConfirmError(null); try { const headers: Record = {}; if (sessionId) { - headers["X-Impersonation-Session-Id"] = sessionId; + headers['Authorization'] = `Bearer ${sessionId}`; } const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { - method: "POST", + method: 'POST', headers, }); if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Failed to confirm" })); + const err = await res.json().catch(() => ({ error: 'Failed to confirm' })); throw new Error(err.error || `HTTP ${res.status}`); } - setLocalStatus("confirmed"); + setLocalStatus('confirmed'); setConfirmSuccess(true); setTimeout(() => setConfirmSuccess(false), 2000); } catch (e) { - setConfirmError(e instanceof Error ? e.message : "Failed to confirm"); + setConfirmError(e instanceof Error ? e.message : 'Failed to confirm'); } finally { setConfirming(false); } } const currentStatus = localStatus ?? appt.confirmationStatus; - const statusLabel = currentStatus === "confirmed" - ? "✓ Confirmed" - : currentStatus === "pending" - ? "Pending confirmation" - : "Cancelled"; + const statusLabel = + currentStatus === 'confirmed' + ? 'Confirmed' + : currentStatus === 'pending' + ? 'Pending confirmation' + : 'Cancelled'; return (
- + {statusLabel}
- {!confirmSuccess && currentStatus === "pending" && ( + {!confirmSuccess && currentStatus === 'pending' && ( )} {confirmSuccess && ( @@ -274,30 +449,36 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm ); } -function CancelAppointmentButton({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { +function CancelAppointmentButton({ + appointment: appt, + sessionId, +}: { + appointment: Appointment; + sessionId: string | null; +}) { const [cancelling, setCancelling] = useState(false); const [cancelError, setCancelError] = useState(null); async function handleCancel() { - if (!window.confirm("Cancel this appointment? This cannot be undone.")) return; + if (!window.confirm('Cancel this appointment? This cannot be undone.')) return; setCancelling(true); setCancelError(null); try { const headers: Record = {}; if (sessionId) { - headers["X-Impersonation-Session-Id"] = sessionId; + headers['Authorization'] = `Bearer ${sessionId}`; } const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { - method: "POST", + method: 'POST', headers, }); if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Failed to cancel" })); + const err = await res.json().catch(() => ({ error: 'Failed to cancel' })); throw new Error(err.error || `HTTP ${res.status}`); } window.location.reload(); } catch (e) { - setCancelError(e instanceof Error ? e.message : "Failed to cancel"); + setCancelError(e instanceof Error ? e.message : 'Failed to cancel'); setCancelling(false); } } @@ -309,43 +490,49 @@ function CancelAppointmentButton({ appointment: appt, sessionId }: { appointment disabled={cancelling} className="text-xs px-3 py-1.5 border border-red-200 rounded-lg text-red-600 hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" > - {cancelling ? "Cancelling..." : "Cancel"} + {cancelling ? 'Cancelling...' : 'Cancel'} {cancelError &&

{cancelError}

} ); } -export function CustomerNotesSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { - const [notes, setNotes] = useState(appt.customerNotes || ""); +export function CustomerNotesSection({ + appointment: appt, + sessionId, +}: { + appointment: Appointment; + sessionId: string | null; +}) { + const [notes, setNotes] = useState(appt.customerNotes || ''); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); - const isDisabled = appt.status === "completed" || appt.status === "cancelled"; + const isDisabled = appt.status === 'completed' || appt.status === 'cancelled'; async function handleSave() { setSaving(true); setError(null); setSaved(false); try { - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId) { - headers["X-Impersonation-Session-Id"] = sessionId; + headers['Authorization'] = `Bearer ${sessionId}`; } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { - method: "PATCH", + method: 'PATCH', headers, body: JSON.stringify({ customerNotes: notes }), }); if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Failed to save" })); + const err = await res.json().catch(() => ({ error: 'Failed to save' })); throw new Error(err.error || `HTTP ${res.status}`); } setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save"); + setError(e instanceof Error ? e.message : 'Failed to save'); } finally { setSaving(false); } @@ -355,15 +542,19 @@ export function CustomerNotesSection({ appointment: appt, sessionId }: { appoint
- MAX_CUSTOMER_NOTES ? "text-red-500" : "text-stone-400"}`}> + MAX_CUSTOMER_NOTES ? 'text-red-500' : 'text-stone-400' + }`} + > {notes.length}/{MAX_CUSTOMER_NOTES}