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/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 f8dbc14..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,27 +120,27 @@ 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 emailPrefix = 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); @@ -180,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 ); @@ -213,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);