From cab17e02305278bef5e40c73bc610ca9f4ba9256 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Thu, 16 Apr 2026 10:39:40 +0000 Subject: [PATCH 01/11] 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/11] =?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/11] 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/11] 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/11] 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/11] 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 772f4df62fae7657b0ab7cb18b66ff332e0b01b8 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 06:42:01 +0000 Subject: [PATCH 07/11] fix(GRO-643): add appointment indexes to schema and S3 error handling (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add idx_appointments_client_id, idx_appointments_staff_id, idx_appointments_start_time, idx_appointments_status to schema. Migration 0029 already handles the DB side; this brings schema.ts in sync so drizzle-kit push is clean going forward. - Wrap deleteObject calls in try/catch (POST /photo/confirm and DELETE /:petId/photo endpoints) so S3 failures don't abort the DB update — orphaned objects are logged as warnings instead. Co-authored-by: Test User Co-authored-by: Paperclip --- apps/api/src/routes/pets.ts | 12 ++++- packages/db/src/schema.ts | 99 ++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 47 deletions(-) diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index a6b9982..2264e6c 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -213,7 +213,11 @@ petsRouter.post( // Delete the previous photo from storage to avoid orphaned objects if (pet.photoKey) { - await deleteObject(pet.photoKey); + try { + await deleteObject(pet.photoKey); + } catch (err) { + console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err); + } } const [row] = await db @@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => { if (!pet) return c.json({ error: "Pet not found" }, 404); if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); - await deleteObject(pet.photoKey); + try { + await deleteObject(pet.photoKey); + } catch (err) { + console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err); + } await db .update(pets) .set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() }) diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0ef3ca6..0a5eaef 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -200,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", { updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -export const appointments = pgTable("appointments", { - id: uuid("id").primaryKey().defaultRandom(), - clientId: uuid("client_id") - .notNull() - .references(() => clients.id, { onDelete: "restrict" }), - petId: uuid("pet_id") - .notNull() - .references(() => pets.id, { onDelete: "restrict" }), - serviceId: uuid("service_id") - .notNull() - .references(() => services.id, { onDelete: "restrict" }), - staffId: uuid("staff_id").references(() => staff.id, { - onDelete: "set null", - }), - // Optional secondary staff (bather/assistant) for tip-split tracking - batherStaffId: uuid("bather_staff_id").references(() => staff.id, { - onDelete: "set null", - }), - status: appointmentStatusEnum("status").notNull().default("scheduled"), - startTime: timestamp("start_time").notNull(), - endTime: timestamp("end_time").notNull(), - notes: text("notes"), - // Override price at time of booking (null = use service base price) - priceCents: integer("price_cents"), - // Recurring series support - seriesId: uuid("series_id").references(() => recurringSeries.id, { - onDelete: "set null", - }), - seriesIndex: integer("series_index"), - // Multi-pet group booking: links this appointment to others in the same visit - groupId: uuid("group_id").references(() => appointmentGroups.id, { - onDelete: "set null", - }), - // Customer confirmation/cancellation tracking - // Values: "pending" | "confirmed" | "cancelled" - confirmationStatus: text("confirmation_status").notNull().default("pending"), - confirmedAt: timestamp("confirmed_at"), - cancelledAt: timestamp("cancelled_at"), - // Token for tokenized email confirm/cancel links (no auth required) - confirmationToken: text("confirmation_token").unique(), - // Customer-provided note visible to groomer (500 char max, editable until appointment starts) - customerNotes: text("customer_notes"), - createdAt: timestamp("created_at").notNull().defaultNow(), - updatedAt: timestamp("updated_at").notNull().defaultNow(), -}); +export const appointments = pgTable( + "appointments", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "restrict" }), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "restrict" }), + staffId: uuid("staff_id").references(() => staff.id, { + onDelete: "set null", + }), + // Optional secondary staff (bather/assistant) for tip-split tracking + batherStaffId: uuid("bather_staff_id").references(() => staff.id, { + onDelete: "set null", + }), + status: appointmentStatusEnum("status").notNull().default("scheduled"), + startTime: timestamp("start_time").notNull(), + endTime: timestamp("end_time").notNull(), + notes: text("notes"), + // Override price at time of booking (null = use service base price) + priceCents: integer("price_cents"), + // Recurring series support + seriesId: uuid("series_id").references(() => recurringSeries.id, { + onDelete: "set null", + }), + seriesIndex: integer("series_index"), + // Multi-pet group booking: links this appointment to others in the same visit + groupId: uuid("group_id").references(() => appointmentGroups.id, { + onDelete: "set null", + }), + // Customer confirmation/cancellation tracking + // Values: "pending" | "confirmed" | "cancelled" + confirmationStatus: text("confirmation_status").notNull().default("pending"), + confirmedAt: timestamp("confirmed_at"), + cancelledAt: timestamp("cancelled_at"), + // Token for tokenized email confirm/cancel links (no auth required) + confirmationToken: text("confirmation_token").unique(), + // Customer-provided note visible to groomer (500 char max, editable until appointment starts) + customerNotes: text("customer_notes"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_appointments_client_id").on(t.clientId), + index("idx_appointments_staff_id").on(t.staffId), + index("idx_appointments_start_time").on(t.startTime), + index("idx_appointments_status").on(t.status), + ] +); export const invoices = pgTable( "invoices", -- 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 08/11] 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 09/11] 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 56477809c91bb9b30864eb04a9d39cbabf054329 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 17 Apr 2026 18:25:04 +0000 Subject: [PATCH 10/11] 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 11/11] 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