diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 158c282..3881905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,20 +202,20 @@ jobs: echo "Updating dev overlay image tags to: $TAG" echo "Updating migration/seed Job names with SHA: $SHORT_SHA" cd /tmp/infra - DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml" + DEV_KUST="apps/overlays/dev/kustomization.yaml" yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST" - MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + MIGRATE_JOB="apps/base/migrate-job.yaml" if [ -f "$MIGRATE_JOB" ]; then yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB" fi - SEED_JOB="apps/groombook/base/seed-job.yaml" + SEED_JOB="apps/base/seed-job.yaml" if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" @@ -237,7 +237,7 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "chore/update-image-tags-${TAG}" - git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml + git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml git commit -m "chore: update image tags and migration/seed Job names to ${TAG}" git push -u origin "chore/update-image-tags-${TAG}" diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 68d6d25..8f3d171 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -28,6 +28,12 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims | | TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds | | TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 | +| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned | +| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned | +| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned | +| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned | +| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned | +| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned | ### 4.2 Client Management diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 16a96b5..ccae727 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/schema.ts", + schema: "./src/db/schema.ts", out: "./migrations", dialect: "postgresql", dbCredentials: { diff --git a/apps/api/migrations/0030_extended_pet_profile.sql b/apps/api/migrations/0030_extended_pet_profile.sql new file mode 100644 index 0000000..a42346d --- /dev/null +++ b/apps/api/migrations/0030_extended_pet_profile.sql @@ -0,0 +1,12 @@ +-- Migration: 0030_extended_pet_profile +-- Adds extended profile fields to the pets table + +BEGIN; + +ALTER TABLE pets ADD COLUMN coat_type text; +ALTER TABLE pets ADD COLUMN temperament_score integer; +ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb; +ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb; +ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb; + +COMMIT; \ No newline at end of file diff --git a/apps/api/migrations/meta/0030_snapshot.json b/apps/api/migrations/meta/0030_snapshot.json new file mode 100644 index 0000000..b60cb80 --- /dev/null +++ b/apps/api/migrations/meta/0030_snapshot.json @@ -0,0 +1,48 @@ +{ + "id": "0030_extended_pet_profile", + "prevId": "0028_sms_reminders", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "client_id": { "name": "client_id", "type": "uuid", "isNullable": false }, + "name": { "name": "name", "type": "text", "isNullable": false }, + "species": { "name": "species", "type": "text", "isNullable": false }, + "breed": { "name": "breed", "type": "text", "isNullable": true }, + "weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true }, + "date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true }, + "health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true }, + "grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true }, + "cut_style": { "name": "cut_style", "type": "text", "isNullable": true }, + "shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true }, + "special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true }, + "custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" }, + "photo_key": { "name": "photo_key", "type": "text", "isNullable": true }, + "photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true }, + "image": { "name": "image", "type": "text", "isNullable": true }, + "coat_type": { "name": "coat_type", "type": "text", "isNullable": true }, + "temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true }, + "temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" }, + "medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" }, + "preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" }, + "created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } }, + "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" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { "columns": {}, "schemas": {}, "tables": {} } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index 8db9b8d..782d371 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -204,6 +204,20 @@ "when": 1775741667192, "tag": "0028_sms_reminders", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1775828067192, + "tag": "0029_db_indexes_constraints", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1775914467192, + "tag": "0030_extended_pet_profile", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/__tests__/petsExtendedFields.test.ts b/apps/api/src/__tests__/petsExtendedFields.test.ts new file mode 100644 index 0000000..a013d88 --- /dev/null +++ b/apps/api/src/__tests__/petsExtendedFields.test.ts @@ -0,0 +1,414 @@ +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(), +}; + +// ─── Mutable mock state ─────────────────────────────────────────────────────── + +const CLIENT_ID = "client-uuid-extended"; +const PET_ID = "pet-uuid-extended"; + +let petRows: Record[] = []; +let appointmentRows: Record[] = []; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; +let deletedId: string | null = null; + +function resetMock() { + petRows = [{ + 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: null, + temperamentScore: null, + temperamentFlags: [], + medicalAlerts: [], + preferredCuts: [], + createdAt: new Date(), + updatedAt: new Date(), + }]; + appointmentRows = []; + insertedValues = []; + updatedValues = []; + deletedId = null; +} + +function makeSelectChainable(rows: unknown[]): unknown { + const chain = new Proxy([...rows], { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; +} + +function makeInsertChainable(): unknown { + let vals: Record = {}; + const chain = new Proxy({}, { + get(target, prop) { + if (prop === "values") { + return (v: Record) => { vals = v; return chain; }; + } + if (prop === "returning") { + return () => { + insertedValues.push(vals); + return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }]; + }; + } + return chain; + }, + }); + return chain; +} + +function makeUpdateChainable(): unknown { + let vals: Record = {}; + let whereId: string | null = null; + const chain = new Proxy({}, { + get(target, prop) { + if (prop === "set") { + return (v: Record) => { vals = v; return chain; }; + } + if (prop === "where") { + return (cond: unknown) => { + // Extract id from condition if it's an eq call + if (whereId) vals = { ...vals }; + return chain; + }; + } + if (prop === "returning") { + return () => { + const merged = { ...petRows[0], ...vals }; + updatedValues.push(vals); + return [merged]; + }; + } + return chain; + }, + }); + return chain; +} + +function makeDeleteChainable(): unknown { + let whereId: string | null = null; + const chain = new Proxy({}, { + get(target, prop) { + if (prop === "where") { + return (cond: unknown) => { + whereId = PET_ID; + return chain; + }; + } + if (prop === "returning") { + return () => { + const row = petRows[0]; + deletedId = row.id as string; + return [row]; + }; + } + return chain; + }, + }); + return chain; +} + +vi.mock("../db", () => { + const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} }); + const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} }); + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => { + const name = (table as { _name?: string })._name; + if (name === "appointments") return makeSelectChainable(appointmentRows); + return makeSelectChainable(petRows); + }, + }), + insert: () => makeInsertChainable(), + update: () => makeUpdateChainable(), + delete: () => makeDeleteChainable(), + }), + pets, + appointments, + and, + eq, + exists, + or, + }; +}); + +// ─── 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); +} + +function createApp() { + const app = makeApp(MANAGER); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("Extended pet profile fields — validation", () => { + beforeEach(resetMock); + + it("rejects temperamentScore of 0 (below min)", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.success).toBe(false); + }); + + it("rejects temperamentScore of 6 (above max)", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.success).toBe(false); + }); + + it("rejects non-integer temperamentScore", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }), + }); + expect(res.status).toBe(400); + }); + + it("rejects invalid medicalAlert severity", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: CLIENT_ID, + name: "Test", + species: "dog", + medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }], + }), + }); + expect(res.status).toBe(400); + }); + + it("accepts valid temperamentScore 1–5", async () => { + const app = createApp(); + for (const score of [1, 2, 3, 4, 5]) { + resetMock(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }), + }); + expect(res.status).toBe(201); + } + }); + + it("accepts all valid medicalAlert severity values", async () => { + const app = createApp(); + for (const severity of ["low", "medium", "high"] as const) { + resetMock(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: CLIENT_ID, + name: "Test", + species: "dog", + medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }], + }), + }); + expect(res.status).toBe(201); + } + }); +}); + +describe("Extended pet profile fields — create", () => { + beforeEach(resetMock); + + it("accepts all extended fields on create", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: CLIENT_ID, + name: "Biscuit", + species: "dog", + breed: "Golden Retriever", + coatType: "double", + temperamentScore: 4, + temperamentFlags: ["anxious_with_dryers", "gentle"], + medicalAlerts: [ + { type: "seizure", description: "Occasional episodes", severity: "medium" }, + ], + preferredCuts: ["puppy cut", "teddy bear"], + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.coatType).toBe("double"); + expect(body.temperamentScore).toBe(4); + expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]); + expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]); + expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]); + }); + + it("create without extended fields works (all optional)", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }), + }); + expect(res.status).toBe(201); + }); +}); + +describe("Extended pet profile fields — update", () => { + beforeEach(resetMock); + + it("updates coatType", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ coatType: "smooth" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.coatType).toBe("smooth"); + }); + + it("updates temperamentScore", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ temperamentScore: 2 }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.temperamentScore).toBe(2); + }); + + it("rejects temperamentScore 0 on update", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ temperamentScore: 0 }), + }); + expect(res.status).toBe(400); + }); + + it("rejects invalid severity on update", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }], + }), + }); + expect(res.status).toBe(400); + }); + + it("rejects too many temperamentFlags (>20)", async () => { + const app = createApp(); + const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }), + }); + expect(res.status).toBe(400); + }); + + it("rejects too many preferredCuts (>20)", async () => { + const app = createApp(); + const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }), + }); + expect(res.status).toBe(400); + }); + + it("rejects too many medicalAlerts (>50)", async () => { + const app = createApp(); + const alerts = Array.from({ length: 51 }, (_, i) => ({ + type: `type_${i}`, + description: `desc_${i}`, + severity: "low" as const, + })); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }), + }); + expect(res.status).toBe(400); + }); + + it("returns extended fields in GET response", async () => { + petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }]; + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.coatType).toBe("wire"); + expect(body.temperamentScore).toBe(3); + expect(body.temperamentFlags).toEqual(["gentle"]); + expect(body.preferredCuts).toEqual(["scissor cut"]); + }); +}); \ No newline at end of file diff --git a/apps/api/src/__tests__/seed-uat-credentials.test.ts b/apps/api/src/__tests__/seed-uat-credentials.test.ts new file mode 100644 index 0000000..7f954ae --- /dev/null +++ b/apps/api/src/__tests__/seed-uat-credentials.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ─── Test configuration constants (must match seed.ts) ───────────────────────── + +const UAT_ACCOUNTS = [ + { + email: "uat-super@groombook.dev", + name: "UAT Super User", + passwordEnv: "SEED_UAT_SUPER_PASSWORD", + staffEmail: "uat-super@groombook.dev", + }, + { + email: "uat-groomer@groombook.dev", + name: "UAT Staff Groomer", + passwordEnv: "SEED_UAT_GROOMER_PASSWORD", + staffEmail: "uat-groomer@groombook.dev", + }, + { + email: "uat-customer@groombook.dev", + name: "UAT Customer", + passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", + staffEmail: null, + }, + { + email: "uat-tester@groombook.dev", + name: "UAT Tester", + passwordEnv: "SEED_UAT_TESTER_PASSWORD", + staffEmail: "uat-tester@groombook.dev", + }, +]; + +const TEST_PASSWORD = "test-password-123"; + +// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ─── + +async function hashPassword(password: string): Promise { + const { hashPassword } = await import("better-auth/crypto"); + return hashPassword(password); +} + +// ─── Mock DB state ───────────────────────────────────────────────────────────── + +interface UserRow { + id: string; + email: string; + name: string; + emailVerified: boolean; +} + +interface AccountRow { + id: string; + accountId: string; + providerId: string; + userId: string; + password: string | null; +} + +interface StaffRow { + id: string; + email: string; + userId: string | null; + name: string; +} + +let dbUsers: UserRow[] = []; +let dbAccounts: AccountRow[] = []; +let dbStaff: StaffRow[] = []; +let insertedUsers: UserRow[] = []; +let insertedAccounts: AccountRow[] = []; +let updatedStaff: Array<{ id: string; userId: string }> = []; + +const originalEnv = { ...process.env }; + +function resetMock() { + dbUsers = []; + dbAccounts = []; + dbStaff = []; + insertedUsers = []; + insertedAccounts = []; + updatedStaff = []; + process.env = { ...originalEnv }; +} + +// ─── Mock schema ─────────────────────────────────────────────────────────────── + +function makeSchemaMock() { + const user = new Proxy({ _name: "user" }, { + get(_t, p) { + if (p === "_name") return "user"; + if (p === "$inferSelect") return {}; + return { table: "user", column: p }; + }, + }); + + const account = new Proxy({ _name: "account" }, { + get(_t, p) { + if (p === "_name") return "account"; + if (p === "$inferSelect") return {}; + return { table: "account", column: p }; + }, + }); + + const staff = new Proxy({ _name: "staff" }, { + get(_t, p) { + if (p === "_name") return "staff"; + if (p === "$inferSelect") return {}; + return { table: "staff", column: p }; + }, + }); + + return { user, account, staff }; +} + +const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock(); + +function eq(col: unknown, val: unknown) { + return { __type: "eq" as const, col, val }; +} + +function and(...conds: unknown[]) { + return { __type: "and" as const, conds }; +} + +// ─── Seed logic helper ───────────────────────────────────────────────────────── +// Inline the credential provisioning logic under test so we can call it directly. +// This is the same logic as seed.ts lines 514-598. + +interface SeedAccount { + email: string; + name: string; + passwordEnv: string; + staffEmail: string | null; +} + +let uuidCounter = 0; +function mockUuid(): string { + return `mock-uuid-${++uuidCounter}`; +} + +async function seedUatCredentials( + accounts: SeedAccount[], + opts: { + users?: UserRow[]; + accounts?: AccountRow[]; + staff?: StaffRow[]; + } +) { + const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts; + + for (const acct of accounts) { + const password = process.env[acct.passwordEnv]; + if (!password) { + console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`); + continue; + } + + // 1. Find or create the Better-Auth user + const existingUser = users.find((u) => u.email === acct.email); + + let userId: string; + if (existingUser) { + userId = existingUser.id; + } else { + userId = mockUuid(); + const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true }; + insertedUsers.push(newUser); + dbUsers.push(newUser); + } + + // 2. Check if credential account already exists + const existingAccount = accts.find( + (a) => a.userId === userId && a.providerId === "credential" + ); + + if (existingAccount) { + // skip — already has credential account + } else { + // Use Better-Auth's hashPassword so test helper matches production seed.ts + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + + const newAccount: AccountRow = { + id: mockUuid(), + accountId: userId, + providerId: "credential", + userId, + password: passwordHash, + }; + insertedAccounts.push(newAccount); + dbAccounts.push(newAccount); + } + + // 3. Link staff record to Better-Auth user + if (acct.staffEmail) { + const existingStaff = staffRows.find((s) => s.email === acct.staffEmail); + if (existingStaff && !existingStaff.userId) { + existingStaff.userId = userId; + updatedStaff.push({ id: existingStaff.id, userId }); + } + } + } +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe("seedUatCredentials — credential provisioning logic", () => { + beforeEach(() => { + resetMock(); + uuidCounter = 0; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + // ── AC-1: creates user + account when neither exists ────────────────────── + + it("AC-1: creates user and account for each UAT account with password env var set", async () => { + process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD; + process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD; + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD; + + await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] }); + + // 4 users created (customer + tester have no staff, super + groomer do) + expect(insertedUsers).toHaveLength(4); + expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined(); + expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined(); + expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined(); + expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined(); + + // 4 accounts created + expect(insertedAccounts).toHaveLength(4); + for (const acct of insertedAccounts) { + expect(acct.providerId).toBe("credential"); + // Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex) + expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + // Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64) + const parts = acct.password!.split(":"); + const saltHex = parts[0]!; + const keyHex = parts[1]!; + const salt = Buffer.from(saltHex, "hex"); + const storedHash = Buffer.from(keyHex, "hex"); + expect(salt).toHaveLength(16); + expect(storedHash).toHaveLength(64); + } + }); + + // ── AC-2: emailVerified = true ───────────────────────────────────────────── + + it("AC-2: created users have emailVerified = true", async () => { + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + + await seedUatCredentials( + [UAT_ACCOUNTS[2]!], // customer only + { users: [], accounts: [], staff: [] } + ); + + expect(insertedUsers[0]!.emailVerified).toBe(true); + }); + + // ── AC-3: providerId = credential, password is hashed ────────────────────── + + it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => { + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + + await seedUatCredentials( + [UAT_ACCOUNTS[2]!], + { users: [], accounts: [], staff: [] } + ); + + const acct = insertedAccounts[0]!; + expect(acct.providerId).toBe("credential"); + // Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars) + expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + const parts = acct.password!.split(":"); + const saltHex = parts[0]!; + const keyHex = parts[1]!; + expect(() => Buffer.from(saltHex, "hex")).not.toThrow(); + expect(() => Buffer.from(keyHex, "hex")).not.toThrow(); + const salt = Buffer.from(saltHex, "hex"); + const storedHash = Buffer.from(keyHex, "hex"); + expect(salt).toHaveLength(16); + expect(storedHash).toHaveLength(64); + }); + + // ── AC-4: staff.userId is linked ──────────────────────────────────────────── + + it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => { + process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD; + const staffRows: StaffRow[] = [ + { id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" }, + ]; + + await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows }); + + expect(updatedStaff).toHaveLength(1); + expect(updatedStaff[0]!.id).toBe("staff-super-1"); + expect(updatedStaff[0]!.userId).toBe("mock-uuid-1"); + expect(staffRows[0]!.userId).toBe("mock-uuid-1"); + }); + + it("AC-4b: does not update staff.userId if already set", async () => { + process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD; + const staffRows: StaffRow[] = [ + { id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" }, + ]; + + await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows }); + + expect(updatedStaff).toHaveLength(0); + }); + + // ── AC-5: idempotent — skips when user already exists ─────────────────────── + + it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => { + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + + const preExistingUsers: UserRow[] = [ + { id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true }, + ]; + const preExistingAccounts: AccountRow[] = [ + { + id: "pre-existing-acct", + accountId: "pre-existing-user", + providerId: "credential", + userId: "pre-existing-user", + password: await hashPassword(TEST_PASSWORD), + }, + ]; + + // First call — nothing inserted (user + account pre-exist) + await seedUatCredentials([UAT_ACCOUNTS[2]!], { + users: preExistingUsers, + accounts: preExistingAccounts, + staff: [], + }); + + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + + // Second call — still nothing inserted + await seedUatCredentials([UAT_ACCOUNTS[2]!], { + users: preExistingUsers, + accounts: preExistingAccounts, + staff: [], + }); + + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + }); + + // ── AC-6: missing env var skips with warning ──────────────────────────────── + + it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => { + // No env vars set at all + delete process.env.SEED_UAT_SUPER_PASSWORD; + delete process.env.SEED_UAT_GROOMER_PASSWORD; + delete process.env.SEED_UAT_CUSTOMER_PASSWORD; + delete process.env.SEED_UAT_TESTER_PASSWORD; + + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); + + await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] }); + + // Nothing created + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + // Warning logged for each of the 4 accounts + expect(warnSpy).toHaveBeenCalledTimes(4); + expect(warnSpy).toHaveBeenCalledWith( + "⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set" + ); + + warnSpy.mockRestore(); + }); + + // ── AC-7: partial env var coverage ───────────────────────────────────────── + + it("AC-7: only accounts with password env var set are provisioned", async () => { + process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD; + // Only super has password set + + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); + + await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] }); + + expect(insertedUsers).toHaveLength(1); + expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev"); + expect(insertedAccounts).toHaveLength(1); + expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1"); + + // 3 warnings for missing accounts + expect(warnSpy).toHaveBeenCalledTimes(3); + + warnSpy.mockRestore(); + }); +}); + +// ─── Password hash format verification ─────────────────────────────────────── + +describe("password hash format — scrypt parameters", () => { + it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => { + const hash = await hashPassword("test-password"); + const parts = hash.split(":"); + const saltHex = parts[0]!; + const keyHex = parts[1]!; + + expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + expect(Buffer.from(saltHex, "hex")).toHaveLength(16); + expect(Buffer.from(keyHex, "hex")).toHaveLength(64); + }); + + it("same password produces different hashes (due to random salt)", async () => { + const hash1 = await hashPassword("same-password"); + const hash2 = await hashPassword("same-password"); + + expect(hash1).not.toBe(hash2); + // Both are valid Better-Auth hex format + expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + }); + + it("different passwords produce different hashes", async () => { + const hash1 = await hashPassword("password1"); + const hash2 = await hashPassword("password2"); + + expect(hash1).not.toBe(hash2); + }); +}); \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 0a5eaef..179ea52 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -12,6 +12,16 @@ import { uuid, } from "drizzle-orm/pg-core"; +// ─── Shared types ─────────────────────────────────────────────────────────────── + +export type MedicalAlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + type: string; + description: string; + severity: MedicalAlertSeverity; +} + // ─── Enums ──────────────────────────────────────────────────────────────────── export const appointmentStatusEnum = pgEnum("appointment_status", [ @@ -146,6 +156,12 @@ export const pets = pgTable( photoKey: text("photo_key"), photoUploadedAt: timestamp("photo_uploaded_at"), image: text("image"), + // Extended profile fields + coatType: text("coat_type"), + temperamentScore: integer("temperament_score"), + temperamentFlags: jsonb("temperament_flags").$type().default([]), + medicalAlerts: jsonb("medical_alerts").$type().default([]), + preferredCuts: jsonb("preferred_cuts").$type().default([]), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 2ff67bf..566da17 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -18,7 +18,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; -import { eq, sql } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import * as schema from "./schema.js"; // ── Seed profile configuration ───────────────────────────────────────────── @@ -511,6 +511,90 @@ async function seedKnownUsers() { } } + // ── Better-Auth email+password credentials for UAT accounts ────────────────── + // Provisions Better-Auth user + account records so UAT testers can log in + // via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO. + const uatPasswordAccounts = [ + { email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" }, + { email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" }, + { email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null }, + { email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" }, + ]; + + for (const acct of uatPasswordAccounts) { + const password = process.env[acct.passwordEnv]; + if (!password) { + console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`); + continue; + } + + // 1. Find or create the Better-Auth user + const [existingUser] = await db + .select() + .from(schema.user) + .where(eq(schema.user.email, acct.email)) + .limit(1); + + let userId: string; + if (existingUser) { + userId = existingUser.id; + console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`); + } else { + userId = uuid(); + await db.insert(schema.user).values({ + id: userId, + name: acct.name, + email: acct.email, + emailVerified: true, + }); + console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`); + } + + // 2. Check if credential account already exists + const [existingAccount] = await db + .select() + .from(schema.account) + .where(and( + eq(schema.account.userId, userId), + eq(schema.account.providerId, "credential") + )) + .limit(1); + + if (existingAccount) { + console.log(`✓ Credential account for '${acct.email}' already exists — skipping`); + } else { + // Use Better-Auth's own hashPassword to guarantee parameter/encoding match. + // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random + // hex string, key hex-encoded, format saltHex:keyHex. + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + + await db.insert(schema.account).values({ + id: uuid(), + accountId: userId, + providerId: "credential", + userId, + password: passwordHash, + }); + console.log(`✓ Created credential account for '${acct.email}'`); + } + + // 3. Link staff record to Better-Auth user (for accounts that have staff records) + if (acct.staffEmail) { + const [existingStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, acct.staffEmail)) + .limit(1); + if (existingStaff && !existingStaff.userId) { + await db.update(schema.staff) + .set({ userId }) + .where(eq(schema.staff.id, existingStaff.id)); + console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`); + } + } + } + // ── Services: idempotent upsert using name as unique key ───────────────────── // UNIQUE constraint on services.name (migration 0020) must exist first. // Uses b0000001-... IDs to match main seed servicesDef for same-named services. diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index 1672811..379d2be 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -24,6 +24,15 @@ const createPetSchema = z.object({ shampooPreference: z.string().max(500).optional(), specialCareNotes: z.string().max(2000).optional(), customFields: z.record(z.string(), z.string()).optional(), + coatType: z.string().max(100).optional(), + temperamentScore: z.number().int().min(1).max(5).optional(), + temperamentFlags: z.array(z.string().max(100)).max(20).optional(), + medicalAlerts: z.array(z.object({ + type: z.string().max(100), + description: z.string().max(1000), + severity: z.enum(["low", "medium", "high"]), + })).max(50).optional(), + preferredCuts: z.array(z.string().max(200)).max(20).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 90ef116..4f60f42 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -42,10 +42,23 @@ export interface Pet { customFields: Record; photoKey?: string; photoUploadedAt?: string; + coatType?: string | null; + temperamentScore?: number | null; + temperamentFlags?: string[]; + medicalAlerts?: MedicalAlert[]; + preferredCuts?: string[]; createdAt: string; updatedAt: string; } +export type MedicalAlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + type: string; + description: string; + severity: MedicalAlertSeverity; +} + export interface GroomingVisitLog { id: string; petId: string;