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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..38582cf --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a3d97fd..c6e90a5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 = { + "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, }); }); diff --git a/apps/api/src/middleware/portalAudit.ts b/apps/api/src/middleware/portalAudit.ts new file mode 100644 index 0000000..a18129d --- /dev/null +++ b/apps/api/src/middleware/portalAudit.ts @@ -0,0 +1,45 @@ +import type { MiddlewareHandler } from "hono"; +import { getDb, impersonationAuditLogs } from "@groombook/db"; +import type { PortalEnv } from "./portalSession.js"; + +/** + * Server-side audit logging middleware for portal routes. + * Applied after validatePortalSession in the middleware chain. + * + * After the route handler completes (await next()), inserts an audit log entry + * into impersonationAuditLogs: + * - sessionId: from c.get("portalSessionId") + * - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments") + * - pageVisited: c.req.path + * - metadata: { method, statusCode: c.res.status } + * + * Log entries are written for both success and error responses. + * Does NOT throw if audit logging fails — errors are logged but the user's + * request is not affected. + */ +export const portalAudit: MiddlewareHandler = async (c, next) => { + await next(); + + const sessionId = c.get("portalSessionId"); + if (!sessionId) return; + + const method = c.req.method; + const routePath = c.req.path; + const pageVisited = c.req.path; + const statusCode = c.res.status; + + try { + const db = getDb(); + await db + .insert(impersonationAuditLogs) + .values({ + sessionId, + action: `${method} ${routePath}`, + pageVisited, + metadata: { method, statusCode }, + }) + .returning(); + } catch (err) { + console.error("[portalAudit] Failed to write audit log:", err); + } +}; diff --git a/apps/api/src/middleware/portalSession.ts b/apps/api/src/middleware/portalSession.ts new file mode 100644 index 0000000..6dfdb03 --- /dev/null +++ b/apps/api/src/middleware/portalSession.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandler } from "hono"; +import { and, eq, getDb, impersonationSessions } from "@groombook/db"; + +export interface PortalEnv { + Variables: { + portalClientId: string; + portalSessionId: string; + }; +} + +/** + * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. + * Must be applied to all portal routes. + * + * Reads x-session-id from request headers, queries impersonationSessions for a row where + * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). + * Returns 401 if session is invalid/missing/expired. + * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). + */ +export const validatePortalSession: MiddlewareHandler = async (c, next) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + c.set("portalClientId", session.clientId); + c.set("portalSessionId", session.id); + await next(); +}; diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 8b10b56..d768bc8 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,33 +1,22 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, inArray } from "@groombook/db"; +import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; -import type { AppEnv } from "../middleware/rbac.js"; +import { validatePortalSession } from "../middleware/portalSession.js"; +import { portalAudit } from "../middleware/portalAudit.js"; +import type { PortalEnv } from "../middleware/portalSession.js"; -export const portalRouter = new Hono(); +export const portalRouter = new Hono(); -// ─── Session helper ─────────────────────────────────────────────────────────── - -async function getClientIdFromSession(sessionId: string | null | undefined): Promise { - if (!sessionId) return null; - const db = getDb(); - const [session] = await db - .select() - .from(impersonationSessions) - .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) - .limit(1); - if (!session || session.expiresAt <= new Date()) return null; - return session.clientId; -} +// Apply middleware to all portal routes +portalRouter.use("/*", validatePortalSession, portalAudit); // ─── GET routes ────────────────────────────────────────────────────────────── portalRouter.get("/me", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); if (!client) return c.json({ error: "Not found" }, 404); @@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => { portalRouter.get("/appointments", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const now = new Date(); const allAppts = await db @@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => { portalRouter.get("/pets", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); @@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => { portalRouter.get("/invoices", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const invoiceIds = clientInvoices.map(i => i.id); @@ -148,12 +131,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -196,12 +174,7 @@ portalRouter.patch( portalRouter.post("/appointments/:id/confirm", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -319,28 +287,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - let clientId: string | null = null; - if (sessionId) { - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - if (session && session.expiresAt > new Date()) { - clientId = session.clientId; - } - } - - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .insert(waitlistEntries) @@ -364,26 +311,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [existing] = await db .select() @@ -392,7 +320,7 @@ portalRouter.patch( .limit(1); if (!existing) return c.json({ error: "Not found" }, 404); - if (existing.clientId !== session.clientId) { + if (existing.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -414,26 +342,7 @@ portalRouter.patch( portalRouter.delete("/waitlist/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .select() @@ -442,7 +351,7 @@ portalRouter.delete("/waitlist/:id", async (c) => { .limit(1); if (!entry) return c.json({ error: "Not found" }, 404); - if (entry.clientId !== session.clientId) { + if (entry.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -475,9 +384,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const invoiceRows = await db .select() @@ -514,9 +421,7 @@ portalRouter.post( ); portalRouter.get("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const methods = await listPaymentMethods(clientId); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); @@ -524,9 +429,7 @@ portalRouter.get("/payment-methods", async (c) => { }); portalRouter.post("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const customerId = await getOrCreateStripeCustomer(clientId); @@ -539,9 +442,7 @@ portalRouter.post("/payment-methods", async (c) => { }); portalRouter.delete("/payment-methods/:id", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const paymentMethodId = c.req.param("id"); 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) 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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 28bde7c..83e95d6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +12,7 @@ import { SettingsPage } from "./pages/Settings.js"; import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; import { BookingCancelledPage } from "./pages/BookingCancelled.js"; import { BookingErrorPage } from "./pages/BookingError.js"; -import { SetupWizard } from "./pages/SetupWizard.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"; 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", }), }) ); diff --git a/apps/web/src/components/GlobalSearch.tsx b/apps/web/src/components/GlobalSearch.tsx index 8971fde..deb4770 100644 --- a/apps/web/src/components/GlobalSearch.tsx +++ b/apps/web/src/components/GlobalSearch.tsx @@ -26,6 +26,7 @@ export function GlobalSearch() { const [query, setQuery] = useState(""); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [open, setOpen] = useState(false); const inputRef = useRef(null); const dropdownRef = useRef(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() { )} - {!loading && !hasResults && ( + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && !hasResults && (
No results found
diff --git a/apps/web/src/components/PetPhotoUpload.tsx b/apps/web/src/components/PetPhotoUpload.tsx index f33ce48..0b479f3 100644 --- a/apps/web/src/components/PetPhotoUpload.tsx +++ b/apps/web/src/components/PetPhotoUpload.tsx @@ -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; diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 386354d..1dd7046 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -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(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(); }} >
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", diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index a3515ce..4cd2631 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -199,11 +199,11 @@ export function ReportsPage() { } const [summData, revData, apptData, svcData, clientData] = await Promise.all([ - summRes.json() as Promise, - revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>, - apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>, - svcRes.json() as Promise<{ rows: ServiceRow[] }>, - clientRes.json() as Promise, + summRes.ok ? summRes.json() as Promise : 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 : clientRes.text().then(() => { throw new Error("clients response not ok"); }), ]); setSummary(summData); diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 088a685..8d70d06 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"]); + 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

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 (
@@ -393,7 +395,7 @@ issuerUrl: authForm.issuerUrl, diff --git a/apps/web/src/pages/SetupWizard.d.ts b/apps/web/src/pages/SetupWizard.d.ts index 5758e2b..786c80d 100644 --- a/apps/web/src/pages/SetupWizard.d.ts +++ b/apps/web/src/pages/SetupWizard.d.ts @@ -1 +1 @@ -export { SetupWizard } from "./SetupWizard.jsx"; +export { SetupWizard } from "./SetupWizard.tsx"; diff --git a/apps/web/src/pages/SetupWizard.jsx b/apps/web/src/pages/SetupWizard.tsx similarity index 89% rename from apps/web/src/pages/SetupWizard.jsx rename to apps/web/src/pages/SetupWizard.tsx index 666b67c..8587519 100644 --- a/apps/web/src/pages/SetupWizard.jsx +++ b/apps/web/src/pages/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(null); const [loadingStatus, setLoadingStatus] = useState(true); - // Auth provider form state - const [authForm, setAuthForm] = useState({ + const [authForm, setAuthForm] = useState({ 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(null); const [step, setStep] = useState(0); const [businessName, setBusinessName] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); useEffect(() => { fetch("/api/setup/status") - .then((r) => r.json()) + .then((r) => r.json() as Promise) .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 */}
{STEPS.map((_, i) => (
- {/* Step indicator */}

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

- {/* Title */}

{current?.title}

- {/* Description */}

{current?.description}

- {/* Step: Business name input */} {current?.id === "business" && ( 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" && (
- {/* Provider ID */}
- {/* Display Name */}
- {/* Issuer URL */}
- {/* Internal Base URL (optional) */}
- {/* Client ID */}
- {/* Client Secret */}
- {/* Scopes */}
- {/* Test Connection button */} - {/* Test result */} {testResult && (
)} - {/* Step: Super user info */} {current?.id === "superuser" && (
)} - {/* Step: Second admin info */} {current?.id === "admin" && (
)} - {/* Error message */} {error && (

)} - {/* Navigation buttons */}

)}