From e8455195eedec37cc0c8875b9ed038fcd60ce158 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 14 Apr 2026 15:47:06 +0000 Subject: [PATCH 01/12] feat(GRO-631): add Docker HEALTHCHECK and update .dockerignore Co-Authored-By: Paperclip --- .dockerignore | 2 ++ apps/api/Dockerfile | 3 +++ apps/web/Dockerfile | 2 ++ 3 files changed, 7 insertions(+) diff --git a/.dockerignore b/.dockerignore index edb296c..feec617 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,5 @@ apps/web/dist apps/api/dist packages/db/dist packages/types/dist +.turbo +screenshots/ diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 1a89f85..fe0e0da 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -34,6 +34,9 @@ COPY --from=builder /app/packages/types/dist packages/types/dist RUN pnpm install --frozen-lockfile --prod EXPOSE 3000 +RUN apk add --no-cache curl +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 CMD ["node", "apps/api/dist/index.js"] # Migrate stage — runs drizzle-kit migrate against the database diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 704730b..eb110fa 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -20,3 +20,5 @@ FROM nginx:alpine AS runner COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/apps/web/dist /usr/share/nginx/html EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:80/ || exit 1 From f4f522d5e6712ba47ab1cef66f2abaeddcb4dcd3 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 14 Apr 2026 15:56:15 +0000 Subject: [PATCH 02/12] fix(GRO-631): pin pnpm version and guard against duplicate CD PRs - Pin pnpm/action-setup@v4 to version 9.15.4 in all 5 jobs - Add duplicate PR guard in CD job before gh pr create - Remove stale kubectl delete job migrate-schema command Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69b8800..19f391c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + with: + version: '9.15.4' - uses: actions/setup-node@v4 with: @@ -42,6 +44,8 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + with: + version: '9.15.4' - uses: actions/setup-node@v4 with: @@ -62,6 +66,8 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + with: + version: '9.15.4' - uses: actions/setup-node@v4 with: @@ -101,6 +107,8 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + with: + version: '9.15.4' - uses: actions/setup-node@v4 with: @@ -238,7 +246,6 @@ jobs: echo "Deploying images tagged $TAG to groombook-dev..." # Run migration with PR image - kubectl delete job migrate-schema -n groombook-dev --ignore-not-found kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found cat < Date: Tue, 14 Apr 2026 16:10:04 +0000 Subject: [PATCH 03/12] feat(GRO-631): add security headers to nginx.conf Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-XSS-Protection, and Permissions-Policy headers to server block and static assets location. Co-Authored-By: Paperclip --- apps/web/nginx.conf | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index 89955f0..a70f242 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -3,10 +3,22 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # Cache static assets location ~* \.(js|css|png|svg|ico|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; } # Proxy API calls to the API service From 70e9465b68017020447efdaacc72c4133977ba15 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 14 Apr 2026 16:22:23 +0000 Subject: [PATCH 04/12] fix(GRO-631): add tag validation to promote-prod workflow - Validate tag format against regex YYYY.MM.DD-sha7 before proceeding - Verify image exists in GHCR using gh api with packages: read permission - Add packages: read permission to job permissions block Co-Authored-By: Paperclip --- .github/workflows/promote-prod.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index 483e8cd..110d1a3 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -14,7 +14,29 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: + - name: Validate tag format + run: | + TAG="${{ inputs.tag }}" + if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then + echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)" + exit 1 + fi + echo "Tag format valid: $TAG" + + - name: Verify image exists in GHCR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ inputs.tag }}" + # Check that the API image exists — if API was pushed, web/migrate were too + if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then + echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed." + exit 1 + fi + echo "Image verified: ghcr.io/groombook/api:$TAG exists" + - name: Generate infra repo token id: infra-token uses: tibdex/github-app-token@v2 From df07f2d6dce5c264d6e5ad7e1ffdbd023285c261 Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" <269737991+groombook-cto[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:15:05 +0000 Subject: [PATCH 05/12] fix(GRO-635): implement groomer data isolation in appointmentGroups, groomingLogs + batherStaffId conflict check - appointmentGroups: Hono() + groomer isolation on all 5 endpoints - groomingLogs: Hono() + groomer isolation on GET, POST, DELETE with appointmentId preserved - appointments: batherStaffId conflict checks in POST and PATCH handlers - Non-groomer roles retain full access Co-Authored-By: Paperclip --- apps/api/src/routes/appointmentGroups.ts | 72 ++++++++++++++++- apps/api/src/routes/appointments.ts | 53 ++++++++++++- apps/api/src/routes/groomingLogs.ts | 99 ++++++++++++++++++++++-- 3 files changed, 216 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/appointmentGroups.ts b/apps/api/src/routes/appointmentGroups.ts index 8ecbb45..d28cdf6 100644 --- a/apps/api/src/routes/appointmentGroups.ts +++ b/apps/api/src/routes/appointmentGroups.ts @@ -16,8 +16,9 @@ import { services, staff, } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; -export const appointmentGroupsRouter = new Hono(); +export const appointmentGroupsRouter = new Hono(); // ─── Schemas ────────────────────────────────────────────────────────────────── @@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => { const clientId = c.req.query("clientId"); const from = c.req.query("from"); const to = c.req.query("to"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; const groupConditions = clientId ? [eq(appointmentGroups.clientId, clientId)] @@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => { })) .filter((g) => !from || g.appointments.length > 0); + if (isGroomer) { + return c.json( + result.filter((g) => + g.appointments.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) + ); + } + return c.json(result); }); @@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => { appointmentGroupsRouter.get("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; const [group] = await db .select() @@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => { serviceId: appointments.serviceId, serviceName: services.name, staffId: appointments.staffId, + batherStaffId: appointments.batherStaffId, staffName: staff.name, status: appointments.status, startTime: appointments.startTime, @@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => { .where(eq(appointments.groupId, id)) .orderBy(appointments.startTime); + if ( + isGroomer && + !groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) { + return c.json({ error: "Forbidden" }, 403); + } + const [client] = await db .select({ name: clients.name, email: clients.email }) .from(clients) @@ -140,6 +165,13 @@ appointmentGroupsRouter.post( zValidator("json", createGroupSchema), async (c) => { const db = getDb(); + const staffRow = c.get("staff"); + if (staffRow?.role === "groomer") { + return c.json( + { error: "Forbidden: groomers cannot create group bookings" }, + 403 + ); + } const body = c.req.valid("json"); const startTime = new Date(body.startTime); @@ -244,6 +276,28 @@ appointmentGroupsRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [group] = await db + .select({ id: appointmentGroups.id }) + .from(appointmentGroups) + .where(eq(appointmentGroups.id, id)); + if (!group) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const groupAppts = await db + .select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId }) + .from(appointments) + .where(eq(appointments.groupId, id)); + if ( + !groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) { + return c.json({ error: "Forbidden" }, 403); + } + } const [updated] = await db .update(appointmentGroups) @@ -261,6 +315,8 @@ appointmentGroupsRouter.patch( appointmentGroupsRouter.delete("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; const [group] = await db .select({ id: appointmentGroups.id }) @@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => { .where(eq(appointmentGroups.id, id)); if (!group) return c.json({ error: "Not found" }, 404); + if (isGroomer) { + const groupAppts = await db + .select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId }) + .from(appointments) + .where(eq(appointments.groupId, id)); + if ( + !groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) { + return c.json({ error: "Forbidden" }, 403); + } + } + await db .update(appointments) .set({ status: "cancelled", updatedAt: new Date() }) diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 6ed72e2..24d6b3c 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -163,6 +163,28 @@ appointmentsRouter.post( } } + if (apptFields.batherStaffId) { + const bathConflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, apptFields.batherStaffId), + eq(appointments.batherStaffId, apptFields.batherStaffId) + ), + lt(appointments.startTime, end), + gte(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (bathConflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + if (!recurrence) { // Single appointment const [inserted] = await tx @@ -398,7 +420,8 @@ appointmentsRouter.patch( const needsConflictCheck = updateFields.startTime !== undefined || updateFields.endTime !== undefined || - updateFields.staffId !== undefined; + updateFields.staffId !== undefined || + updateFields.batherStaffId !== undefined; const update: Record = { ...updateFields, @@ -434,6 +457,11 @@ appointmentsRouter.patch( updateFields.staffId !== undefined ? updateFields.staffId : current.staffId; + // Use provided batherStaffId (may be null to unassign); fall back to existing + const batherStaffId = + updateFields.batherStaffId !== undefined + ? updateFields.batherStaffId + : current.batherStaffId; if (end <= start) { throw Object.assign(new Error("end before start"), { @@ -461,6 +489,29 @@ appointmentsRouter.patch( } } + if (batherStaffId) { + const bathConflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, batherStaffId), + eq(appointments.batherStaffId, batherStaffId) + ), + lt(appointments.startTime, end), + gte(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, id), + ) + ) + .limit(1); + if (bathConflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + const [updated] = await tx .update(appointments) .set(update) diff --git a/apps/api/src/routes/groomingLogs.ts b/apps/api/src/routes/groomingLogs.ts index 81eeaf4..1f7f85a 100644 --- a/apps/api/src/routes/groomingLogs.ts +++ b/apps/api/src/routes/groomingLogs.ts @@ -1,9 +1,10 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db"; +import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; -export const groomingLogsRouter = new Hono(); +export const groomingLogsRouter = new Hono(); const createLogSchema = z.object({ petId: z.string().uuid(), @@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => { const db = getDb(); const petId = c.req.query("petId"); if (!petId) return c.json({ error: "petId is required" }, 400); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + if (isGroomer) { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } + const rows = await db .select() .from(groomingVisitLogs) @@ -33,11 +54,50 @@ groomingLogsRouter.post( zValidator("json", createLogSchema), async (c) => { const db = getDb(); - const { groomedAt, ...rest } = c.req.valid("json"); + const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + if (isGroomer) { + if (appointmentId) { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.id, appointmentId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } else { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } + } + const [row] = await db .insert(groomingVisitLogs) .values({ ...rest, + petId, + appointmentId: appointmentId ?? null, groomedAt: groomedAt ? new Date(groomedAt) : new Date(), }) .returning(); @@ -47,10 +107,37 @@ groomingLogsRouter.post( groomingLogsRouter.delete("/:id", async (c) => { const db = getDb(); - const [row] = await db + const id = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [log] = await db + .select() + .from(groomingVisitLogs) + .where(eq(groomingVisitLogs.id, id)) + .limit(1); + if (!log) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, log.petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } + + await db .delete(groomingVisitLogs) - .where(eq(groomingVisitLogs.id, c.req.param("id"))) + .where(eq(groomingVisitLogs.id, id)) .returning(); - if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); }); From 77a631945944ff165a5353d9d4332991fe488839 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 14 Apr 2026 21:58:44 +0000 Subject: [PATCH 06/12] fix(GRO-655): create corepack cache dir in builder stage Prevents ENOENT crash in migrate and seed jobs. Root cause: corepack tries to mkdir /home/node/.cache/node/corepack/v1 but the directory does not exist in the builder stage. This was a regression in c438f57 where the cache directory was not pre-created. Co-Authored-By: Paperclip --- apps/api/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index fe0e0da..b8b82fe 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile # Build FROM deps AS builder +RUN mkdir -p /home/node/.cache/node/corepack COPY packages/ packages/ COPY apps/api/ apps/api/ RUN pnpm --filter @groombook/types build && \ From 648755eee50ba5c4fbdc3d076d2343b74eadc365 Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" <269737991+groombook-cto[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:02:37 +0000 Subject: [PATCH 07/12] fix: add corepack cache dir to Dockerfile (GRO-655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds mkdir -p /home/node/.cache/node/corepack in builder stage to fix ENOENT crash in migration/seed jobs. Root cause: c438f57 image regression — container user's home cache directory not pre-created for corepack. Blocking: GRO-618 (UAT promotion), GRO-607 (payment UI), GRO-609 Co-Authored-By: Paperclip --- apps/api/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index fe0e0da..23ab29e 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile # Build FROM deps AS builder +RUN mkdir -p /home/node/.cache/node/corepack COPY packages/ packages/ COPY apps/api/ apps/api/ RUN pnpm --filter @groombook/types build && \ @@ -49,4 +50,4 @@ CMD ["pnpm", "db:seed"] # Reset stage — drops all tables, re-runs migrations, and re-seeds FROM builder AS reset -CMD ["pnpm", "db:reset"] +CMD ["pnpm", "db:reset"] \ No newline at end of file From 0ed87f9ed8502d8b636f823858a41aa7c5c8187c Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 15 Apr 2026 00:12:01 +0000 Subject: [PATCH 08/12] fix(api): add server-side pagination to churn risk query (GRO-641) - Add SQL-level LIMIT/OFFSET pagination to churn risk query - Add separate COUNT(*) subquery for total without fetching all rows - Accept page and limit query params with sensible defaults and bounds - Return page, limit, and churnRiskTotal in response Co-Authored-By: Paperclip --- apps/api/src/routes/reports.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 3849d4c..c249862 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => { ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); + const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20)); + const offset = (page - 1) * limit; + const churnRisk = await db .select({ clientId: clients.id, @@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => { .having( sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` ) - .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`); + .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`) + .limit(limit) + .offset(offset); + + const [churnCountRow] = await db + .select({ total: sql`count(*)::int` }) + .from( + db + .select({ id: clients.id }) + .from(clients) + .leftJoin(appointments, eq(appointments.clientId, clients.id)) + .groupBy(clients.id) + .having( + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` + ) + .as("churn_count") + ); + const churnRiskTotal = churnCountRow?.total ?? 0; return c.json({ from: from.toISOString(), to: to.toISOString(), newClients, activeInPeriodCount: activeInPeriod.length, - churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients - churnRiskTotal: churnRisk.length, + churnRisk, + churnRiskTotal, + page, + limit, }); }); From 4fa4859eafe0c5dc27571eea23c3b8de6599c10b Mon Sep 17 00:00:00 2001 From: "groombook-ceo[bot]" <269735724+groombook-ceo[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:47:09 +0000 Subject: [PATCH 09/12] fix: set Manager 1 as super user in UAT seed to resolve OOBE redirect Co-authored-by: Flea Flicker Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com> --- packages/db/src/seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index f18f5f7..ebb84a9 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -567,7 +567,7 @@ async function seed() { // ── Staff ── const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => - ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false }) + ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) ); const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) From 67e21579750890dcc636a0956c0660a6a88cae95 Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" <269737991+groombook-cto[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:54:00 +0000 Subject: [PATCH 10/12] feat(GRO-631): add graceful shutdown to API server (#292) - Capture server instance from serve() call - Add SIGTERM and SIGINT handlers for graceful shutdown - Add 10-second forced exit timeout Co-authored-by: Flea Flicker Co-authored-by: Paperclip --- apps/api/src/index.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7f49e20..9e56c42 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -187,9 +187,24 @@ api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); console.log(`API server listening on port ${port}`); -serve({ fetch: app.fetch, port }); +const server = serve({ fetch: app.fetch, port }); // Start background reminder scheduler (runs every minute to check for upcoming appointments) startReminderScheduler(); +function shutdown() { + console.log("Shutting down gracefully..."); + server.close(() => { + console.log("HTTP server closed"); + process.exit(0); + }); + setTimeout(() => { + console.error("Forced shutdown after timeout"); + process.exit(1); + }, 10_000); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + export default app; From 80b66fe20c4b9d92e7902ce14fc718792b543378 Mon Sep 17 00:00:00 2001 From: "groombook-ceo[bot]" <269735724+groombook-ceo[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:08:54 +0000 Subject: [PATCH 11/12] fix(GRO-655): create corepack cache dir in builder stage Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com> Co-authored-by: Paperclip From e1e13d5091d0a03e27c9a783c784ede02c767f70 Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" <269737991+groombook-cto[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:26:20 +0000 Subject: [PATCH 12/12] fix(GRO-636): input validation fixes for 5 API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Zod validation across 5 API routes: 1. invoices GET / — query param validation (uuid, enum, int bounds) 2. book POST / — future-time refinement on startTime 3. appointments — recurrence series capped at 1 year 4. services — durationMinutes capped at 480 (8 hours) 5. stripe-webhooks — UUID validation on invoice IDs before DB lookup Closes GRO-636 Co-Authored-By: Paperclip --- apps/api/src/routes/appointments.ts | 4 ++ apps/api/src/routes/book.ts | 5 +- apps/api/src/routes/invoices.ts | 98 ++++++++++++++------------ apps/api/src/routes/services.ts | 2 +- apps/api/src/routes/stripe-webhooks.ts | 13 +++- 5 files changed, 72 insertions(+), 50 deletions(-) diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 24d6b3c..2c893b7 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -41,6 +41,10 @@ const createAppointmentSchema = z.object({ frequencyWeeks: z.number().int().min(1).max(52), count: z.number().int().min(2).max(52), }) + .refine( + (r) => r.frequencyWeeks * r.count <= 52, + { message: "Recurrence series must not exceed 1 year" } + ) .optional(), }); diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index d82823f..f74405c 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => { const bookingSchema = z.object({ serviceId: z.string().uuid(), - startTime: z.string().datetime(), + startTime: z.string().datetime().refine( + (dt) => new Date(dt) > new Date(), + { message: "Appointment must be in the future" } + ), clientName: z.string().min(1).max(200), clientEmail: z.string().email(), clientPhone: z.string().max(50).optional(), diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 96c3e0e..0f65a34 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -44,53 +44,61 @@ const updateInvoiceSchema = z.object({ }); // List invoices -invoicesRouter.get("/", async (c) => { - const db = getDb(); - const clientId = c.req.query("clientId"); - const appointmentId = c.req.query("appointmentId"); - const status = c.req.query("status"); - const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200); - const offset = parseInt(c.req.query("offset") || "0", 10); - - const conditions = []; - if (clientId) conditions.push(eq(invoices.clientId, clientId)); - if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); - if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - const [totalResult] = await db - .select({ count: sql`count(*)` }) - .from(invoices) - .where(whereClause); - - const rows = await db - .select({ - id: invoices.id, - appointmentId: invoices.appointmentId, - clientId: invoices.clientId, - clientName: clients.name, - subtotalCents: invoices.subtotalCents, - taxCents: invoices.taxCents, - tipCents: invoices.tipCents, - totalCents: invoices.totalCents, - status: invoices.status, - paymentMethod: invoices.paymentMethod, - paidAt: invoices.paidAt, - notes: invoices.notes, - createdAt: invoices.createdAt, - updatedAt: invoices.updatedAt, - }) - .from(invoices) - .leftJoin(clients, eq(invoices.clientId, clients.id)) - .where(whereClause) - .orderBy(invoices.createdAt) - .limit(limit) - .offset(offset); - - return c.json({ data: rows, total: totalResult?.count ?? 0 }); +const listInvoicesQuerySchema = z.object({ + clientId: z.string().uuid().optional(), + appointmentId: z.string().uuid().optional(), + status: z.enum(["draft", "pending", "paid", "void"]).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), }); +invoicesRouter.get( + "/", + zValidator("query", listInvoicesQuerySchema), + async (c) => { + const db = getDb(); + const { clientId, appointmentId, status, limit, offset } = c.req.valid("query"); + + const conditions = []; + if (clientId) conditions.push(eq(invoices.clientId, clientId)); + if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); + if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ count: sql`count(*)` }) + .from(invoices) + .where(whereClause); + + const rows = await db + .select({ + id: invoices.id, + appointmentId: invoices.appointmentId, + clientId: invoices.clientId, + clientName: clients.name, + subtotalCents: invoices.subtotalCents, + taxCents: invoices.taxCents, + tipCents: invoices.tipCents, + totalCents: invoices.totalCents, + status: invoices.status, + paymentMethod: invoices.paymentMethod, + paidAt: invoices.paidAt, + notes: invoices.notes, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt, + }) + .from(invoices) + .leftJoin(clients, eq(invoices.clientId, clients.id)) + .where(whereClause) + .orderBy(invoices.createdAt) + .limit(limit) + .offset(offset); + + return c.json({ data: rows, total: totalResult?.count ?? 0 }); + } +); + // Get single invoice with line items and tip splits invoicesRouter.get("/:id", async (c) => { const db = getDb(); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index e9ccc44..659dee2 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -9,7 +9,7 @@ const createServiceSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(2000).optional(), basePriceCents: z.number().int().positive(), - durationMinutes: z.number().int().positive(), + durationMinutes: z.number().int().positive().max(480), active: z.boolean().default(true), }); diff --git a/apps/api/src/routes/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts index 4a948a1..fa7c8ef 100644 --- a/apps/api/src/routes/stripe-webhooks.ts +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import Stripe from "stripe"; +import { z } from "zod/v3"; import { eq, getDb, invoices } from "@groombook/db"; import { getStripeClient } from "../services/payment.js"; @@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => { const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); for (const invoiceId of invoiceIds) { if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); const [inv] = await db .select() .from(invoices) - .where(eq(invoices.id, invoiceId)) + .where(eq(invoices.id, invoiceIdTrimmed)) .limit(1); if (!inv) continue; if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue; @@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => { stripePaymentIntentId: pi.id, updatedAt: new Date(), }) - .where(eq(invoices.id, invoiceId)); + .where(eq(invoices.id, invoiceIdTrimmed)); } } } else if (event.type === "payment_intent.payment_failed") { @@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => { const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); for (const invoiceId of invoiceIds) { if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); await db .update(invoices) .set({ paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed", updatedAt: new Date(), }) - .where(eq(invoices.id, invoiceId)); + .where(eq(invoices.id, invoiceIdTrimmed)); } } } else if (event.type === "charge.refunded") {