diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b08c640..b37a76a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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' || '' }} diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index d03aeea..166cf68 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -98,6 +98,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced | | TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced | | TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced | +| TC-API-3.16 | Get pet profile summary | GET /api/pets/{id}/profile-summary | 200 OK, aggregated profile with grooming history, visit count, upcoming appointment | +| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden | +| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) | +| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) | ### 4.4 Appointment Scheduling @@ -159,6 +163,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created | | TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned | | TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created | +| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned | +| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized | +| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" | +| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned | ### 4.9 Waitlist diff --git a/apps/api/src/__tests__/petProfileSummary.test.ts b/apps/api/src/__tests__/petProfileSummary.test.ts new file mode 100644 index 0000000..38b138c --- /dev/null +++ b/apps/api/src/__tests__/petProfileSummary.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { petsRouter } from "../routes/pets.js"; + +// ─── Mock staff fixtures ────────────────────────────────────────────────────── + +const MANAGER: StaffRow = { + id: "staff-manager-id", + oidcSub: "oidc-manager-sub", + userId: null, + role: "manager", + isSuperUser: true, + name: "Manager McManager", + email: "manager@example.com", + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const GROOMER: StaffRow = { + id: "staff-groomer-id", + oidcSub: "oidc-groomer-sub", + userId: null, + role: "groomer", + isSuperUser: false, + name: "Groomer McGroome", + email: "groomer@example.com", + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +// ─── Mutable mock state ─────────────────────────────────────────────────────── + +const CLIENT_ID = "client-uuid-summary"; +const PET_ID = "pet-uuid-summary"; + +interface MockState { + pets: Record[]; + appointments: Record[]; + groomingLogs: Record[]; + staffMembers: Record[]; + services: Record[]; +} + +let mock: MockState; + +function resetMock() { + mock = { + pets: [{ + id: PET_ID, + clientId: CLIENT_ID, + name: "Biscuit", + species: "dog", + breed: "Golden Retriever", + weightKg: "30.00", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + customFields: {}, + photoKey: null, + photoUploadedAt: null, + image: null, + coatType: "double", + temperamentScore: 3, + temperamentFlags: ["gentle"], + medicalAlerts: [], + preferredCuts: ["puppy cut"], + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + }], + appointments: [ + { + id: "appt-completed-1", + clientId: CLIENT_ID, + petId: PET_ID, + serviceId: "service-1", + staffId: "staff-groomer-id", + batherStaffId: null, + status: "completed", + startTime: new Date("2024-06-01T09:00:00Z"), + endTime: new Date("2024-06-01T11:00:00Z"), + notes: null, + priceCents: 6000, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "confirmed", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2024-05-15"), + updatedAt: new Date("2024-05-15"), + }, + { + id: "appt-upcoming-1", + clientId: CLIENT_ID, + petId: PET_ID, + serviceId: "service-2", + staffId: "staff-groomer-id", + batherStaffId: null, + status: "confirmed", + startTime: new Date("2024-12-01T09:00:00Z"), + endTime: new Date("2024-12-01T11:00:00Z"), + notes: null, + priceCents: 6500, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "confirmed", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2024-11-01"), + updatedAt: new Date("2024-11-01"), + }, + ], + groomingLogs: [ + { + id: "log-1", + petId: PET_ID, + appointmentId: "appt-completed-1", + staffId: "staff-groomer-id", + cutStyle: "puppy cut", + productsUsed: "oatmeal shampoo", + notes: "Trimmed nails", + groomedAt: new Date("2024-06-01T10:00:00Z"), + createdAt: new Date("2024-06-01T10:00:00Z"), + }, + ], + staffMembers: [ + { + id: "staff-groomer-id", + name: "Groomer McGroome", + email: "groomer@example.com", + role: "groomer", + isSuperUser: false, + active: true, + oidcSub: "oidc-groomer-sub", + userId: null, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "staff-manager-id", + name: "Manager McManager", + email: "manager@example.com", + role: "manager", + isSuperUser: true, + active: true, + oidcSub: "oidc-manager-sub", + userId: null, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + services: [ + { id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() }, + { id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() }, + ], + }; +} + +vi.mock("../db/index.js", () => { + const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} }); + const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} }); + const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} }); + const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} }); + const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} }); + + function makeChainable(rows: unknown[]) { + const arr = rows as unknown[]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") { + return () => makeChainable(target); + } + if (prop === Symbol.iterator) { + return function* () { for (const v of target) yield v; }; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + } + + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => { + const name = (table as { _name?: string })._name; + if (name === "pets") return makeChainable(mock.pets); + if (name === "appointments") return makeChainable(mock.appointments); + if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs); + if (name === "staff") return makeChainable(mock.staffMembers); + if (name === "services") return makeChainable(mock.services); + return makeChainable([]); + }, + }), + insert: () => ({ values: () => ({ returning: () => [{}] }) }), + update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }), + delete: () => ({ where: () => ({ returning: () => [{}] }) }), + }), + pets, + appointments, + groomingVisitLogs, + staff, + services, + and: vi.fn((a: unknown, b: unknown) => [a, b]), + desc: vi.fn((c: unknown) => c), + eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), + exists: vi.fn(() => true), + gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })), + or: vi.fn((a: unknown, b: unknown) => [a, b]), + sql: vi.fn((str: string) => str), + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeApp(staff: StaffRow = MANAGER) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("staff", staff); + await next(); + }); + return app.route("/pets", petsRouter); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET /:id/profile-summary", () => { + beforeEach(resetMock); + + it("returns 404 for non-existent pet", async () => { + const app = makeApp(); + mock.pets = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(404); + }); + + it("returns 403 for groomer with no pet linkage", async () => { + const app = makeApp(GROOMER); + // Groomer has no linkage to this pet's client — clear appointments + mock.appointments = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(403); + }); + + it("returns complete aggregated profile for manager", async () => { + const app = makeApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe(PET_ID); + expect(body.name).toBe("Biscuit"); + expect(body.species).toBe("dog"); + expect(body.recentGroomingHistory).toBeInstanceOf(Array); + expect(body.lastVisitDate).toBeTruthy(); + expect(body.visitCount).toBeGreaterThanOrEqual(0); + }); + + it("groomer with pet linkage returns 200", async () => { + const app = makeApp(GROOMER); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + }); + + it("recentGroomingHistory is limited to 10 entries", async () => { + const app = makeApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10); + }); + + it("returns null upcomingAppointment when none scheduled", async () => { + const app = makeApp(MANAGER); + mock.appointments = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.upcomingAppointment).toBeNull(); + }); +}); + +describe("GET /:id/profile-summary — visitCount", () => { + beforeEach(resetMock); + + it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => { + const app = makeApp(MANAGER); + // Add a second completed appointment + mock.appointments = [ + ...mock.appointments, + { + id: "appt-completed-2", + clientId: CLIENT_ID, + petId: PET_ID, + serviceId: "service-1", + staffId: "staff-groomer-id", + batherStaffId: null, + status: "completed", + startTime: new Date("2024-07-01T09:00:00Z"), + endTime: new Date("2024-07-01T11:00:00Z"), + notes: null, + priceCents: 6000, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "confirmed", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2024-06-15"), + updatedAt: new Date("2024-06-15"), + }, + ]; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.visitCount).toBeGreaterThanOrEqual(2); + }); + + it("returns visitCount = 0 when no completed appointments", async () => { + const app = makeApp(MANAGER); + mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" })); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.visitCount).toBe(0); + }); +}); + +describe("GET /:id/profile-summary — empty history", () => { + beforeEach(resetMock); + + it("returns empty history array when no grooming logs", async () => { + const app = makeApp(MANAGER); + mock.groomingLogs = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recentGroomingHistory).toEqual([]); + expect(body.lastVisitDate).toBeNull(); + }); +}); \ No newline at end of file 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, + }); +}); diff --git a/packages/db/migrations/0034_extend_pet_profile_columns.sql b/packages/db/migrations/0034_extend_pet_profile_columns.sql new file mode 100644 index 0000000..e931dc4 --- /dev/null +++ b/packages/db/migrations/0034_extend_pet_profile_columns.sql @@ -0,0 +1,8 @@ +-- Migration: 0034_extend_pet_profile_columns.sql +-- GRO-1850: Adds temperament_score, temperament_flags, medical_alerts, +-- and preferred_cuts columns to the pets table. + +ALTER TABLE "pets" ADD COLUMN "temperament_score" integer; +ALTER TABLE "pets" ADD COLUMN "temperament_flags" jsonb DEFAULT '[]'; +ALTER TABLE "pets" ADD COLUMN "medical_alerts" jsonb DEFAULT '[]'; +ALTER TABLE "pets" ADD COLUMN "preferred_cuts" jsonb DEFAULT '[]'; \ No newline at end of file diff --git a/packages/db/migrations/meta/0034_snapshot.json b/packages/db/migrations/meta/0034_snapshot.json new file mode 100644 index 0000000..66c1851 --- /dev/null +++ b/packages/db/migrations/meta/0034_snapshot.json @@ -0,0 +1,210 @@ +{ + "id": "0034_extend_pet_profile_columns", + "prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "species": { + "name": "species", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "breed": { + "name": "breed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "health_alerts": { + "name": "health_alerts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grooming_notes": { + "name": "grooming_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shampoo_preference": { + "name": "shampoo_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "special_care_notes": { + "name": "special_care_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coat_type": { + "name": "coat_type", + "type": "coat_type", + "primaryKey": false, + "notNull": false + }, + "pet_size_category": { + "name": "pet_size_category", + "type": "pet_size_category", + "primaryKey": false, + "notNull": false + }, + "temperament_score": { + "name": "temperament_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temperament_flags": { + "name": "temperament_flags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "medical_alerts": { + "name": "medical_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "preferred_cuts": { + "name": "preferred_cuts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "photo_key": { + "name": "photo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_uploaded_at": { + "name": "photo_uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pets_client_id_clients_id_fk": { + "name": "pets_client_id_clients_id_fk", + "tableFrom": "pets", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "coat_type": { + "name": "coat_type", + "values": [ + "short", + "medium", + "long", + "wire", + "double", + "hairless", + "curly" + ] + }, + "pet_size_category": { + "name": "pet_size_category", + "values": [ + "small", + "medium", + "large", + "extra_large" + ] + } + }, + "nativeEnums": {} +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index a364fe1..db9e36c 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1779500000000, "tag": "0033_add_services_default_buffer_minutes", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1751140800000, + "tag": "0034_extend_pet_profile_columns", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d53138e..46ad5c6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -225,3 +225,34 @@ export interface MedicalAlert { } export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless"; + +export interface GroomingHistoryEntry { + id: string; + petId: string; + appointmentId: string | null; + staffId: string | null; + staffName: string | null; + cutStyle: string | null; + productsUsed: string | null; + notes: string | null; + groomedAt: string; + createdAt: string; +} + +export interface UpcomingAppointment { + id: string; + serviceId: string; + serviceName: string; + staffId: string | null; + staffName: string | null; + startTime: string; + endTime: string; + status: AppointmentStatus; +} + +export interface PetProfileSummary extends Pet { + recentGroomingHistory: GroomingHistoryEntry[]; + lastVisitDate: string | null; + visitCount: number; + upcomingAppointment: UpcomingAppointment | null; +} diff --git a/src/__tests__/portalSessionFromAuth.test.ts b/src/__tests__/portalSessionFromAuth.test.ts new file mode 100644 index 0000000..8448803 --- /dev/null +++ b/src/__tests__/portalSessionFromAuth.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const CLIENT_EMAIL = "alice@example.com"; +const CLIENT_NAME = "Alice Smith"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-001", + email: CLIENT_EMAIL, + name: CLIENT_NAME, + }, + session: { + id: "ba-session-001", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +const MOCK_CLIENT = { + id: CLIENT_ID, + email: CLIENT_EMAIL, + name: CLIENT_NAME, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let insertedSession: Record | null = null; +let mockClientRow: Record | null = null; +let mockStaffRow: Record | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const staff = new Proxy( + { _name: "staff" }, + { get: (t, p) => (p === "_name" ? "staff" : { table: "staff", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(mockClientRow ? [mockClientRow] : []); + } + if (table._name === "staff") { + return makeChainable(mockStaffRow ? [mockStaffRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "impersonationSessions") { + insertedSession = { id: "new-session-001", ...vals }; + return [insertedSession]; + } + return []; + }, + }), + }), + }), + impersonationSessions, + clients, + staff, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/session-from-auth", () => { + beforeEach(() => { + insertedSession = null; + mockClientRow = null; + mockStaffRow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 404 when authenticated user has no client record", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = null; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("No client record found for this user"); + }); + + it("returns a portal session with sessionId, clientId, clientName when client is found", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = MOCK_CLIENT; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty("sessionId"); + expect(body).toHaveProperty("clientId", CLIENT_ID); + expect(body).toHaveProperty("clientName", CLIENT_NAME); + }); + + it("creates a portal session with reason sso-bridge", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = MOCK_CLIENT; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + expect(insertedSession).not.toBeNull(); + expect((insertedSession as Record).reason).toBe("sso-bridge"); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(503); + }); +}); \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index da2b2d1..ff1e125 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -172,7 +172,7 @@ export async function initAuth(): Promise { clientSecret: oidcClientSecret, issuerUrl: oidcIssuer, internalBaseUrl: process.env.OIDC_INTERNAL_BASE, - scopes: "openid profile email", + scopes: "openid profile email role", }; console.log("[auth] Using env var config (no DB config found)"); } diff --git a/src/middleware/rbac.ts b/src/middleware/rbac.ts index bace747..de1fdec 100644 --- a/src/middleware/rbac.ts +++ b/src/middleware/rbac.ts @@ -22,7 +22,7 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( c, next ) => { - // Better-Auth's own routes handle their own auth — skip staff resolution + // Better-Auth\'s own routes handle their own auth — skip staff resolution // OOBE setup routes also handle their own auth — staff record is created during setup if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) { await next(); @@ -120,28 +120,31 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .where( and( eq(account.userId, jwt.sub), - sql`${account.providerId} IN ('authentik', 'google', 'github')` + sql`${account.providerId} IN (\'authentik\', \'google\', \'github\')` ) ) .limit(1); 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 ? 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 ?? "") as string, name, role: "groomer", isSuperUser: false, active: true, - }) - .returning(); + } as Parameters[0] extends { values: infer V } ? V : never) + .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})` @@ -177,7 +180,7 @@ export function requireRole( if (!(allowedRoles as string[]).includes(staffRow.role)) { return c.json( { - error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`, + error: `Forbidden: role \'${staffRow.role}\' is not permitted to access this resource`, }, 403 ); @@ -210,7 +213,7 @@ export function requireRoleOrSuperUser( { error: hasAllowedRole ? "Forbidden: super user privileges required" - : `Forbidden: role '${staffRow.role}' is not permitted`, + : `Forbidden: role \'${staffRow.role}\' is not permitted`, }, 403 ); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index a4c2b87..7b7b160 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -36,7 +36,7 @@ portalRouter.post( return c.json({ error: "Client not found" }, 404); } - const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001"; let staffId = DEMO_STAFF_ID; const [demoStaff] = await db @@ -71,6 +71,82 @@ portalRouter.post( } ); +// Bridge Better Auth session → portal session for real SSO customers (GRO-1866). +// Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession. +import { getAuth } from "../lib/auth.js"; + +portalRouter.post("/session-from-auth", async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [client] = await db + .select() + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (!client) { + return c.json({ error: "No client record found for this user" }, 404); + } + + const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001"; + + let staffId = DEMO_STAFF_ID; + const [demoStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.id, DEMO_STAFF_ID)) + .limit(1); + + if (!demoStaff) { + const [firstStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.active, true)) + .limit(1); + if (!firstStaff) { + return c.json({ error: "No staff records found" }, 500); + } + staffId = firstStaff.id; + } + + const [portalSession] = await db + .insert(impersonationSessions) + .values({ + staffId, + clientId: client.id, + reason: "sso-bridge", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }) + .returning(); + + if (!portalSession) { + return c.json({ error: "Failed to create session" }, 500); + } + + return c.json( + { + sessionId: portalSession.id, + clientId: client.id, + clientName: client.name, + }, + 201 + ); +}); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit);