From cab17e02305278bef5e40c73bc610ca9f4ba9256 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Thu, 16 Apr 2026 10:39:40 +0000 Subject: [PATCH 01/18] docs: add CONTRIBUTING.md with branch strategy Document the three-branch GitOps model (dev/uat/main), developer workflow, promotion flow, and branch protection rules. Refs GRO-702 Co-Authored-By: Paperclip --- CONTRIBUTING.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8dd1af6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# 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 assigns the promotion PR to the CEO: + +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. -- 2.52.0 From 4a65c30d40e57fdeedec6f1cd6e73d763379709e Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Thu, 16 Apr 2026 10:43:12 +0000 Subject: [PATCH 02/18] =?UTF-8?q?docs:=20fix=20bash=20snippet=20quoting=20?= =?UTF-8?q?and=20add=20uat=E2=86=92main=20pr=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix \n quoting in two gh pr create commands: use ANSI-C $'...' quoting so newlines render correctly in PR bodies (not literal \n) - Add missing gh pr create example for the UAT → main promotion step Addresses Greptile review feedback on PR #304. Co-Authored-By: Paperclip --- CONTRIBUTING.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dd1af6..38582cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ GroomBook uses a three-branch GitOps model: 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" + --body $'Closes GRO-NNN\n\ncc @cpfarhood' ``` 3. **Pipeline gates before merge to `dev`:** @@ -41,7 +41,7 @@ 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" + --body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood' ``` Gates: @@ -51,7 +51,13 @@ Gates: ### UAT → Main (Production) -After UAT passes, the CTO assigns the promotion PR to the CEO: +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 -- 2.52.0 From 85c76b5209c7e9d801fe9818f72d8e40f2880136 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:58:03 +0000 Subject: [PATCH 03/18] fix(GRO-724): rename dev hostname from groombook.dev.farh.net to dev.groombook.dev (#308) Updates playwright baseURL to the canonical dev.groombook.dev FQDN per canonical infra targets. Co-authored-by: Flea Flicker Co-authored-by: Paperclip --- apps/web/e2e/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts index fad7857..ddc725b 100644 --- a/apps/web/e2e/playwright.config.ts +++ b/apps/web/e2e/playwright.config.ts @@ -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", -- 2.52.0 From 0abb79010d303f535885802dff8fddbbc1144793 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 16 Apr 2026 17:41:05 +0000 Subject: [PATCH 04/18] fix(GRO-639): replace sql ANY() with inArray for Drizzle compatibility Use Drizzle's inArray() instead of raw sql template with = ANY() to avoid PostgreSQL array binding issues in the reminder scheduler bulk sent-check query. Co-Authored-By: Paperclip --- apps/api/src/services/reminders.ts | 146 +++++++++++++++-------------- 1 file changed, 78 insertions(+), 68 deletions(-) diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 5aa2abc..1981258 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -5,6 +5,7 @@ import { eq, getDb, gte, + inArray, lt, appointments, clients, @@ -59,68 +60,77 @@ export async function runReminderCheck(): Promise { ) ); + 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(); + 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 { .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 { } } - 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.52.0 From 5df8837b5f6eb7c7943f8e94ff181016bf66551d Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 02:08:08 +0000 Subject: [PATCH 05/18] ci: add dev to pull_request branch list Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19f391c..b95ad64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] workflow_dispatch: inputs: ref: -- 2.52.0 From edf2ef8f7e85ff3b95982773825c80390ed111f5 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:35:33 +0000 Subject: [PATCH 06/18] fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email (#314) The resolveStaffMiddleware auto-links on first API call when staff.user_id IS NULL. Setting userId at seed time blocks this path since Better-Auth's user.id is opaque and unknown pre-auth. Remove userId from all staff inserts so the middleware can populate it on first authenticated call. Co-authored-by: Test User Co-authored-by: Paperclip --- packages/db/src/seed.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index a19f254..dca21d4 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -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.52.0 From f8ea417799f4caefd7d0b8db534aaf304e6bbaec Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 06:45:06 +0000 Subject: [PATCH 07/18] fix(GRO-642): sanitize logo MIME type to prevent XSS in data URL rendering Add ALLOWED_LOGO_TYPES allowlist check before constructing data URL from user-controlled logoBase64 and logoMimeType fields. Only MIME types that the API explicitly accepts (image/png, image/jpeg, image/gif, image/webp, image/svg+xml) can be rendered as data URLs. Co-Authored-By: Paperclip --- apps/web/src/pages/Settings.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 5ccb943..291b5e1 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -27,6 +27,8 @@ interface AuthProviderForm { const REDACTED = "••••••••"; +const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"]); + interface CurrentUser { id: string; name: string; @@ -326,7 +328,7 @@ issuerUrl: authForm.issuerUrl, if (!loaded) return

Loading settings...

; - 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 (
-- 2.52.0 From b00d6a8ca060dab01fb9a9d5d2f6f02a6bac818c Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 06:46:24 +0000 Subject: [PATCH 08/18] fix(GRO-642): restrict allowed logo MIME types to bitmap formats only Exclude image/svg+xml from the frontend allowlist since SVG poses greater XSS risk due to its ability to contain scripts, even with proper Content-Type validation. The server-side validation (commit 8182870) still accepts SVG and validates magic bytes, but the frontend restrict to safer bitmap formats as specified in the issue. Co-Authored-By: Paperclip --- apps/web/src/pages/Settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 291b5e1..8d70d06 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -27,7 +27,7 @@ interface AuthProviderForm { const REDACTED = "••••••••"; -const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"]); +const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); interface CurrentUser { id: string; -- 2.52.0 From ea7bf4f49b6641598ff408d4f9e3e9230b15b46a Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 11:31:06 +0000 Subject: [PATCH 09/18] fix(GRO-749): use correct impersonation header in portal Appointments Replace Authorization: Bearer with X-Impersonation-Session-Id in all 5 mutation handlers in Appointments.tsx (confirm, cancel, save-notes, reschedule, booking). The portal backend validates X-Impersonation-Session-Id header, not Authorization Bearer. Co-Authored-By: Paperclip --- apps/web/src/portal/sections/Appointments.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 65e4c18..f5fad62 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -379,7 +379,7 @@ export function ConfirmationSection({ try { const headers: Record = {}; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { method: 'POST', @@ -455,7 +455,7 @@ function CancelAppointmentButton({ try { const headers: Record = {}; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { method: 'POST', @@ -507,7 +507,7 @@ export function CustomerNotesSection({ try { const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { method: 'PATCH', @@ -600,7 +600,7 @@ export function RescheduleFlow({ setError(null); try { const headers: Record = { 'Content-Type': 'application/json' }; - if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`; + if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? ''; const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, { method: 'POST', headers, @@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${sessionId}`, + 'X-Impersonation-Session-Id': sessionId ?? '', }, body: JSON.stringify({ petId: selectedPet.id, -- 2.52.0 From 89505a2363f5c656c074ec77bf6becb9feef90fd Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 12:14:49 +0000 Subject: [PATCH 10/18] fix(GRO-749): update test assertions to use X-Impersonation-Session-Id header QA found test assertion failures - tests were asserting the old (incorrect) Authorization: Bearer header instead of the correct X-Impersonation-Session-Id. Co-Authored-By: Paperclip --- apps/web/src/__tests__/Appointments.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx index b223866..bc42a07 100644 --- a/apps/web/src/__tests__/Appointments.test.tsx +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => { "/api/portal/appointments/appt-1/notes", expect.objectContaining({ headers: expect.objectContaining({ - "Authorization": "Bearer test-session-id", + "X-Impersonation-Session-Id": "test-session-id", }), }) ); @@ -269,7 +269,7 @@ describe("ConfirmationSection", () => { "/api/portal/appointments/appt-1/confirm", expect.objectContaining({ headers: expect.objectContaining({ - "Authorization": "Bearer test-session-id", + "X-Impersonation-Session-Id": "test-session-id", }), }) ); -- 2.52.0 From 6046594a15afbbb4c9b50e344bcf417a1346102f Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 12:29:14 +0000 Subject: [PATCH 11/18] fix(GRO-642): add ARIA dialog attributes to remaining modals Add role="dialog", aria-modal="true", focus trap, Escape-to-close, and focus-restore-on-close to Invoices.tsx and Clients.tsx Modal components, and to the two inline modals in BillingPayments.tsx. Co-Authored-By: Paperclip --- apps/web/src/pages/Clients.tsx | 46 ++++++++++++++- apps/web/src/pages/Invoices.tsx | 56 +++++++++++++++++-- .../src/portal/sections/BillingPayments.tsx | 52 +++++++++++++++-- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 0e40212..8f89d52 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -857,12 +857,56 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(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(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 (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 2cbf3ae..6b9424c 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -686,19 +686,63 @@ export function InvoicesPage() { // ─── Shared UI helpers ──────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(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(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 (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 6bcfb17..d47bea4 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { loadStripe } from "@stripe/stripe-js"; import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; @@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr const [isProcessing, setIsProcessing] = useState(false); const [isComplete, setIsComplete] = useState(false); const [error, setError] = useState(null); + const completeModalRef = useRef(null); + const paymentModalRef = useRef(null); + + // Focus trap + Escape-to-close for both inline modals + useEffect(() => { + const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current; + if (!modalRef) return; + + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !modalRef) return; + const focusables = modalRef.querySelectorAll(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(); + }; + }, [isComplete, onClose]); const formatCents = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); @@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr if (isComplete) { return ( -
-
+
+
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr } return ( -
-
+
+

Pay Outstanding Balance

-
+
{renderSection()}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 6bcfb17..27709d1 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
)} -
+
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard }, -- 2.52.0 From 77971a1ac9dee3eb64621270f9ce82b4b53da96d Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:13:44 +0000 Subject: [PATCH 14/18] fix(GRO-769): proxy logo uploads through API server to fix mixed content (#325) * fix(GRO-766): prevent horizontal overflow on portal mobile pages - Add overflow-x-hidden to main content area in CustomerPortal - Add w-full overflow-hidden to content wrapper div - Add flex-wrap to BillingPayments tab button row Co-Authored-By: Paperclip * fix(GRO-769): proxy logo uploads through API server to fix mixed content The pre-signed URL flow used an internal HTTP endpoint for S3 uploads, which browsers blocked as mixed content on HTTPS pages. Instead of generating a pre-signed URL that the browser uploads to directly, the new /logo/upload endpoint receives the file via multipart POST and streams it to S3 from the API server using the internal endpoint. This resolves the mixed content error that was blocking logo uploads on dev.groombook.dev. Co-Authored-By: Paperclip --------- Co-authored-by: Test User Co-authored-by: Paperclip --- apps/api/src/lib/s3.ts | 19 +++++ apps/api/src/routes/settings.ts | 73 ++++++++++++++++++- apps/web/src/pages/Settings.tsx | 42 +++-------- apps/web/src/portal/CustomerPortal.tsx | 4 +- .../src/portal/sections/BillingPayments.tsx | 2 +- 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/apps/api/src/lib/s3.ts b/apps/api/src/lib/s3.ts index c242ff9..b0793c5 100644 --- a/apps/api/src/lib/s3.ts +++ b/apps/api/src/lib/s3.ts @@ -67,3 +67,22 @@ export async function deleteObject(key: string): Promise { }) ); } + +/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */ +export async function putObject( + key: string, + body: Buffer | Uint8Array | string, + contentType: string, + contentLength: number +): Promise { + const client = getS3Client(); + await client.send( + new PutObjectCommand({ + Bucket: getBucket(), + Key: key, + Body: body, + ContentType: contentType, + ContentLength: contentLength, + }) + ); +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index ec1f8fa..fe06b80 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, businessSettings } from "@groombook/db"; -import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js"; +import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js"; import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); @@ -100,6 +100,77 @@ settingsRouter.post( } ); +/** + * POST /api/admin/settings/logo/upload + * Proxy upload through the API server to avoid mixed-content issues with + * pre-signed URLs that use the internal HTTP endpoint. The file is uploaded + * directly to S3 from the server using the internal endpoint. + */ +settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => { + const db = getDb(); + + // Parse multipart form data (file field) + const body = await c.req.parseBody({ all: true }); + const file = body["file"]; + + if (!file || !(file instanceof File)) { + return c.json({ error: "No file provided" }, 400); + } + + const contentType = file.type; + if (!ALLOWED_LOGO_TYPES.has(contentType)) { + return c.json( + { + error: + "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }, + 400 + ); + } + + const fileSizeBytes = file.size; + if (fileSizeBytes > MAX_LOGO_SIZE) { + return c.json({ error: "File must not exceed 512 KB" }, 400); + } + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + + // Read file into buffer and upload directly to S3 (bypasses pre-signed URL) + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await putObject(key, buffer, contentType, fileSizeBytes); + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + // Update database with new logo key + const [updated] = await db + .update(businessSettings) + .set({ + logoKey: key, + logoBase64: null, + logoMimeType: null, + updatedAt: new Date(), + }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + if (!updated) { + return c.json({ error: "Settings not found" }, 404); + } + + return c.json({ ok: true, logoKey: updated.logoKey }); +}); + /** * POST /api/admin/settings/logo/confirm * Called after the client has successfully uploaded to the presigned URL. diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 8d70d06..c1f01ce 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -158,46 +158,28 @@ export function SettingsPage() { } try { - // Step 1: Get presigned upload URL - const uploadRes = await fetch("/api/admin/settings/logo/upload-url", { + // Upload directly through the API server to avoid mixed-content issues + // with pre-signed URLs that use the internal HTTP endpoint + const formData = new FormData(); + formData.append("file", file); + + const uploadRes = await fetch("/api/admin/settings/logo/upload", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }), + body: formData, }); if (!uploadRes.ok) { const err = await uploadRes.json().catch(() => null); - throw new Error(err?.error ?? "Failed to get upload URL"); + throw new Error(err?.error ?? "Failed to upload logo"); } - const { uploadUrl, key } = await uploadRes.json(); + const { logoKey } = await uploadRes.json(); - // Step 2: PUT the file directly to S3 - const putRes = await fetch(uploadUrl, { - method: "PUT", - headers: { "Content-Type": file.type }, - body: file, - }); - if (!putRes.ok) { - throw new Error("Failed to upload logo to storage"); - } - - // Step 3: Confirm the upload - const confirmRes = await fetch("/api/admin/settings/logo/confirm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }); - if (!confirmRes.ok) { - const err = await confirmRes.json().catch(() => null); - throw new Error(err?.error ?? "Failed to confirm logo upload"); - } - - // Step 4: Fetch the presigned GET URL for display + // Fetch the presigned GET URL for display const logoRes = await fetch("/api/admin/settings/logo"); if (logoRes.ok) { const logoData = await logoRes.json(); - setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); + setForm((f) => ({ ...f, logoKey, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); } else { - setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null })); + setForm((f) => ({ ...f, logoKey, logoUrl: null, logoBase64: null, logoMimeType: null })); } setMessage({ type: "success", text: "Logo uploaded." }); refresh(); diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 89bc750..a542cc0 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -326,7 +326,7 @@ export function CustomerPortal() { )} {/* Main Content */} -
+

@@ -340,7 +340,7 @@ export function CustomerPortal() {

-
+
{renderSection()}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index d47bea4..e4d2902 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
)} -
+
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard }, -- 2.52.0 From 8ecbfbeee48ecf80f0f8dfdff4c8c874083c419e Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:23:09 +0000 Subject: [PATCH 15/18] fix(GRO-743): add dedicated client detail route with unconditional data fetch (#316) Direct navigation to /admin/clients/{id} now: - Fetches GET /api/clients/{id} on mount (unconditional) - Fetches GET /api/pets?clientId= on mount - Shows loading state while fetching - Shows error state on failure (401/404/5xx) - Preserves existing link-based navigation from ClientsPage Added ClientDetailPage.tsx as a standalone route component. Added 3 E2E tests covering direct nav, loading state, and error state. Co-authored-by: Test User Co-authored-by: Paperclip --- apps/e2e/tests/clients.spec.ts | 49 +++++ apps/web/src/App.tsx | 2 + apps/web/src/pages/ClientDetailPage.tsx | 236 ++++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 apps/web/src/pages/ClientDetailPage.tsx diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 64cbcbc..eb766f1 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -63,3 +63,52 @@ test("clicking a client shows their details", async ({ page }) => { // Email appears in both the list row and the detail panel once selected await expect(page.getByText("alice@example.com")).toHaveCount(2); }); + +test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => { + // Mock individual client fetch for direct navigation + await page.route("/api/clients/client-1", (route) => + route.fulfill({ json: MOCK_CLIENTS[0] }) + ); + // Mock pets for this client + await page.route("/api/pets**", (route) => + route.fulfill({ json: [] }) + ); + + await page.goto("/admin/clients/client-1"); + // Client name must be visible without any clicking + await expect(page.getByText("Alice Johnson")).toBeVisible(); + // Should show back to list link + await expect(page.getByText("← Back to list")).toBeVisible(); +}); + +test("direct URL navigation shows loading then client", async ({ page }) => { + let resolvePets: (value: unknown) => void; + const petsPromise = new Promise((resolve) => { resolvePets = resolve; }); + + await page.route("/api/clients/client-1", (route) => + route.fulfill({ json: MOCK_CLIENTS[0] }) + ); + await page.route("/api/pets**", async (route) => { + await petsPromise; + await route.fulfill({ json: [] }); + }); + + const navigationPromise = page.goto("/admin/clients/client-1"); + // Should show loading state briefly + await expect(page.getByText("Loading client…")).toBeVisible(); + // Resolve pets and wait for navigation + resolvePets!(); + await navigationPromise; + // After data loads, client name is shown + await expect(page.getByText("Alice Johnson")).toBeVisible(); +}); + +test("direct URL navigation shows error state on failure", async ({ page }) => { + await page.route("/api/clients/nonexistent", (route) => + route.fulfill({ status: 404, json: { error: "Client not found" } }) + ); + + await page.goto("/admin/clients/nonexistent"); + await expect(page.getByText(/client not found/i)).toBeVisible(); + await expect(page.getByText("← Back to clients")).toBeVisible(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 83e95d6..ea51314 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r import { useEffect, useState } from "react"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; +import { ClientDetailPage } from "./pages/ClientDetailPage.js"; import { ServicesPage } from "./pages/Services.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; @@ -296,6 +297,7 @@ function AdminLayout() { } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/pages/ClientDetailPage.tsx b/apps/web/src/pages/ClientDetailPage.tsx new file mode 100644 index 0000000..fdb9d19 --- /dev/null +++ b/apps/web/src/pages/ClientDetailPage.tsx @@ -0,0 +1,236 @@ +import { useEffect, useState, useCallback } from "react"; +import { useParams, Link } from "react-router-dom"; +import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; +import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; +import { PetPhotoUpload } from "../components/PetPhotoUpload.js"; + +export function ClientDetailPage() { + const { clientId } = useParams<{ clientId: string }>(); + const [client, setClient] = useState(null); + const [pets, setPets] = useState([]); + const [visitLogs, setVisitLogs] = useState>({}); + const [logsLoading, setLogsLoading] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [photoRevisions, setPhotoRevisions] = useState>({}); + + const handlePhotoUploaded = useCallback((petId: string) => { + setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 })); + }, []); + + useEffect(() => { + if (!clientId) { + setError("No client ID provided"); + setLoading(false); + return; + } + + async function load() { + const id = clientId!; + setLoading(true); + setError(null); + try { + const [clientRes, petsRes] = await Promise.all([ + fetch(`/api/clients/${encodeURIComponent(id)}`), + fetch(`/api/pets?clientId=${encodeURIComponent(id)}`), + ]); + + if (!clientRes.ok) { + const err = await clientRes.json().catch(() => ({})) as { error?: string }; + throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`); + } + if (!petsRes.ok) { + throw new Error(`Pets fetch failed: ${petsRes.status}`); + } + + setClient(await clientRes.json() as Client); + setPets(await petsRes.json() as Pet[]); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load client"); + } finally { + setLoading(false); + } + } + + void load(); + }, [clientId]); + + async function loadVisitLogs(petId: string) { + setLogsLoading((prev) => ({ ...prev, [petId]: true })); + const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`); + if (r.ok) { + const logs = await r.json() as GroomingVisitLog[]; + setVisitLogs((prev) => ({ ...prev, [petId]: logs })); + } + setLogsLoading((prev) => ({ ...prev, [petId]: false })); + } + + if (loading) { + return ( +
+ Loading client… +
+ ); + } + + if (error || !client) { + return ( +
+
+ ← Back to clients +
+
+ {error ?? "Client not found"} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

{client.name}

+ {client.status === "disabled" && ( + + Disabled + + )} +
+ {client.email &&
{client.email}
} + {client.phone &&
{client.phone}
} + {client.address &&
{client.address}
} + {client.notes && ( +
+ {client.notes} +
+ )} +
+ + ← Back to list + +
+ + {/* Pets */} +
+

Pets

+
+ + {pets.length === 0 ? ( +

No pets on file for this client.

+ ) : ( +
+ {pets.map((p) => ( +
+ {/* Photo + header */} +
+ +
+
+ {p.name} +
+
+ {p.species}{p.breed ? ` · ${p.breed}` : ""} +
+ {p.weightKg != null &&
{p.weightKg} kg
} + {p.dateOfBirth &&
Born {new Date(p.dateOfBirth).toLocaleDateString()}
} +
+ handlePhotoUploaded(p.id)} /> +
+
+
+ + {p.healthAlerts && ( +
+ ⚠ Health alerts: {p.healthAlerts} +
+ )} + + {/* Grooming preferences */} + {(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && ( +
+ {p.cutStyle && ( +
+ Cut: {p.cutStyle} +
+ )} + {p.shampooPreference && ( +
+ Shampoo: {p.shampooPreference} +
+ )} + {p.specialCareNotes && ( +
+ Special care: {p.specialCareNotes} +
+ )} + {p.groomingNotes && ( +
+ Notes: {p.groomingNotes} +
+ )} +
+ )} + + {/* Visit history */} + {(() => { + const logs = visitLogs[p.id]; + const loadingLogs = logsLoading[p.id]; + return ( +
+
+
VISIT HISTORY
+ {!logs && !loadingLogs && ( + + )} +
+ {loadingLogs &&
Loading…
} + {logs && logs.length === 0 &&
No visits yet
} + {logs && logs.length > 0 && ( + <> + {logs.slice(0, 3).map((log) => ( +
+ {new Date(log.groomedAt).toLocaleDateString()} + {log.cutStyle && · {log.cutStyle}} + {log.notes && · {log.notes}} +
+ ))} + {logs.length > 3 && ( +
+{logs.length - 3} more visits
+ )} + + )} +
+ ); + })()} +
+ ))} +
+ )} +
+ ); +} -- 2.52.0 From 4001691ae770e979eba80738980814faf2d5c322 Mon Sep 17 00:00:00 2001 From: "lint-roller-qa[bot]" <269744346+lint-roller-qa[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:04:41 +0000 Subject: [PATCH 16/18] fix(GRO-773): raise auth rate-limit threshold and exempt /get-session (#327) Raise the Better Auth rate limit from max:10/window:60 to max:100/window:10 to match library defaults, and exempt /get-session from rate limiting entirely via customRules (returns null = no rate limit check). Both AUTH_DISABLED and production rateLimit blocks updated. Co-authored-by: Test User Co-authored-by: Paperclip --- apps/api/src/lib/auth.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 37a51b0..209e9d6 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -93,9 +93,12 @@ export async function initAuth(): Promise { baseURL: BETTER_AUTH_URL, rateLimit: { enabled: true, - max: 10, - window: 60, + max: 100, + window: 10, storage: "memory", + customRules: { + "/get-session": false, + }, }, plugins: [ genericOAuth({ @@ -240,9 +243,12 @@ export async function initAuth(): Promise { baseURL: BETTER_AUTH_URL, rateLimit: { enabled: true, - max: 10, - window: 60, + max: 100, + window: 10, storage: "memory", + customRules: { + "/get-session": false, + }, }, account: { storeStateStrategy: "cookie" as const, -- 2.52.0 From 56477809c91bb9b30864eb04a9d39cbabf054329 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 17 Apr 2026 18:25:04 +0000 Subject: [PATCH 17/18] feat(GRO-785): validate tip split totals before marking invoice paid - PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits exist or splits don't sum to 100% - POST /invoices/:id/tip-splits now returns 400 (not 422) on validation failure via router-level ZodError handler Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 2714be4..f59fc58 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -18,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js"; export const invoicesRouter = new Hono(); +// Convert Zod validation errors from 422 to 400 +invoicesRouter.onError((err, c) => { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation failed", issues: err.issues }, 400); + } + throw err; +}); + const createInvoiceSchema = z.object({ appointmentId: z.string().uuid().optional(), clientId: z.string().uuid(), @@ -334,6 +342,29 @@ invoicesRouter.patch( } } + // Validate tip splits when marking invoice as paid + if (body.status === "paid" && current.tipCents > 0) { + const splits = await db + .select() + .from(invoiceTipSplits) + .where(eq(invoiceTipSplits.invoiceId, id)); + + if (splits.length === 0) { + return c.json( + { error: "Tip split percentages must sum to 100%" }, + 400 + ); + } + + const totalBps = splits.reduce((sum, s) => sum + Math.round(Number(s.sharePct) * 100), 0); + if (totalBps !== 10000) { + return c.json( + { error: "Tip split percentages must sum to 100%" }, + 400 + ); + } + } + const update: Record = { ...body, updatedAt: new Date() }; // Auto-set paidAt when marking as paid -- 2.52.0 From 23385aa6debb4b42a209b8b801946c1f4e9836ec Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 17 Apr 2026 18:29:01 +0000 Subject: [PATCH 18/18] feat(GRO-786): add ARIA label attributes to Modal dialog component - Update Modal component to accept title and titleStyle props - Add role="dialog", aria-modal="true", and aria-labelledby attributes - Use useId() to generate stable ID for title heading association - Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet, Log Grooming Visit, Permanently Delete Client) with title props - Delete modal passes titleStyle for red color on warning Co-Authored-By: Paperclip --- apps/web/src/pages/Clients.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 0e40212..c62690f 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useId } from "react"; import { useSearchParams } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; @@ -647,8 +647,7 @@ export function ClientsPage() { {/* ── Client modal ── */} {showClientForm && ( - setShowClientForm(false)}> -

{editingClient ? "Edit Client" : "New Client"}

+ setShowClientForm(false)}>
setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -678,8 +677,7 @@ export function ClientsPage() { {/* ── Pet modal ── */} {showPetForm && ( - setShowPetForm(false)}> -

{editingPet ? "Edit Pet" : "Add Pet"}

+ setShowPetForm(false)}> setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -753,8 +751,7 @@ export function ClientsPage() { {/* ── Visit log modal ── */} {showLogForm && logPetId && ( - setShowLogForm(false)}> -

Log Grooming Visit

+ setShowLogForm(false)}> {logsLoading[logPetId] &&

Loading history…

} {visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
@@ -817,8 +814,7 @@ export function ClientsPage() { {/* ── Delete confirmation modal ── */} {showDeleteConfirm && selectedClient && ( - setShowDeleteConfirm(false)}> -

Permanently Delete Client

+ setShowDeleteConfirm(false)}>

This will permanently delete {selectedClient.name} and all their pets. This action cannot be undone.

@@ -856,13 +852,20 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── -function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { +function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) { + const titleId = useId(); return (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
+

{title}

{children}
-- 2.52.0