Compare commits

..

3 Commits

Author SHA1 Message Date
Barcode Betty 3bc8c634ab Merge origin/dev into fix/gro-1749-uat-seed-sync
Resolve conflict in apps/api/src/routes/admin/seed.ts:
- Keep UAT_CLIENT with address (not stripped by GRO-1743)
- Keep weightKg in UAT_PETS (from both branches)
- Use origin/dev coatType as const (typed properly)
- Use existingPet naming (from origin/dev)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 17:45:25 +00:00
Barcode Betty a848a3f35f fix(gro-1749): sync UAT seed data to root src and fix route path
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 1m11s
- Add UAT_CLIENT (name: "UAT Customer", email: uat-customer@groombook.dev)
- Add UAT_PETS: Bella (Poodle, curly) and Max (Labrador Retriever, short)
- Add UAT Client upsert block and UAT Pets loop after Demo Dog
- Change adminSeedRouter.post("/seed") → adminSeedRouter.post("/")
  in both src/routes/admin/seed.ts (Docker build) and apps/api/src/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:41:27 +00:00
Flea Flicker d17915907c fix(ci): add provenance: false to fix registry push failures
CI / Test (pull_request) Successful in 1m6s
CI / Lint & Typecheck (pull_request) Successful in 1m10s
CI / Build & Push Docker Images (pull_request) Successful in 37s
OCI attestation manifests fail to push on Gitea registry when image
layers already exist (cross-repo blob references). Disabling provenance
generation on all four build-push-action steps ensures a simple single
manifest push that Gitea handles reliably.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 01:25:07 +00:00
10 changed files with 10 additions and 224 deletions
-1
View File
@@ -1 +0,0 @@
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
+6 -2
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev, uat]
branches: [main, dev]
pull_request:
branches: [main, dev, uat]
branches: [main, dev]
workflow_dispatch:
inputs:
ref:
@@ -96,6 +96,7 @@ 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' || '' }}
@@ -110,6 +111,7 @@ 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' || '' }}
@@ -124,6 +126,7 @@ 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' || '' }}
@@ -138,6 +141,7 @@ 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' || '' }}
-20
View File
@@ -48,26 +48,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
#### SSO Login Journey (Authentik OIDC end-to-end)
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-API-1.17 | SSO redirect to Authentik | Navigate to app → sign-in page shown → click "Sign in with SSO" | Redirected to Authentik at auth.farh.net | 403 error, redirect loop, no SSO button |
| TC-API-1.18 | Authenticate with valid OIDC credentials | At Authentik login page, enter valid credentials and authenticate | Redirected back to app with valid session | Redirect loop, 403, missing session cookie |
| TC-API-1.19 | SSO user auto-provisioned as groomer | Complete SSO login as a user with no pre-existing staff record | 200 response; groomer staff record auto-created; session active | 403 Forbidden, staff record not created |
| TC-API-1.20 | Existing staff record resolves correctly | Complete SSO login as uat-groomer (pre-existing staff) | 200 OK, correct staff identity resolved, no duplicate record created | 403, duplicate record, wrong staff data |
| TC-API-1.21 | SSO session grants dashboard access | After TC-API-1.18 SSO login, GET /api/staff/me | 200 OK, valid staff record returned, correct role displayed | 401/403, missing session, wrong identity |
#### OOBE Flow Post-Login
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-API-1.22 | Fresh DB reports needsSetup | On a fresh DB (no super user), GET /api/setup/status | needsSetup: true returned | needsSetup: false when it should be true |
| TC-API-1.23 | Configure OIDC via auth-provider endpoint | POST /api/setup/auth-provider with valid OIDC config | 200 OK, auth provider configured, no 403 | 403, setup blocked, invalid config rejected |
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
### 4.2 Client Management
| # | Scenario | Steps | Expected |
+1 -1
View File
@@ -46,7 +46,7 @@ const UAT_CLIENT = {
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short" as const, weightKg: "30.00" },
];
const DEMO_SERVICES = [
+1 -131
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -283,133 +283,3 @@ 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<typeof getDb>,
clientId: string,
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
): Promise<boolean> {
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<number>`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,
});
});
-4
View File
@@ -105,10 +105,6 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
-5
View File
@@ -11,7 +11,6 @@ import {
unique,
uuid,
} from "drizzle-orm/pg-core";
import type { MedicalAlert } from "@groombook/types";
// ─── Enums ────────────────────────────────────────────────────────────────────
@@ -165,10 +164,6 @@ export const pets = pgTable(
specialCareNotes: text("special_care_notes"),
coatType: coatTypeEnum("coat_type"),
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
temperamentScore: integer("temperament_score"),
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
-13
View File
@@ -970,79 +970,66 @@ 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==}
+1 -46
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
import { and, eq, getDb, sql, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -110,51 +110,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
// (e.g. authentik). If so, create a groomer staff record on the fly.
if (jwt.email) {
const [oidcAccount] = await db
.select({ id: account.id })
.from(account)
.where(
and(
eq(account.userId, jwt.sub),
sql`${account.providerId} IN ('authentik', 'google', 'github')`
)
)
.limit(1);
if (oidcAccount) {
// Derive name: prefer jwt.name, fall back to email prefix, then "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,
name,
role: "groomer",
isSuperUser: false,
active: true,
})
.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})`
);
c.set("staff", newStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+1 -1
View File
@@ -46,7 +46,7 @@ const UAT_CLIENT = {
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth", weightKg: "30.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short", weightKg: "30.00" },
];
const DEMO_SERVICES = [