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 1/4] 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 2/4] 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 3/4] 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 4/4] 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;