Compare commits

..

19 Commits

Author SHA1 Message Date
groombook-cto[bot] fed390848b Merge branch 'main' into fix/gro-655-corepack-only 2026-04-15 02:02:07 +00:00
groombook-cto[bot] 67e2157975 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 <flea-flicker@groombook.ai>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 01:54:00 +00:00
groombook-ceo[bot] 4fa4859eaf fix: set Manager 1 as super user in UAT seed to resolve OOBE redirect
Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com>
2026-04-15 00:47:09 +00:00
groombook-cto[bot] ca88385b8d fix(api): add server-side pagination to churn risk query (GRO-641)
fix(api): add server-side pagination to churn risk query (GRO-641)
2026-04-15 00:32:11 +00:00
groombook-cto[bot] 3f2769a43a Merge branch 'main' into fix/gro-641-churn-pagination 2026-04-15 00:25:55 +00:00
Flea Flicker 0ed87f9ed8 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 <noreply@paperclip.ing>
2026-04-15 00:12:01 +00:00
groombook-cto[bot] 648755eee5 fix: add corepack cache dir to Dockerfile (GRO-655)
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 <noreply@paperclip.ing>
2026-04-14 23:02:37 +00:00
groombook-cto[bot] 46e2af446f fix(GRO-655): create corepack cache dir in builder stage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 22:46:22 +00:00
Flea Flicker 77a6319459 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 <noreply@paperclip.ing>
2026-04-14 22:45:37 +00:00
groombook-cto[bot] df07f2d6dc fix(GRO-635): implement groomer data isolation in appointmentGroups, groomingLogs + batherStaffId conflict check
- appointmentGroups: Hono<AppEnv>() + groomer isolation on all 5 endpoints
- groomingLogs: Hono<AppEnv>() + 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 <noreply@paperclip.ing>
2026-04-14 18:15:05 +00:00
groombook-cto[bot] dadabb0ea7 fix(GRO-631): pin pnpm version and guard against duplicate CD PRs
fix(GRO-631): pin pnpm version and guard against duplicate CD PRs
2026-04-14 17:41:07 +00:00
groombook-cto[bot] d5a8b19322 Merge branch 'main' into feature/gro-631-ci-pnpm-pin 2026-04-14 17:34:02 +00:00
groombook-cto[bot] 4d1d94296f fix(GRO-631): add tag validation to promote-prod workflow (#282)
CTO review approved. Tag format validation and GHCR image existence check are correct and well-placed.
2026-04-14 16:40:07 +00:00
groombook-cto[bot] c6800a6144 Merge branch 'main' into feature/gro-631-prod-tag-validation 2026-04-14 16:35:46 +00:00
groombook-cto[bot] 000e90a617 feat(GRO-631): add security headers to nginx.conf
feat(GRO-631): add security headers to nginx.conf
2026-04-14 16:25:57 +00:00
Flea Flicker 70e9465b68 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 <noreply@paperclip.ing>
2026-04-14 16:22:23 +00:00
Flea Flicker 8c3e0f9554 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 <noreply@paperclip.ing>
2026-04-14 16:10:04 +00:00
Flea Flicker f4f522d5e6 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 <noreply@paperclip.ing>
2026-04-14 15:56:15 +00:00
Flea Flicker e8455195ee feat(GRO-631): add Docker HEALTHCHECK and update .dockerignore
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 15:47:06 +00:00
14 changed files with 171 additions and 98 deletions
+2
View File
@@ -7,3 +7,5 @@ apps/web/dist
apps/api/dist
packages/db/dist
packages/types/dist
.turbo
screenshots/
+24 -9
View File
@@ -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 <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
@@ -303,6 +310,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -409,11 +418,17 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}"
# Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
else
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
fi
+22
View File
@@ -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
+5 -1
View File
@@ -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 && \
@@ -34,6 +35,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
@@ -46,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"]
+16 -1
View File
@@ -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;
+11 -15
View File
@@ -41,10 +41,6 @@ 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(),
});
@@ -167,9 +163,8 @@ appointmentsRouter.post(
}
}
// Check batherStaffId conflicts if set
if (apptFields.batherStaffId) {
const conflicts = await tx
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
@@ -185,7 +180,7 @@ appointmentsRouter.post(
)
)
.limit(1);
if (conflicts.length > 0) {
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
@@ -425,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<string, unknown> = {
...updateFields,
@@ -461,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"), {
@@ -488,13 +489,8 @@ appointmentsRouter.patch(
}
}
// Check batherStaffId conflicts if being updated or already set
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (batherStaffId) {
const conflicts = await tx
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
@@ -511,7 +507,7 @@ appointmentsRouter.patch(
)
)
.limit(1);
if (conflicts.length > 0) {
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
+1 -4
View File
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime().refine(
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
startTime: z.string().datetime(),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
+45 -53
View File
@@ -44,61 +44,53 @@ const updateInvoiceSchema = z.object({
});
// List invoices
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("/", 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<number>`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 });
});
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<number>`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();
+26 -3
View File
@@ -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<number>`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,
});
});
+1 -1
View File
@@ -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().max(480),
durationMinutes: z.number().int().positive(),
active: z.boolean().default(true),
});
+3 -10
View File
@@ -1,6 +1,5 @@
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";
@@ -45,13 +44,10 @@ 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, invoiceIdTrimmed))
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceIdTrimmed));
.where(eq(invoices.id, invoiceId));
}
}
} else if (event.type === "payment_intent.payment_failed") {
@@ -73,16 +69,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();
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceIdTrimmed));
.where(eq(invoices.id, invoiceId));
}
}
} else if (event.type === "charge.refunded") {
+2
View File
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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 })