Promote dev → main: GRO-639, GRO-642, GRO-666, GRO-724 #318

Merged
the-dogfather-cto[bot] merged 15 commits from dev into main 2026-04-17 11:43:47 +00:00
18 changed files with 377 additions and 144 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
pull_request:
branches: [main]
branches: [main, dev]
workflow_dispatch:
inputs:
ref:
+90
View File
@@ -0,0 +1,90 @@
# Contributing to GroomBook
## Branch Strategy
GroomBook uses a three-branch GitOps model:
| Branch | Environment | Purpose |
|--------|-------------|---------|
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
## Developer Workflow
1. **Branch from `dev`** — create a feature or fix branch:
```bash
git checkout dev
git pull origin dev
git checkout -b feat/my-feature
```
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
```bash
gh pr create --base dev --title "feat: description (GRO-NNN)" \
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
```
3. **Pipeline gates before merge to `dev`:**
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
- CTO (The Dogfather) reviews second — architecture and final approval
- Both must approve; 2 approving reviews required by branch protection
## Promotion Flow
### Dev → UAT
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
```bash
gh pr create --base uat --head dev \
--title "chore: promote dev to uat (YYYY.MM.DD)" \
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
```
Gates:
- Shedward Scissorhands runs regression/acceptance tests
- Barkley Trimsworth performs security review
- CTO approves and merges (1 approving review required)
### UAT → Main (Production)
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
```bash
gh pr create --base main --head uat \
--title "chore: promote uat to main (YYYY.MM.DD)" \
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
```
Gates:
- CEO (Scrubs McBarkley) reviews for business alignment and merges
- 1 approving review required; triggers auto-deploy to Production
## Branch Protection Summary
| Branch | Required Approvals | Who approves |
|--------|--------------------|-------------|
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
| `uat` | 1 | CTO (The Dogfather) |
| `main` | 1 | CEO (Scrubs McBarkley) |
Force-pushes and branch deletions are disabled on all three branches.
## Commit Style
Use [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — new feature
- `fix:` — bug fix
- `chore:` — maintenance (dependency updates, build config, promotions)
- `docs:` — documentation only
- `ci:` — CI/CD changes
- `refactor:` — code restructure without behaviour change
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
## Questions?
Open a Paperclip issue in the GRO project or ask in the team channel.
+62 -2
View File
@@ -72,6 +72,60 @@ app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
// Magic bytes for allowed image types
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
};
/**
* Validates that the given base64 content matches the declared MIME type
* by checking magic bytes. Returns null if valid, or the field to clear if not.
*/
function validateLogoMagicBytes(
logoBase64: string | null,
logoMimeType: string | null
): "logoBase64" | "logoMimeType" | null {
if (!logoBase64 || !logoMimeType) return null;
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
try {
const binary = Buffer.from(logoBase64, "base64");
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
if (logoMimeType === "image/webp") {
if (binary.length < 12) return "logoBase64";
const webpMagic = binary.slice(0, 4);
const webpSig = binary.slice(8, 12);
if (
webpMagic[0] !== 0x52 ||
webpMagic[1] !== 0x49 ||
webpMagic[2] !== 0x46 ||
webpMagic[3] !== 0x46 ||
webpSig[0] !== 0x57 ||
webpSig[1] !== 0x45 ||
webpSig[2] !== 0x42 ||
webpSig[3] !== 0x50
) {
return "logoBase64";
}
return null;
}
// All other types: check prefix
if (binary.length < expectedMagic.length) return "logoBase64";
for (let i = 0; i < expectedMagic.length; i++) {
if (binary[i] !== expectedMagic[i]) return "logoBase64";
}
return null;
} catch {
return "logoBase64";
}
}
// Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => {
const db = getDb();
@@ -87,13 +141,19 @@ app.get("/api/branding", async (c) => {
}
}
// Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
return c.json({
businessName: settings.businessName,
primaryColor: settings.primaryColor,
accentColor: settings.accentColor,
logoUrl,
logoBase64: settings.logoBase64,
logoMimeType: settings.logoMimeType,
logoBase64: safeLogoBase64,
logoMimeType: safeLogoMimeType,
});
});
+78 -68
View File
@@ -5,6 +5,7 @@ import {
eq,
getDb,
gte,
inArray,
lt,
appointments,
clients,
@@ -59,68 +60,77 @@ export async function runReminderCheck(): Promise<void> {
)
);
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
if (appointmentIds.length === 0) continue;
// Bulk check: which appointments already have email and SMS reminders sent?
const sentRows = await db
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.reminderType, window.label),
appointmentIds.length === 1
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
: inArray(reminderLogs.appointmentId, appointmentIds)
)
);
const sentEmail = new Set(
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
);
const sentSms = new Set(
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
);
// Bulk JOIN: fetch all client/pet/service/staff data in one query
const joinedRows = await db
.select({
appointmentId: appointments.id,
startTime: appointments.startTime,
clientId: appointments.clientId,
petId: appointments.petId,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
confirmationToken: appointments.confirmationToken,
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
clientSmsOptIn: clients.smsOptIn,
clientPhone: clients.phone,
petName: pets.name,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(appointments.clientId, clients.id))
.innerJoin(pets, eq(appointments.petId, pets.id))
.innerJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(
and(
gte(appointments.startTime, windowStart),
lt(appointments.startTime, windowEnd),
eq(appointments.status, "scheduled")
)
);
const appointmentMap = new Map<string, typeof joinedRows[number]>();
for (const row of joinedRows) {
appointmentMap.set(row.appointmentId, row);
}
for (const appt of upcoming) {
const [emailLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
const joined = appointmentMap.get(appt.id as string);
if (!joined) continue;
const [smsLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
if (!clientEmail || clientEmailOptOut) continue;
if (!petName || !serviceName) continue;
if (!client || !client.email || client.emailOptOut) continue;
const [pet] = await db
.select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) continue;
const emailSent = sentEmail.has(appt.id as string);
const smsSent = sentSms.has(appt.id as string);
let confirmationToken = appt.confirmationToken;
if (!confirmationToken) {
@@ -131,15 +141,15 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id));
}
if (!emailLog) {
if (!emailSent) {
const sent = await sendEmail(
buildReminderEmail(
client.email,
clientEmail,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
clientName,
petName,
serviceName,
groomerName: staffName,
startTime: appt.startTime,
},
window.hours,
@@ -155,20 +165,20 @@ export async function runReminderCheck(): Promise<void> {
}
}
if (!smsLog && client.smsOptIn && client.phone) {
if (!smsSent && clientSmsOptIn && clientPhone) {
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
const smsBody = [
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
`Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`,
TCPA_OPT_OUT,
].join(". ");
try {
const smsOk = await smsSend(client.phone, smsBody);
const smsOk = await smsSend(clientPhone, smsBody);
if (smsOk) {
await db
.insert(reminderLogs)
+2 -2
View File
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for GroomBook Web E2E tests.
*
* Targets the deployed dev environment at groombook.dev.farh.net.
* Targets the deployed dev environment at dev.groombook.dev.
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
*
* Run locally:
@@ -19,7 +19,7 @@ export default defineConfig({
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "https://groombook.dev.farh.net",
baseURL: "https://dev.groombook.dev",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
+1 -1
View File
@@ -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.jsx";
import { SetupWizard } from "./pages/SetupWizard.tsx";
import { CustomerPortal } from "./portal/CustomerPortal.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
+13 -3
View File
@@ -26,6 +26,7 @@ export function GlobalSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -45,15 +46,18 @@ export function GlobalSearch() {
debounceRef.current = setTimeout(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
if (res.ok) {
const data: SearchResults = await res.json();
setResults(data);
setOpen(true);
} else {
setError("Search failed. Please try again.");
}
} catch (err) {
console.warn("GlobalSearch: fetch error", err);
} catch {
setError("Search failed. Please try again.");
} finally {
setLoading(false);
}
@@ -160,7 +164,13 @@ export function GlobalSearch() {
</div>
)}
{!loading && !hasResults && (
{!loading && error && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
{error}
</div>
)}
{!loading && !error && !hasResults && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
No results found
</div>
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
}
async function handleFile(file: File) {
const MAX_FILE_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
return;
}
if (!ACCEPTED_TYPES.includes(file.type)) {
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
return;
+52 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -273,7 +273,15 @@ export function AppointmentsPage() {
cascade !== "this_only"
? `/api/appointments/${id}?cascade=${cascade}`
: `/api/appointments/${id}`;
await fetch(url, { method: "DELETE" });
try {
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to delete appointment");
}
setSelectedAppt(null);
await loadAppointments();
}
@@ -819,8 +827,49 @@ function AppointmentDetail({
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements?.[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
if (!modalRef.current) return;
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
@@ -833,6 +882,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={modalRef}
style={{
background: "#fff",
borderRadius: 8,
+9
View File
@@ -211,6 +211,15 @@ function InvoiceDetailModal({
setSaving(true);
setError(null);
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
// Real-time validation: prevent submit if tip splits don't sum to 100%
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
if (Math.abs(totalPct - 100) >= 0.01) {
setError("Tip split percentages must sum to 100%");
setSaving(false);
return;
}
}
try {
const res = await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
+5 -5
View File
@@ -199,11 +199,11 @@ export function ReportsPage() {
}
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
summRes.json() as Promise<Summary>,
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
clientRes.json() as Promise<ClientReport>,
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
]);
setSummary(summData);
+6 -4
View File
@@ -27,6 +27,8 @@ interface AuthProviderForm {
const REDACTED = "••••••••";
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
interface CurrentUser {
id: string;
name: string;
@@ -149,9 +151,9 @@ export function SettingsPage() {
return;
}
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
return;
}
@@ -326,7 +328,7 @@ issuerUrl: authForm.issuerUrl,
if (!loaded) return <p>Loading settings...</p>;
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
return (
<div style={{ maxWidth: 600 }}>
@@ -393,7 +395,7 @@ issuerUrl: authForm.issuerUrl,
<input
ref={fileInputRef}
type="file"
accept="image/png,image/svg+xml,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleLogoChange}
style={{ display: "none" }}
/>
+1 -1
View File
@@ -1 +1 @@
export { SetupWizard } from "./SetupWizard.jsx";
export { SetupWizard } from "./SetupWizard.tsx";
@@ -2,16 +2,39 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js";
export function SetupWizard({ onSetupComplete }) {
interface SetupStatus {
showAuthProviderStep?: boolean;
}
interface TestResult {
ok: boolean;
error?: string;
}
interface AuthFormState {
providerId: string;
displayName: string;
issuerUrl: string;
internalBaseUrl: string;
clientId: string;
clientSecret: string;
scopes: string;
}
interface Step {
id: string;
title: string;
description: string;
}
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding();
// Fetch setup status to determine if auth provider step is needed
const [setupStatus, setSetupStatus] = useState(null); // null = loading
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
const [loadingStatus, setLoadingStatus] = useState(true);
// Auth provider form state
const [authForm, setAuthForm] = useState({
const [authForm, setAuthForm] = useState<AuthFormState>({
providerId: "authentik",
displayName: "",
issuerUrl: "",
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
scopes: "openid profile email",
});
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [step, setStep] = useState(0);
const [businessName, setBusinessName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/setup/status")
.then((r) => r.json())
.then((r) => r.json() as Promise<SetupStatus>)
.then((data) => {
setSetupStatus(data);
setLoadingStatus(false);
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
});
}, []);
// Build steps dynamically based on setup status
const STEPS = setupStatus?.showAuthProviderStep
const STEPS: Step[] = setupStatus?.showAuthProviderStep
? [
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
@@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) {
const isFirst = step === 0;
const canGoBack = step > 0 && step < STEPS.length - 1;
// Determine if we can proceed - depends on which step we're on
const canGoNext = (() => {
if (step === STEPS.length - 1) return true; // done step
if (step === STEPS.length - 1) return true;
if (current?.id === "business") return businessName.trim().length > 0;
if (current?.id === "auth") {
return (
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
scopes: authForm.scopes,
}),
});
const data = await res.json();
const data = (await res.json()) as TestResult;
setTestResult(data);
} catch (e) {
} catch {
setTestResult({ ok: false, error: "Network error. Please try again." });
} finally {
setTestingConnection(false);
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
const handleNext = async () => {
if (step === STEPS.length - 1) {
// Done - redirect to admin
navigate("/admin");
return;
}
// Submit auth provider config
if (current?.id === "auth") {
setLoading(true);
setError(null);
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
}),
});
if (!res.ok) {
const data = await res.json();
const data = (await res.json()) as { error?: string };
setError(data.error || "Failed to save auth provider configuration. Please try again.");
setLoading(false);
return;
}
} catch (e) {
} catch {
setError("Network error. Please try again.");
setLoading(false);
return;
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
setLoading(false);
}
// Submit business name and complete setup
if (current?.id === "business" && businessName.trim()) {
setLoading(true);
setError(null);
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
body: JSON.stringify({ businessName: businessName.trim() }),
});
if (!res.ok) {
const data = await res.json();
const data = (await res.json()) as { error?: string };
setError(data.error || "Setup failed. Please try again.");
setLoading(false);
return;
}
// Refresh branding so the nav bar shows the new business name
refreshBranding();
// Clear needsSetup state in App so the redirect to /admin sticks
if (onSetupComplete) onSetupComplete();
} catch (e) {
} catch {
setError("Network error. Please try again.");
setLoading(false);
return;
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
);
}
const inputStyle = {
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.6rem 0.85rem",
borderRadius: 8,
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
maxWidth: 480,
width: "100%",
}}>
{/* Progress dots */}
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
{STEPS.map((_, i) => (
<div
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
))}
</div>
{/* Step indicator */}
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
Step {step + 1} of {STEPS.length}
</p>
{/* Title */}
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
{current?.title}
</h2>
{/* Description */}
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
{current?.description}
</p>
{/* Step: Business name input */}
{current?.id === "business" && (
<input
type="text"
placeholder="e.g. Happy Paws Grooming"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
autoFocus
style={inputStyle}
/>
)}
{/* Step: Auth provider config form */}
{current?.id === "auth" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
{/* Provider ID */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Provider ID
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Display Name */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Display Name
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Issuer URL */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Issuer URL
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Internal Base URL (optional) */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Client ID */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client ID
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Client Secret */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client Secret
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Scopes */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Scopes
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
/>
</div>
{/* Test Connection button */}
<button
type="button"
onClick={handleTestConnection}
onClick={() => { void handleTestConnection(); }}
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
style={{
padding: "0.45rem 0.85rem",
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
{testingConnection ? "Testing..." : "Test Connection"}
</button>
{/* Test result */}
{testResult && (
<div style={{
padding: "0.5rem 0.75rem",
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
</div>
)}
{/* Step: Super user info */}
{current?.id === "superuser" && (
<div style={{
background: "#f0fdf4",
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
</div>
)}
{/* Step: Second admin info */}
{current?.id === "admin" && (
<div style={{
background: "#fffbeb",
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
</div>
)}
{/* Error message */}
{error && (
<p style={{
margin: "0.5rem 0 0",
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
</p>
)}
{/* Navigation buttons */}
<div style={{
display: "flex",
gap: "0.75rem",
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
</button>
)}
<button
onClick={handleNext}
onClick={() => { void handleNext(); }}
disabled={(!canGoNext && !isLast) || loading}
style={{
padding: "0.55rem 1.25rem",
+4 -3
View File
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
import { useBranding } from "../BrandingContext.js";
import { getDevUser } from "../pages/DevLoginSelector.js";
import type { ImpersonationSession } from "@groombook/types";
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
@@ -34,7 +35,7 @@ export function CustomerPortal() {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false);
const [clientName, setClientName] = useState<string>("");
@@ -149,7 +150,7 @@ export function CustomerPortal() {
const handleReschedule = useCallback((appointmentId: string) => {
// Look up the full appointment from Dashboard's displayed data
// The appointment was already fetched by Dashboard, so we use the ID to find it
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
setShowReschedule(true);
}, []);
@@ -227,7 +228,7 @@ export function CustomerPortal() {
{showReschedule && rescheduleAppointment && (
<RescheduleFlow
appointment={rescheduleAppointment as any}
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
/>
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
interface Appointment {
export interface Appointment {
id: string;
petId: string;
serviceId: string;
-4
View File
@@ -399,7 +399,6 @@ async function seedKnownUsers() {
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
@@ -426,7 +425,6 @@ async function seedKnownUsers() {
name: "UAT Super User",
email: "uat-super@groombook.dev",
oidcSub: uatSuperOidcSub,
userId: uatSuperOidcSub,
role: "manager",
isSuperUser: true,
active: true,
@@ -453,7 +451,6 @@ async function seedKnownUsers() {
name: "UAT Staff Groomer",
email: "uat-groomer@groombook.dev",
oidcSub: uatStaffOidcSub,
userId: uatStaffOidcSub,
role: "groomer",
isSuperUser: false,
active: true,
@@ -648,7 +645,6 @@ async function seed() {
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
+2
View File
@@ -40,6 +40,8 @@ export interface Pet {
shampooPreference: string | null;
specialCareNotes: string | null;
customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
createdAt: string;
updatedAt: string;
}