Compare commits

..

9 Commits

Author SHA1 Message Date
Paperclip d87f0f9608 fix(GRO-607): CTO review fixes — payment security and correctness
- Fix multi-invoice total calculation: use inArray() instead of eq()
  on single ID, sum all invoices not just first
- Add ownership check to payment method deletion: verify the payment
  method belongs to the authenticated Stripe customer before detaching
- Remove duplicate /config endpoint in portal.ts
- Fix webhook Stripe client: use getStripeClient() from payment service
  instead of constructing with WEBHOOK_SECRET
- Remove unnecessary body validator on /invoices/:id/pay route
- Export getStripeClient() for use by stripe-webhooks.ts
- Add inArray import to payment.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 08:12:05 +00:00
Paperclip 6f9e6e7153 fix(GRO-607): remove unused eslint-disable directive in CustomerPortal
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 07:51:38 +00:00
Paperclip dc947874ca fix(GRO-607): Stripe Elements payment UI - lint/type fixes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 20:00:18 +00:00
Paperclip 78b71cca58 GRO-607: Replace mock payment flow with real Stripe Elements
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:55:49 +00:00
Paperclip 5456637705 GRO-607: Add /portal/config endpoint + rename date field
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:55:24 +00:00
Paperclip 3037b77fe8 GRO-607: Install Stripe frontend packages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:55:13 +00:00
Paperclip ae873215c0 feat(GRO-597): Stripe payment backend — schema, service, API, webhooks
Consolidates GRO-605, GRO-606, GRO-608 into a single clean PR:
- GRO-605: Stripe SDK integration + payment service
- GRO-606: Payment API endpoints (pay invoice, payment methods, refunds)
- GRO-608: Stripe webhook handler

Migration consolidation:
- Single 0026_stripe_payment.sql migration adds stripeCustomerId to clients
  and stripe_payment_intent_id, stripe_refund_id, payment_failure_reason to invoices
- Removed duplicate 0027_stripe_identifiers.sql

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:54:15 +00:00
Paperclip 9d37053580 GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:43:24 +00:00
Paperclip fdaf4db0d5 GRO-605: Stripe SDK integration + payment service
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:43:03 +00:00
12 changed files with 23 additions and 326 deletions
-2
View File
@@ -7,5 +7,3 @@ apps/web/dist
apps/api/dist apps/api/dist
packages/db/dist packages/db/dist
packages/types/dist packages/types/dist
.turbo
screenshots/
+9 -24
View File
@@ -20,8 +20,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -44,8 +42,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -66,8 +62,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -107,8 +101,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -246,6 +238,7 @@ jobs:
echo "Deploying images tagged $TAG to groombook-dev..." echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image # 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 kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f - cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1 apiVersion: batch/v1
@@ -310,8 +303,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -418,17 +409,11 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}" git push -u origin "chore/update-image-tags-${TAG}"
# Check if PR already exists for this branch # Create PR and merge immediately (no required checks on groombook/infra)
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true) PR_URL=$(gh pr create \
if [ -n "$EXISTING_PR" ]; then --repo groombook/infra \
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR" --base main \
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge --head "chore/update-image-tags-${TAG}" \
else --title "chore: deploy ${TAG} to dev" \
PR_URL=$(gh pr create \ --body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
--repo groombook/infra \ gh pr merge "$PR_URL" --merge
--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,29 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: read
steps: 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 - name: Generate infra repo token
id: infra-token id: infra-token
uses: tibdex/github-app-token@v2 uses: tibdex/github-app-token@v2
+1 -5
View File
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
# Build # Build
FROM deps AS builder FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/ COPY packages/ packages/
COPY apps/api/ apps/api/ COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \ RUN pnpm --filter @groombook/types build && \
@@ -35,9 +34,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000 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"] CMD ["node", "apps/api/dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database # Migrate stage — runs drizzle-kit migrate against the database
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds # Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset FROM builder AS reset
CMD ["pnpm", "db:reset"] CMD ["pnpm", "db:reset"]
+1 -16
View File
@@ -187,24 +187,9 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
await initAuth(); await initAuth();
console.log(`API server listening on port ${port}`); console.log(`API server listening on port ${port}`);
const server = serve({ fetch: app.fetch, port }); serve({ fetch: app.fetch, port });
// Start background reminder scheduler (runs every minute to check for upcoming appointments) // Start background reminder scheduler (runs every minute to check for upcoming appointments)
startReminderScheduler(); 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; export default app;
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services, services,
staff, staff,
} from "@groombook/db"; } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>(); export const appointmentGroupsRouter = new Hono();
// ─── Schemas ────────────────────────────────────────────────────────────────── // ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId"); const clientId = c.req.query("clientId");
const from = c.req.query("from"); const from = c.req.query("from");
const to = c.req.query("to"); const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)] ? [eq(appointmentGroups.clientId, clientId)]
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
})) }))
.filter((g) => !from || g.appointments.length > 0); .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); return c.json(result);
}); });
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => { appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select() .select()
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId, serviceId: appointments.serviceId,
serviceName: services.name, serviceName: services.name,
staffId: appointments.staffId, staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name, staffName: staff.name,
status: appointments.status, status: appointments.status,
startTime: appointments.startTime, startTime: appointments.startTime,
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id)) .where(eq(appointments.groupId, id))
.orderBy(appointments.startTime); .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 const [client] = await db
.select({ name: clients.name, email: clients.email }) .select({ name: clients.name, email: clients.email })
.from(clients) .from(clients)
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema), zValidator("json", createGroupSchema),
async (c) => { async (c) => {
const db = getDb(); 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 body = c.req.valid("json");
const startTime = new Date(body.startTime); const startTime = new Date(body.startTime);
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); 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 const [updated] = await db
.update(appointmentGroups) .update(appointmentGroups)
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => { appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select({ id: appointmentGroups.id }) .select({ id: appointmentGroups.id })
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id)); .where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404); 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 await db
.update(appointments) .update(appointments)
.set({ status: "cancelled", updatedAt: new Date() }) .set({ status: "cancelled", updatedAt: new Date() })
+1 -52
View File
@@ -163,28 +163,6 @@ 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) { if (!recurrence) {
// Single appointment // Single appointment
const [inserted] = await tx const [inserted] = await tx
@@ -420,8 +398,7 @@ appointmentsRouter.patch(
const needsConflictCheck = const needsConflictCheck =
updateFields.startTime !== undefined || updateFields.startTime !== undefined ||
updateFields.endTime !== undefined || updateFields.endTime !== undefined ||
updateFields.staffId !== undefined || updateFields.staffId !== undefined;
updateFields.batherStaffId !== undefined;
const update: Record<string, unknown> = { const update: Record<string, unknown> = {
...updateFields, ...updateFields,
@@ -457,11 +434,6 @@ appointmentsRouter.patch(
updateFields.staffId !== undefined updateFields.staffId !== undefined
? updateFields.staffId ? updateFields.staffId
: current.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) { if (end <= start) {
throw Object.assign(new Error("end before start"), { throw Object.assign(new Error("end before start"), {
@@ -489,29 +461,6 @@ 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 const [updated] = await tx
.update(appointments) .update(appointments)
.set(update) .set(update)
+6 -93
View File
@@ -1,10 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db"; import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>(); export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({ const createLogSchema = z.object({
petId: z.string().uuid(), petId: z.string().uuid(),
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb(); const db = getDb();
const petId = c.req.query("petId"); const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400); 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 const rows = await db
.select() .select()
.from(groomingVisitLogs) .from(groomingVisitLogs)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema), zValidator("json", createLogSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json"); const { groomedAt, ...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 const [row] = await db
.insert(groomingVisitLogs) .insert(groomingVisitLogs)
.values({ .values({
...rest, ...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(), groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
}) })
.returning(); .returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => { groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const [row] = await db
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) .delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id)) .where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
+3 -26
View File
@@ -286,10 +286,6 @@ reportsRouter.get("/clients", async (c) => {
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); 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 const churnRisk = await db
.select({ .select({
clientId: clients.id, clientId: clients.id,
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
.having( .having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` 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({ return c.json({
from: from.toISOString(), from: from.toISOString(),
to: to.toISOString(), to: to.toISOString(),
newClients, newClients,
activeInPeriodCount: activeInPeriod.length, activeInPeriodCount: activeInPeriod.length,
churnRisk, churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal, churnRiskTotal: churnRisk.length,
page,
limit,
}); });
}); });
-2
View File
@@ -20,5 +20,3 @@ FROM nginx:alpine AS runner
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
-12
View File
@@ -3,22 +3,10 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 # Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ { location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; 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 # Proxy API calls to the API service
+1 -1
View File
@@ -567,7 +567,7 @@ async function seed() {
// ── Staff ── // ── Staff ──
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => 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: profile === "uat" && i === 0 }) ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
); );
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => 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 }) ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })