From 3eaefb4911ae540b2fa03d82dc34e29b44139d37 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 22 May 2026 13:11:39 +0000 Subject: [PATCH 1/5] fix: add better-auth to pnpm-lock.yaml packages/db specifiers Co-Authored-By: Paperclip --- pnpm-lock.yaml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d3451b..ac304d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 0.7.6(hono@4.12.18)(zod@4.4.3) better-auth: specifier: ^1.5.6 - version: 1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)) + version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)) drizzle-orm: specifier: ^0.38.4 version: 0.38.4(kysely@0.28.17)(postgres@3.4.9) @@ -84,6 +84,9 @@ importers: packages/db: dependencies: + better-auth: + specifier: ^1.5.6 + version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)) drizzle-orm: specifier: ^0.38.4 version: 0.38.4(kysely@0.28.17)(postgres@3.4.9) @@ -967,66 +970,79 @@ packages: resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.3': resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.3': resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.3': resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.3': resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.3': resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.3': resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.3': resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.3': resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.3': resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.3': resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.3': resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.3': resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.3': resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} @@ -3927,7 +3943,7 @@ snapshots: balanced-match@4.0.4: {} - better-auth@1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)): + better-auth@1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9)) @@ -3947,6 +3963,7 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: + drizzle-kit: 0.30.6 drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9) vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0) transitivePeerDependencies: From 2f17b1ab85db5d9b2481a9823dda57aec78ef497 Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Tue, 26 May 2026 00:36:15 +0000 Subject: [PATCH 2/5] Promo/Gro 1764 Uat (#86) --- .ci-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ci-trigger diff --git a/.ci-trigger b/.ci-trigger new file mode 100644 index 0000000..bc10555 --- /dev/null +++ b/.ci-trigger @@ -0,0 +1 @@ +GRO-1757+GRO-1764 CI trigger 2026-05-26 \ No newline at end of file From 8e8a87767cca255d88dab719c9692349a8774b8a Mon Sep 17 00:00:00 2001 From: Lint Roller <23+gb_lint@noreply.git.farh.net> Date: Tue, 26 May 2026 01:34:42 +0000 Subject: [PATCH 3/5] fix(ci): remove duplicate provenance keys + add uat push trigger (GRO-1762) --- .gitea/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b08c640..dfb785b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, dev] + branches: [main, dev, uat] pull_request: - branches: [main, dev] + branches: [main, dev, uat] workflow_dispatch: inputs: ref: @@ -96,7 +96,6 @@ jobs: file: Dockerfile target: runner push: true - provenance: false tags: | git.farh.net/groombook/api:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} @@ -111,7 +110,6 @@ jobs: file: Dockerfile target: migrate push: true - provenance: false tags: | git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} @@ -126,7 +124,6 @@ jobs: file: Dockerfile target: seed push: true - provenance: false tags: | git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} @@ -141,7 +138,6 @@ jobs: file: Dockerfile target: reset push: true - provenance: false tags: | git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} From 385ed102119c7d68ddc96c7556df9ec80d7bc4b5 Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Tue, 26 May 2026 01:48:12 +0000 Subject: [PATCH 4/5] fix(rbac): guard noUncheckedIndexedAccess in name derivation and newStaff insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With noUncheckedIndexedAccess:true, split("@")[0] returns string|undefined, making `name` typed as string|undefined and failing the notNull staff.name insert constraint. Fix by using ?? fallback on the array access. Also add newStaff null guard after .returning() destructure — array destructuring yields T|undefined with noUncheckedIndexedAccess enabled. --- src/middleware/rbac.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/middleware/rbac.ts b/src/middleware/rbac.ts index bace747..f8dbc14 100644 --- a/src/middleware/rbac.ts +++ b/src/middleware/rbac.ts @@ -127,15 +127,14 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( if (oidcAccount) { // Derive name: prefer jwt.name, fall back to email prefix, then "Unknown" - const name = - jwt.name?.trim() || - (jwt.email ? jwt.email.split("@")[0] : "Unknown"); + const emailPrefix = jwt.email.split("@")[0] ?? "Unknown"; + const name = jwt.name?.trim() || emailPrefix; const [newStaff] = await db .insert(staff) .values({ userId: jwt.sub, - email: jwt.email ?? "", + email: jwt.email, name, role: "groomer", isSuperUser: false, @@ -143,6 +142,10 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( }) .returning(); + if (!newStaff) { + return c.json({ error: "Forbidden: auto-provision failed" }, 500); + } + console.log( `[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})` ); From 32156e9a451075369a81c7e17c9f792c2db56cdc Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 26 May 2026 12:30:10 +0000 Subject: [PATCH 5/5] fix: restore pet profile summary endpoint from dev (GRO-1177) --- apps/api/src/routes/pets.ts | 132 +++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index dbc5418..f8b6440 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js"; +import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, @@ -283,3 +283,133 @@ petsRouter.get("/:petId/photo", async (c) => { const url = await getPresignedGetUrl(pet.photoKey); return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt }); }); + +// ─── Profile Summary ─────────────────────────────────────────────────────────── + +async function groomerLinkageCheck( + db: ReturnType, + clientId: string, + staffRow: NonNullable +): Promise { + const [linkage] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + return !!linkage; +} + +/** + * GET /:id/profile-summary + * Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment. + * Groomer RBAC: same visibility rules as GET /:id. + */ +petsRouter.get("/:id/profile-summary", async (c) => { + const db = getDb(); + const petId = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [row] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!row) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow); + if (!hasLinkage) return c.json({ error: "Forbidden" }, 403); + } + + // Recent grooming history: last 10, with staff name join + const historyRows = await db + .select({ + id: groomingVisitLogs.id, + petId: groomingVisitLogs.petId, + appointmentId: groomingVisitLogs.appointmentId, + staffId: groomingVisitLogs.staffId, + staffName: staff.name, + cutStyle: groomingVisitLogs.cutStyle, + productsUsed: groomingVisitLogs.productsUsed, + notes: groomingVisitLogs.notes, + groomedAt: groomingVisitLogs.groomedAt, + createdAt: groomingVisitLogs.createdAt, + }) + .from(groomingVisitLogs) + .leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId)) + .where(eq(groomingVisitLogs.petId, petId)) + .orderBy(desc(groomingVisitLogs.groomedAt)) + .limit(10); + + const recentGroomingHistory = historyRows.map((r) => ({ + id: r.id, + petId: r.petId, + appointmentId: r.appointmentId, + staffId: r.staffId, + staffName: r.staffName, + cutStyle: r.cutStyle, + productsUsed: r.productsUsed, + notes: r.notes, + groomedAt: r.groomedAt?.toISOString() ?? null, + createdAt: r.createdAt?.toISOString() ?? null, + })); + + const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null; + + // Completed appointment count for this pet + const [{ count: visitCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(appointments) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))); + + // Upcoming appointment: next scheduled or confirmed + const [nextAppt] = await db + .select({ + id: appointments.id, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .leftJoin(services, eq(services.id, appointments.serviceId)) + .leftJoin(staff, eq(staff.id, appointments.staffId)) + .where( + and( + eq(appointments.petId, petId), + or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")), + gte(appointments.startTime, new Date()) + ) + ) + .orderBy(appointments.startTime) + .limit(1); + + const upcomingAppointment = nextAppt + ? { + id: nextAppt.id, + serviceId: nextAppt.serviceId, + serviceName: nextAppt.serviceName, + staffId: nextAppt.staffId, + staffName: nextAppt.staffName, + startTime: nextAppt.startTime?.toISOString() ?? null, + endTime: nextAppt.endTime?.toISOString() ?? null, + status: nextAppt.status, + } + : null; + + return c.json({ + ...row, + recentGroomingHistory, + lastVisitDate, + visitCount, + upcomingAppointment, + }); +});