diff --git a/.gitignore b/.gitignore index 112405c..8ab6172 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ node_modules/ dist/ +.DS_Store +*.log .env .env.local *.local -.DS_Store -*.log .turbo/ coverage/ minimax-output/ diff --git a/Dockerfile b/Dockerfile index ada297f..6914964 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,37 +2,52 @@ FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR /app +# Install deps FROM base AS deps -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/api/package.json apps/api/ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY packages/db/package.json packages/db/ +COPY packages/types/package.json packages/types/ RUN pnpm install --frozen-lockfile +# Build FROM deps AS builder RUN mkdir -p /home/node/.cache/node/corepack -COPY apps/api/ apps/api/ -RUN pnpm --filter @groombook/api build +COPY packages/ packages/ +COPY src/ src/ +COPY tsconfig.json ./ +RUN pnpm --filter @groombook/types build && \ + pnpm --filter @groombook/db build && \ + pnpm --filter @groombook/api build +# Runtime FROM node:22-alpine AS runner RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR /app ENV NODE_ENV=production -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY --from=builder /app/apps/api/package.json apps/api/ -COPY --from=builder /app/apps/api/dist apps/api/dist +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/dist dist/ +COPY --from=builder /app/packages/db/package.json packages/db/ +COPY --from=builder /app/packages/db/dist packages/db/dist +COPY --from=builder /app/packages/types/package.json packages/types/ +COPY --from=builder /app/packages/types/dist packages/types/dist RUN pnpm install --frozen-lockfile --prod EXPOSE 3000 RUN apk add --no-cache curl HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 -CMD ["node", "apps/api/dist/index.js"] +CMD ["node", "dist/index.js"] +# Migrate stage — runs drizzle-kit migrate against the database FROM builder AS migrate -CMD ["pnpm", "--filter", "@groombook/api", "db:migrate"] +CMD ["pnpm", "db:migrate"] +# Seed stage — populates the database with test data FROM builder AS seed -CMD ["pnpm", "--filter", "@groombook/api", "db:seed"] +CMD ["pnpm", "db:seed"] +# Reset stage — drops all tables, re-runs migrations, and re-seeds FROM builder AS reset -CMD ["pnpm", "--filter", "@groombook/api", "db:reset"] +CMD ["pnpm", "db:reset"] diff --git a/README.md b/README.md index e126579..a3b759b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This repository contains the GroomBook API service, including: ## Structure ``` -apps/api/ # API service source +src/ # API service source packages/db/ # Database schema, migrations, and utilities packages/types/ # Shared TypeScript types ``` diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 68d6d25..cb02d20 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -26,8 +26,19 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | # | Scenario | Steps | Expected | |---|----------|-------|----------| | 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 | +| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table | +| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved | +| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true | +| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table | +| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) | +| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" | +| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error | ### 4.2 Client Management @@ -51,6 +62,14 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-3.5 | Delete pet | DELETE /api/pets/{id} | 200 OK, pet deleted | | TC-API-3.6 | Upload pet photo | POST /api/pets/{id}/photo/upload-url, then confirm | 200 OK, photo uploaded and key stored | | TC-API-3.7 | View pet photo | GET /api/pets/{id}/photo | 200 OK, presigned URL returned | +| TC-API-3.8 | Create pet with extended fields | POST /api/pets with coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts | 201 Created, all extended fields stored and returned | +| TC-API-3.9 | Update pet extended fields | PATCH /api/pets/{id} with coatType, temperamentScore, medicalAlerts | 200 OK, extended fields updated | +| TC-API-3.10 | Reject invalid coatType | POST /api/pets with coatType: "smooth" | 400 Bad Request, invalid coatType rejected | +| TC-API-3.11 | Reject out-of-range temperamentScore | POST /api/pets with temperamentScore: 0 or 6 | 400 Bad Request, score out of range rejected | +| TC-API-3.12 | Reject invalid medicalAlert severity | POST /api/pets with medicalAlerts severity: "critical" | 400 Bad Request, invalid severity rejected | +| 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 | ### 4.4 Appointment Scheduling @@ -177,6 +196,18 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated | | TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled | +### 4.15 Buffer Rules + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-15.1 | List buffer rules | GET /api/admin/buffer-rules | 200 OK, list of active buffer rules returned | +| TC-API-15.2 | Create buffer rule | POST /api/admin/buffer-rules with service, species, sizeCategory, bufferMinutes | 201 Created, buffer rule created | +| TC-API-15.3 | Update buffer rule | PATCH /api/admin/buffer-rules/{id} with updated bufferMinutes | 200 OK, buffer rule updated | +| TC-API-15.4 | Delete buffer rule | DELETE /api/admin/buffer-rules/{id} | 200 OK, buffer rule removed | +| TC-API-15.5 | Reject invalid bufferMinutes | POST /api/admin/buffer-rules with bufferMinutes: -5 | 400 Bad Request, invalid bufferMinutes rejected | +| TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required | +| TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time | + ## Pass/Fail Criteria **Pass:** diff --git a/apps/api/src/__tests__/petsExtendedFields.test.ts b/apps/api/src/__tests__/petsExtendedFields.test.ts index 33e0698..44085ac 100644 --- a/apps/api/src/__tests__/petsExtendedFields.test.ts +++ b/apps/api/src/__tests__/petsExtendedFields.test.ts @@ -2,6 +2,7 @@ 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"; +import { and, eq, exists, or } from "../db/index.js"; // ─── Mock staff fixtures ────────────────────────────────────────────────────── @@ -21,8 +22,8 @@ const MANAGER: StaffRow = { // ─── Mutable mock state ─────────────────────────────────────────────────────── -const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; -const PET_ID = "660e8400-e29b-41d4-a716-446655440002"; +const CLIENT_ID = "a0000000-0000-4000-8000-000000000001"; +const PET_ID = "b0000000-0000-4000-8000-000000000002"; let petRows: Record[] = []; let appointmentRows: Record[] = []; @@ -145,7 +146,8 @@ function makeDeleteChainable(): unknown { return chain; } -vi.mock("../db", () => { +vi.mock("../db", async (importOriginal) => { + const db = await importOriginal(); 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 { @@ -322,11 +324,11 @@ describe("Extended pet profile fields — update", () => { const res = await app.request(`/pets/${PET_ID}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ coatType: "smooth" }), + body: JSON.stringify({ coatType: "double" }), }); expect(res.status).toBe(200); const body = await res.json(); - expect(body.coatType).toBe("smooth"); + expect(body.coatType).toBe("double"); }); it("updates temperamentScore", async () => { diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index dc3d7de..9eb0918 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -45,40 +45,76 @@ const GROOMER: StaffRow = { let staffLookupResult: StaffRow | null = null; let managerFallbackResult: StaffRow | null = MANAGER; +let userLookupResult: { id: string; name: string | null; email: string | null } | null = null; +let _insertedStaff: StaffRow | null = null; vi.mock("../db", () => { - const staff = new Proxy( - { _name: "staff" }, - { - get(target, prop) { - if (prop === "_name") return "staff"; - if (prop === "$inferSelect") return {}; - return { table: "staff", column: prop }; - }, - } - ); + const makeTableProxy = (name: string) => + new Proxy( + { _name: name }, + { + get(target, prop) { + if (prop === "_name") return name; + if (prop === "$inferSelect") return {}; + return { table: name, column: prop }; + }, + } + ); + + const staff = makeTableProxy("staff"); + const user = makeTableProxy("user"); + + const buildQuery = (result: unknown, fallback: unknown) => ({ + [Symbol.iterator]: function* () { + if (result) yield result; + }, + limit: (_n: number) => { + const item = result ?? fallback; + return { + [Symbol.iterator]: function* () { if (item) yield item; }, + 0: item, + length: item ? 1 : 0, + }; + }, + }); return { getDb: () => ({ select: () => ({ - from: () => ({ - where: () => ({ - limit: () => { - // dev mode fallback to first manager - return managerFallbackResult ? [managerFallbackResult] : []; - }, - [Symbol.iterator]: function* () { - if (staffLookupResult) yield staffLookupResult; - }, - 0: staffLookupResult, - length: staffLookupResult ? 1 : 0, - }), + from: (table: unknown) => ({ + where: () => buildQuery( + table === staff ? staffLookupResult : userLookupResult, + table === staff ? managerFallbackResult : null + ), + }), + }), + insert: (_table: unknown) => ({ + values: (vals: Record) => ({ + returning: () => { + const newStaff: StaffRow = { + id: "new-staff-id", + oidcSub: null, + userId: vals.userId as string, + role: vals.role as StaffRow["role"], + isSuperUser: false, + name: vals.name as string, + email: vals.email as string, + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + _insertedStaff = newStaff; + return [newStaff]; + }, }), }), }), staff, + user, eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), and: vi.fn((..._clauses: unknown[]) => ({})), + sql: vi.fn((..._args: unknown[]) => ({})), }; }); @@ -87,6 +123,8 @@ vi.mock("../db", () => { function resetMocks() { staffLookupResult = null; managerFallbackResult = MANAGER; + userLookupResult = null; + _insertedStaff = null; } /** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */ @@ -96,7 +134,10 @@ function buildApp( ) { const app = new Hono(); app.use("*", async (c, next) => { - c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" }); + c.set("jwtPayload", { + sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub", + email: userLookupResult?.email, + }); await next(); }); app.use("*", middleware); @@ -202,6 +243,50 @@ describe("resolveStaffMiddleware", () => { const body = await res.json(); expect(body.error).toMatch(/no staff records found/i); }); + + it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => { + staffLookupResult = null; + userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" }; + let capturedStaff: StaffRow | null = null; + const app = buildApp(resolveStaffMiddleware, (c) => { + capturedStaff = c.get("staff"); + return c.json({ ok: true }); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(200); + expect(capturedStaff).not.toBeNull(); + expect(capturedStaff!.role).toBe("groomer"); + expect(capturedStaff!.userId).toBe("ba-user-new"); + expect(capturedStaff!.name).toBe("New User"); + expect(capturedStaff!.email).toBe("newuser@example.com"); + expect(capturedStaff!.isSuperUser).toBe(false); + }); + + it("auto-provision: falls back to email prefix when user has no name", async () => { + staffLookupResult = null; + userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" }; + let capturedStaff: StaffRow | null = null; + const app = buildApp(resolveStaffMiddleware, (c) => { + capturedStaff = c.get("staff"); + return c.json({ ok: true }); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(200); + expect(capturedStaff!.name).toBe("firstlogin"); + }); + + it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => { + staffLookupResult = null; + userLookupResult = null; + const app = buildApp(resolveStaffMiddleware); + + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/no staff record found for authenticated user/i); + }); }); // ─── requireRole tests ──────────────────────────────────────────────────────── 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/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/index.ts b/apps/api/src/index.ts index b9ccd84..0b53141 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { getDb, businessSettings, eq, staff } from "./db/index.js"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; +import { bufferRulesRouter } from "./routes/buffer-rules.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; import { webhooksRouter } from "./routes/stripe-webhooks.js"; @@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer")); // Staff write routes: manager OR super-user (combined guard — avoids AND stacking) api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager")); api.use("/admin/*", requireRoleOrSuperUser("manager")); +api.use("/buffer-rules/*", requireRole("manager")); api.use("/admin/settings/*", requireSuperUser()); api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager", "groomer")); @@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); +api.route("/buffer-rules", bufferRulesRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index a3c9d8b..1d315ac 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { and, eq, getDb, sql, staff } from "../db/index.js"; +import { and, eq, getDb, sql, staff, user } from "../db/index.js"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -110,6 +110,33 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( return; } } + // Auto-provision: no staff record exists for this user at all, but a valid + // Better-Auth user session exists (jwt.sub = user.id from user table). + // Create a minimal groomer staff record on first login. + const [userRow] = await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, jwt.sub)) + .limit(1); + if (userRow) { + const [newStaff] = await db + .insert(staff) + .values({ + name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown", + email: userRow.email ?? jwt.email ?? "", + userId: jwt.sub, + role: "groomer", + isSuperUser: false, + active: true, + }) + .returning(); + if (!newStaff) { + return c.json({ error: "Internal error: staff record creation failed" }, 500); + } + c.set("staff", newStaff); + await next(); + return; + } return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403 diff --git a/apps/api/src/routes/buffer-rules.ts b/apps/api/src/routes/buffer-rules.ts new file mode 100644 index 0000000..296fec2 --- /dev/null +++ b/apps/api/src/routes/buffer-rules.ts @@ -0,0 +1,124 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { and, eq, getDb, isNull } from "../db/index.js"; +import type { AppEnv } from "../middleware/rbac.js"; +import { bufferRules, services } from "../db/index.js"; + +export const bufferRulesRouter = new Hono(); + +const createBufferRuleSchema = z.object({ + serviceId: z.string().uuid(), + sizeCategory: z + .enum(["small", "medium", "large", "extra_large"]) + .optional(), + coatType: z + .enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]) + .optional(), + bufferMinutes: z.number().int().positive(), +}); + +const updateBufferRuleSchema = z.object({ + bufferMinutes: z.number().int().positive(), +}); + +// GET / — list all buffer rules, optionally filtered by serviceId +bufferRulesRouter.get("/", async (c) => { + const db = getDb(); + const serviceId = c.req.query("serviceId"); + + const conditions = []; + if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId)); + + const rows = await db + .select({ + id: bufferRules.id, + serviceId: bufferRules.serviceId, + sizeCategory: bufferRules.sizeCategory, + coatType: bufferRules.coatType, + bufferMinutes: bufferRules.bufferMinutes, + createdAt: bufferRules.createdAt, + updatedAt: bufferRules.updatedAt, + serviceName: services.name, + }) + .from(bufferRules) + .innerJoin(services, eq(bufferRules.serviceId, services.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(bufferRules.createdAt); + + return c.json(rows); +}); + +// POST / — create a buffer rule +bufferRulesRouter.post( + "/", + zValidator("json", createBufferRuleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // Validate serviceId exists + const [svc] = await db + .select({ id: services.id }) + .from(services) + .where(eq(services.id, body.serviceId)); + if (!svc) return c.json({ error: "Service not found" }, 404); + + // Check for duplicate (service + size + coat) + const [existing] = await db + .select({ id: bufferRules.id }) + .from(bufferRules) + .where( + and( + eq(bufferRules.serviceId, body.serviceId), + body.sizeCategory !== undefined + ? eq(bufferRules.sizeCategory, body.sizeCategory) + : isNull(bufferRules.sizeCategory), + body.coatType !== undefined + ? eq(bufferRules.coatType, body.coatType) + : isNull(bufferRules.coatType) + ) + ); + if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409); + + const [row] = await db + .insert(bufferRules) + .values({ + serviceId: body.serviceId, + sizeCategory: body.sizeCategory ?? null, + coatType: body.coatType ?? null, + bufferMinutes: body.bufferMinutes, + }) + .returning(); + + return c.json(row, 201); + } +); + +// PATCH /:id — update bufferMinutes only +bufferRulesRouter.patch( + "/:id", + zValidator("json", updateBufferRuleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db + .update(bufferRules) + .set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() }) + .where(eq(bufferRules.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +// DELETE /:id — delete a buffer rule +bufferRulesRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(bufferRules) + .where(eq(bufferRules.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); \ No newline at end of file diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index 379d2be..dbc5418 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -24,7 +24,8 @@ 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(), + sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(), + coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).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({ diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 993cb96..3e47f4f 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -13,7 +13,9 @@ const createServiceSchema = z.object({ active: z.boolean().default(true), }); -const updateServiceSchema = createServiceSchema.partial(); +const updateServiceSchema = createServiceSchema.partial().extend({ + defaultBufferMinutes: z.number().int().min(0).optional(), +}); servicesRouter.get("/", async (c) => { const db = getDb(); diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 4f60f42..72f26ac 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -26,6 +26,19 @@ export interface Client { updatedAt: string; } +// ─── Medical Alerts ──────────────────────────────────────────────────────────── + +export type AlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + type: string; + description: string; + severity: AlertSeverity; +} + +// ─── Pet Profile Summary ──────────────────────────────────────────────────── + +export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless"; export interface Pet { id: string; clientId: string; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..e3961f7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,11 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + } +); diff --git a/package.json b/package.json index 871b4bd..e2a0164 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,41 @@ "private": true, "type": "module", "packageManager": "pnpm@9.15.4", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --project .", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.800.0", + "@aws-sdk/s3-request-presigner": "^3.800.0", + "@groombook/db": "workspace:*", + "@groombook/types": "workspace:*", + "@hono/node-server": "^1.13.7", + "@hono/zod-validator": "^0.7.6", + "better-auth": "^1.5.6", + "drizzle-orm": "^0.38.4", + "hono": "^4.6.17", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.16", + "postgres": "^3.4.5", + "stripe": "^22.0.0", + "telnyx": "^1.23.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.17", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.18.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", + "vitest": "^3.2.4" + }, "license": "AGPL-3.0-only" } diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..16a96b5 --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/schema.ts", + out: "./migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/packages/db/migrations/0000_colossal_colossus.sql b/packages/db/migrations/0000_colossal_colossus.sql new file mode 100644 index 0000000..c89c3b4 --- /dev/null +++ b/packages/db/migrations/0000_colossal_colossus.sql @@ -0,0 +1,70 @@ +CREATE TYPE "public"."appointment_status" AS ENUM('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show');--> statement-breakpoint +CREATE TYPE "public"."staff_role" AS ENUM('groomer', 'receptionist', 'manager');--> statement-breakpoint +CREATE TABLE "appointments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "pet_id" uuid NOT NULL, + "service_id" uuid NOT NULL, + "staff_id" uuid, + "status" "appointment_status" DEFAULT 'scheduled' NOT NULL, + "start_time" timestamp NOT NULL, + "end_time" timestamp NOT NULL, + "notes" text, + "price_cents" integer, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "email" text, + "phone" text, + "address" text, + "notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "name" text NOT NULL, + "species" text NOT NULL, + "breed" text, + "weight_kg" numeric(5, 2), + "date_of_birth" timestamp, + "grooming_notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "services" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "base_price_cents" integer NOT NULL, + "duration_minutes" integer NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "staff" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "oidc_sub" text, + "role" "staff_role" DEFAULT 'groomer' NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "staff_email_unique" UNIQUE("email"), + CONSTRAINT "staff_oidc_sub_unique" UNIQUE("oidc_sub") +); +--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pets" ADD CONSTRAINT "pets_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/0001_pet_health_alerts.sql b/packages/db/migrations/0001_pet_health_alerts.sql new file mode 100644 index 0000000..1314308 --- /dev/null +++ b/packages/db/migrations/0001_pet_health_alerts.sql @@ -0,0 +1 @@ +ALTER TABLE "pets" ADD COLUMN "health_alerts" text; diff --git a/packages/db/migrations/0002_invoices.sql b/packages/db/migrations/0002_invoices.sql new file mode 100644 index 0000000..b056a23 --- /dev/null +++ b/packages/db/migrations/0002_invoices.sql @@ -0,0 +1,31 @@ +CREATE TYPE "public"."invoice_status" AS ENUM('draft', 'pending', 'paid', 'void');--> statement-breakpoint +CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card', 'check', 'other');--> statement-breakpoint +CREATE TABLE "invoices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "appointment_id" uuid, + "client_id" uuid NOT NULL, + "subtotal_cents" integer NOT NULL, + "tax_cents" integer DEFAULT 0 NOT NULL, + "tip_cents" integer DEFAULT 0 NOT NULL, + "total_cents" integer NOT NULL, + "status" "invoice_status" DEFAULT 'draft' NOT NULL, + "payment_method" "payment_method", + "paid_at" timestamp, + "notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invoice_line_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "description" text NOT NULL, + "quantity" integer DEFAULT 1 NOT NULL, + "unit_price_cents" integer NOT NULL, + "total_cents" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action; diff --git a/packages/db/migrations/0003_recurring_series.sql b/packages/db/migrations/0003_recurring_series.sql new file mode 100644 index 0000000..72ff971 --- /dev/null +++ b/packages/db/migrations/0003_recurring_series.sql @@ -0,0 +1,10 @@ +-- Add recurring_series table to store recurrence patterns +CREATE TABLE "recurring_series" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "frequency_weeks" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); + +-- Extend appointments with series tracking +ALTER TABLE "appointments" ADD COLUMN "series_id" uuid REFERENCES "recurring_series"("id") ON DELETE SET NULL; +ALTER TABLE "appointments" ADD COLUMN "series_index" integer; diff --git a/packages/db/migrations/0004_reminder_logs.sql b/packages/db/migrations/0004_reminder_logs.sql new file mode 100644 index 0000000..6ed65f7 --- /dev/null +++ b/packages/db/migrations/0004_reminder_logs.sql @@ -0,0 +1,11 @@ +-- Add email opt-out flag to clients +ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean NOT NULL DEFAULT false; + +-- Track sent reminders to prevent duplicate sends +CREATE TABLE "reminder_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE, + "reminder_type" text NOT NULL, + "sent_at" timestamp DEFAULT now() NOT NULL, + UNIQUE ("appointment_id", "reminder_type") +); diff --git a/packages/db/migrations/0005_appointment_groups.sql b/packages/db/migrations/0005_appointment_groups.sql new file mode 100644 index 0000000..6a0a214 --- /dev/null +++ b/packages/db/migrations/0005_appointment_groups.sql @@ -0,0 +1,12 @@ +-- Appointment groups: link multiple appointments from the same client visit. +-- Each appointment in a group is for a different pet and may have a different groomer. +CREATE TABLE appointment_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Link appointments to a group (nullable — non-grouped appointments are unaffected) +ALTER TABLE appointments ADD COLUMN group_id UUID REFERENCES appointment_groups(id) ON DELETE SET NULL; diff --git a/packages/db/migrations/0006_pet_profile_attributes.sql b/packages/db/migrations/0006_pet_profile_attributes.sql new file mode 100644 index 0000000..40e23c5 --- /dev/null +++ b/packages/db/migrations/0006_pet_profile_attributes.sql @@ -0,0 +1,30 @@ +-- Extend pet profiles with grooming-specific attributes (closes groombook/groombook#13) +ALTER TABLE "pets" + ADD COLUMN "cut_style" text, + ADD COLUMN "shampoo_preference" text, + ADD COLUMN "special_care_notes" text, + ADD COLUMN "custom_fields" jsonb DEFAULT '{}' NOT NULL; +--> statement-breakpoint +CREATE TABLE "grooming_visit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "pet_id" uuid NOT NULL, + "appointment_id" uuid, + "staff_id" uuid, + "cut_style" text, + "products_used" text, + "notes" text, + "groomed_at" timestamp NOT NULL DEFAULT now(), + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "grooming_visit_logs" + ADD CONSTRAINT "grooming_visit_logs_pet_id_pets_id_fk" + FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "grooming_visit_logs" + ADD CONSTRAINT "grooming_visit_logs_appointment_id_appointments_id_fk" + FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "grooming_visit_logs" + ADD CONSTRAINT "grooming_visit_logs_staff_id_staff_id_fk" + FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action; diff --git a/packages/db/migrations/0007_tip_splitting.sql b/packages/db/migrations/0007_tip_splitting.sql new file mode 100644 index 0000000..64ec22a --- /dev/null +++ b/packages/db/migrations/0007_tip_splitting.sql @@ -0,0 +1,25 @@ +-- Add bather/assistant staff tracking to appointments and tip split ledger (closes groombook/groombook#12) + +-- Secondary staff member (e.g., bather) who assisted the primary groomer +ALTER TABLE "appointments" + ADD COLUMN "bather_staff_id" uuid REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint + +-- Stores per-staff tip allocations calculated when an invoice is paid +CREATE TABLE "invoice_tip_splits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "staff_id" uuid, + "staff_name" text NOT NULL, + "share_pct" numeric(5, 2) NOT NULL, + "share_cents" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "invoice_tip_splits" + ADD CONSTRAINT "invoice_tip_splits_invoice_id_invoices_id_fk" + FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "invoice_tip_splits" + ADD CONSTRAINT "invoice_tip_splits_staff_id_staff_id_fk" + FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action; diff --git a/packages/db/migrations/0008_business_settings.sql b/packages/db/migrations/0008_business_settings.sql new file mode 100644 index 0000000..7b851c6 --- /dev/null +++ b/packages/db/migrations/0008_business_settings.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "business_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "business_name" text DEFAULT 'GroomBook' NOT NULL, + "logo_base64" text, + "logo_mime_type" text, + "primary_color" text DEFAULT '#4f8a6f' NOT NULL, + "accent_color" text DEFAULT '#8b7355' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); + +-- Seed a default row so GET always returns something +INSERT INTO "business_settings" ("business_name", "primary_color", "accent_color") +VALUES ('GroomBook', '#4f8a6f', '#8b7355') +ON CONFLICT DO NOTHING; diff --git a/packages/db/migrations/0009_client_soft_delete.sql b/packages/db/migrations/0009_client_soft_delete.sql new file mode 100644 index 0000000..b495478 --- /dev/null +++ b/packages/db/migrations/0009_client_soft_delete.sql @@ -0,0 +1,6 @@ +-- Add client status (soft-delete support) +CREATE TYPE "client_status" AS ENUM ('active', 'disabled'); + +ALTER TABLE "clients" + ADD COLUMN "status" "client_status" NOT NULL DEFAULT 'active', + ADD COLUMN "disabled_at" timestamp; diff --git a/packages/db/migrations/0010_impersonation_sessions.sql b/packages/db/migrations/0010_impersonation_sessions.sql new file mode 100644 index 0000000..77faf98 --- /dev/null +++ b/packages/db/migrations/0010_impersonation_sessions.sql @@ -0,0 +1,26 @@ +-- Create impersonation_session_status enum and tables +CREATE TYPE "impersonation_session_status" AS ENUM ('active', 'ended', 'expired'); + +CREATE TABLE "impersonation_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "staff_id" uuid NOT NULL, + "client_id" uuid NOT NULL, + "reason" text, + "status" "impersonation_session_status" DEFAULT 'active' NOT NULL, + "started_at" timestamp DEFAULT now() NOT NULL, + "ended_at" timestamp, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "staff"("id") ON DELETE restrict, + CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE restrict +); + +CREATE TABLE "impersonation_audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "action" text NOT NULL, + "page_visited" text, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "impersonation_sessions"("id") ON DELETE cascade +); diff --git a/packages/db/migrations/0011_impersonation_indexes.sql b/packages/db/migrations/0011_impersonation_indexes.sql new file mode 100644 index 0000000..2529e84 --- /dev/null +++ b/packages/db/migrations/0011_impersonation_indexes.sql @@ -0,0 +1,6 @@ +-- Add indexes on impersonation tables to prevent full table scans +-- Ref: GitHub #95 + +CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint +CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id"); diff --git a/packages/db/migrations/0012_pet_photo.sql b/packages/db/migrations/0012_pet_photo.sql new file mode 100644 index 0000000..23bd03a --- /dev/null +++ b/packages/db/migrations/0012_pet_photo.sql @@ -0,0 +1,5 @@ +-- Add photo storage columns to pets table +-- Ref: GitHub #93 + +ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint +ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp; diff --git a/packages/db/migrations/0013_appointment_confirmation.sql b/packages/db/migrations/0013_appointment_confirmation.sql new file mode 100644 index 0000000..347ebfd --- /dev/null +++ b/packages/db/migrations/0013_appointment_confirmation.sql @@ -0,0 +1,7 @@ +ALTER TABLE appointments + ADD COLUMN confirmation_status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN confirmed_at TIMESTAMPTZ, + ADD COLUMN cancelled_at TIMESTAMPTZ, + ADD COLUMN confirmation_token TEXT UNIQUE; + +CREATE INDEX idx_appointments_confirmation_token ON appointments (confirmation_token) WHERE confirmation_token IS NOT NULL; diff --git a/packages/db/migrations/0014_customer_notes.sql b/packages/db/migrations/0014_customer_notes.sql new file mode 100644 index 0000000..9599808 --- /dev/null +++ b/packages/db/migrations/0014_customer_notes.sql @@ -0,0 +1,3 @@ +ALTER TABLE appointments ADD COLUMN customer_notes TEXT; + +CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL; diff --git a/packages/db/migrations/0015_waitlist.sql b/packages/db/migrations/0015_waitlist.sql new file mode 100644 index 0000000..d99ed8a --- /dev/null +++ b/packages/db/migrations/0015_waitlist.sql @@ -0,0 +1,20 @@ +CREATE TYPE waitlist_status AS ENUM ('active', 'notified', 'expired', 'cancelled'); + +CREATE TABLE waitlist_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE, + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + preferred_date DATE NOT NULL, + preferred_time TIME NOT NULL, + status waitlist_status NOT NULL DEFAULT 'active', + notified_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_waitlist_client_id ON waitlist_entries (client_id); +CREATE INDEX idx_waitlist_preferred_date ON waitlist_entries (preferred_date); +CREATE INDEX idx_waitlist_status ON waitlist_entries (status) WHERE status = 'active'; +CREATE UNIQUE INDEX idx_waitlist_active_unique ON waitlist_entries (client_id, pet_id, service_id, preferred_date, preferred_time) WHERE status = 'active'; diff --git a/packages/db/migrations/0016_ical_token.sql b/packages/db/migrations/0016_ical_token.sql new file mode 100644 index 0000000..2b0bf79 --- /dev/null +++ b/packages/db/migrations/0016_ical_token.sql @@ -0,0 +1 @@ +ALTER TABLE staff ADD COLUMN ical_token TEXT UNIQUE; diff --git a/packages/db/migrations/0017_better_auth_tables.sql b/packages/db/migrations/0017_better_auth_tables.sql new file mode 100644 index 0000000..b5e1f74 --- /dev/null +++ b/packages/db/migrations/0017_better_auth_tables.sql @@ -0,0 +1,49 @@ +-- Better-Auth required tables for session-based authentication +CREATE TABLE "user" ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT false, + image TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE "session" ( + id TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL UNIQUE, + ip_address TEXT, + user_agent TEXT, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE "account" ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at TIMESTAMPTZ, + refresh_token_expires_at TIMESTAMPTZ, + scope TEXT, + password TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE "verification" ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Link staff records to auth identity +ALTER TABLE staff ADD COLUMN user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL; diff --git a/packages/db/migrations/0018_backfill_staff_user_id.sql b/packages/db/migrations/0018_backfill_staff_user_id.sql new file mode 100644 index 0000000..9da9f54 --- /dev/null +++ b/packages/db/migrations/0018_backfill_staff_user_id.sql @@ -0,0 +1,14 @@ +-- Backfill staff.user_id for staff records created before Better-Auth integration. +-- Staff records that predate this migration have user_id = NULL; the resolveStaffMiddleware +-- now falls back to staff.id (dev mode) and oidcSub (production) so these records still work. +-- This migration populates user_id for the known demo/dev staff seeded by seed.ts. + +-- Create demo Better-Auth users for seeded staff (these match the ba-user-* IDs used in tests) +INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) +VALUES ('ba-user-manager', 'Demo Manager', 'demo-manager@groombook.dev', true, NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Link the demo manager staff record to the Better-Auth user +UPDATE staff +SET user_id = 'ba-user-manager', updated_at = NOW() +WHERE oidc_sub = 'demo-manager-001' AND user_id IS NULL; diff --git a/packages/db/migrations/0019_concerned_sunfire.sql b/packages/db/migrations/0019_concerned_sunfire.sql new file mode 100644 index 0000000..bc95d93 --- /dev/null +++ b/packages/db/migrations/0019_concerned_sunfire.sql @@ -0,0 +1 @@ +ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL; diff --git a/packages/db/migrations/0020_typical_daimon_hellstrom.sql b/packages/db/migrations/0020_typical_daimon_hellstrom.sql new file mode 100644 index 0000000..d44a751 --- /dev/null +++ b/packages/db/migrations/0020_typical_daimon_hellstrom.sql @@ -0,0 +1,7 @@ +-- Clean up existing duplicate services before adding unique constraint. +-- Keep the row with the lowest id per name; delete all others. +DELETE FROM services WHERE id NOT IN ( + SELECT (MIN(id::text))::uuid FROM services GROUP BY name +); + +ALTER TABLE "services" ADD CONSTRAINT "services_name_unique" UNIQUE("name"); \ No newline at end of file diff --git a/packages/db/migrations/0021_pet_image.sql b/packages/db/migrations/0021_pet_image.sql new file mode 100644 index 0000000..675b7e8 --- /dev/null +++ b/packages/db/migrations/0021_pet_image.sql @@ -0,0 +1,2 @@ +-- Add image field to pets table for demo pet image support +ALTER TABLE "pets" ADD COLUMN "image" text; diff --git a/packages/db/migrations/0022_logo_key.sql b/packages/db/migrations/0022_logo_key.sql new file mode 100644 index 0000000..7ea52cd --- /dev/null +++ b/packages/db/migrations/0022_logo_key.sql @@ -0,0 +1,2 @@ +-- Add logo_key column to business_settings for S3-based logo storage +ALTER TABLE "business_settings" ADD COLUMN "logo_key" text; \ No newline at end of file diff --git a/packages/db/migrations/0023_auth_provider_config.sql b/packages/db/migrations/0023_auth_provider_config.sql new file mode 100644 index 0000000..dd89297 --- /dev/null +++ b/packages/db/migrations/0023_auth_provider_config.sql @@ -0,0 +1,14 @@ +CREATE TABLE "auth_provider_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "provider_id" text NOT NULL, + "display_name" text NOT NULL, + "issuer_url" text NOT NULL, + "internal_base_url" text, + "client_id" text NOT NULL, + "client_secret" text NOT NULL, + "scopes" text DEFAULT 'openid profile email' NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "auth_provider_config_provider_id_unique" UNIQUE("provider_id") +); diff --git a/packages/db/migrations/0024_invoice_indexes.sql b/packages/db/migrations/0024_invoice_indexes.sql new file mode 100644 index 0000000..46ad858 --- /dev/null +++ b/packages/db/migrations/0024_invoice_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX idx_invoices_client_id ON invoices(client_id); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_created_at ON invoices(created_at); +CREATE INDEX idx_invoice_line_items_invoice_id ON invoice_line_items(invoice_id); +CREATE INDEX idx_invoice_tip_splits_invoice_id ON invoice_tip_splits(invoice_id); \ No newline at end of file diff --git a/packages/db/migrations/0025_rate_limit.sql b/packages/db/migrations/0025_rate_limit.sql new file mode 100644 index 0000000..0a83e14 --- /dev/null +++ b/packages/db/migrations/0025_rate_limit.sql @@ -0,0 +1,6 @@ +-- Better-Auth rate limiting table (GRO-574) +CREATE TABLE "rate_limit" ( + key TEXT NOT NULL PRIMARY KEY, + count INTEGER NOT NULL, + last_request BIGINT NOT NULL +); diff --git a/packages/db/migrations/0026_stripe_payment.sql b/packages/db/migrations/0026_stripe_payment.sql new file mode 100644 index 0000000..8f48557 --- /dev/null +++ b/packages/db/migrations/0026_stripe_payment.sql @@ -0,0 +1,6 @@ +ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text; +ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id"); +ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text; +ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text; +ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text; +ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id"); diff --git a/packages/db/migrations/0027_refunds.sql b/packages/db/migrations/0027_refunds.sql new file mode 100644 index 0000000..ba8d6ea --- /dev/null +++ b/packages/db/migrations/0027_refunds.sql @@ -0,0 +1,11 @@ +CREATE TABLE "refunds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT, + "stripe_refund_id" text NOT NULL, + "idempotency_key" text UNIQUE, + "amount_cents" integer, + "created_at" timestamp NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id"); +CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key"); diff --git a/packages/db/migrations/0028_sms_reminders.sql b/packages/db/migrations/0028_sms_reminders.sql new file mode 100644 index 0000000..1e7314b --- /dev/null +++ b/packages/db/migrations/0028_sms_reminders.sql @@ -0,0 +1,15 @@ +-- SMS opt-in fields for clients (idempotent) +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text; + +-- Add channel column to reminder_logs with default 'email' (idempotent) +ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email'; + +-- Drop old unique constraints if they exist (idempotent) +ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key"; +ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique"; + +-- Add new unique constraint with channel +ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel"); diff --git a/packages/db/migrations/0029_db_indexes_constraints.sql b/packages/db/migrations/0029_db_indexes_constraints.sql new file mode 100644 index 0000000..6b0607d --- /dev/null +++ b/packages/db/migrations/0029_db_indexes_constraints.sql @@ -0,0 +1,20 @@ +-- Migration: 0029_db_indexes_constraints.sql +-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email + +-- Backfill NULL emails before setting NOT NULL +UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL; + +-- Add indexes on appointments table +CREATE INDEX idx_appointments_client_id ON appointments(client_id); +CREATE INDEX idx_appointments_staff_id ON appointments(staff_id); +CREATE INDEX idx_appointments_start_time ON appointments(start_time); +CREATE INDEX idx_appointments_status ON appointments(status); + +-- Add index on pets table +CREATE INDEX idx_pets_client_id ON pets(client_id); + +-- Add index on clients table +CREATE INDEX idx_clients_email ON clients(email); + +-- Set NOT NULL on clients.email (after backfill) +ALTER TABLE clients ALTER COLUMN email SET NOT NULL; diff --git a/packages/db/migrations/0030_messaging.sql b/packages/db/migrations/0030_messaging.sql new file mode 100644 index 0000000..c404505 --- /dev/null +++ b/packages/db/migrations/0030_messaging.sql @@ -0,0 +1,72 @@ +-- Migration: 0030_messaging.sql +-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms'); +CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound'); +CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received'); +CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help'); + +-- ─── Tables ─────────────────────────────────────────────────────────────────── + +CREATE TABLE "conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "business_id" uuid NOT NULL, + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "channel" "messaging_channel" NOT NULL, + "external_number" text NOT NULL, + "business_number" text NOT NULL, + "last_message_at" timestamp, + "status" text NOT NULL DEFAULT 'active', + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC); +CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number"); + +CREATE TABLE "messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE, + "direction" "message_direction" NOT NULL, + "body" text, + "status" "message_status" NOT NULL DEFAULT 'queued', + "provider_message_id" text, + "error_code" text, + "error_message" text, + "sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + "delivered_at" timestamp, + "read_by_client_at" timestamp +); + +CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC); +CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id"); + +CREATE TABLE "message_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE, + "content_type" text NOT NULL, + "url" text NOT NULL, + "size" integer NOT NULL, + "provider_media_id" text +); + +CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id"); + +CREATE TABLE "message_consent_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "business_id" uuid NOT NULL, + "kind" "message_consent_kind" NOT NULL, + "source" text, + "created_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id"); + +-- ─── Business Settings extensions ──────────────────────────────────────────── + +ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text; +ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text; diff --git a/packages/db/migrations/0031_buffer_rules.sql b/packages/db/migrations/0031_buffer_rules.sql new file mode 100644 index 0000000..5bfd90a --- /dev/null +++ b/packages/db/migrations/0031_buffer_rules.sql @@ -0,0 +1,31 @@ +-- Migration: 0031_buffer_rules.sql +-- Buffer rules CRUD: pet size/coat enums, bufferRules table, services.defaultBufferMinutes + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge'); +CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless'); + +-- ─── Alter pets columns to use new enums ───────────────────────────────────── + +ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type"; +ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category"; + +-- ─── Services: add defaultBufferMinutes ─────────────────────────────────────── + +ALTER TABLE "services" ADD COLUMN "default_buffer_minutes" integer; + +-- ─── Buffer Rules table ─────────────────────────────────────────────────────── + +CREATE TABLE "buffer_rules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "service_id" uuid NOT NULL REFERENCES "services"("id") ON DELETE CASCADE, + "size_category" "pet_size_category", + "coat_type" "coat_type", + "buffer_minutes" integer NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_buffer_rules_service_size_coat" UNIQUE ("service_id", "size_category", "coat_type") +); + +CREATE INDEX "idx_buffer_rules_service_id" ON "buffer_rules"("service_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..c77f50a --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,485 @@ +{ + "id": "477cddf9-970f-41c5-9cad-c1ed48c2bedf", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.appointments": { + "name": "appointments", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "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": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "grooming_notes": { + "name": "grooming_notes", + "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 + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0011_snapshot.json b/packages/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..2d20d90 --- /dev/null +++ b/packages/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1468 @@ +{ + "id": "db89d732-7cd5-414e-848b-7f113dcd94c1", + "prevId": "477cddf9-970f-41c5-9cad-c1ed48c2bedf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.appointment_groups": { + "name": "appointment_groups", + "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 + }, + "notes": { + "name": "notes", + "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": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "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": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_unique": { + "name": "reminder_logs_appointment_id_reminder_type_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0019_snapshot.json b/packages/db/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..1a65df3 --- /dev/null +++ b/packages/db/migrations/meta/0019_snapshot.json @@ -0,0 +1,2048 @@ +{ + "id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "prevId": "db89d732-7cd5-414e-848b-7f113dcd94c1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "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 + }, + "notes": { + "name": "notes", + "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": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "confirmation_status": { + "name": "confirmation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_notes": { + "name": "customer_notes", + "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": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "appointments_confirmation_token_unique": { + "name": "appointments_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "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 + }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_unique": { + "name": "reminder_logs_appointment_id_reminder_type_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ical_token": { + "name": "ical_token", + "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": { + "staff_user_id_user_id_fk": { + "name": "staff_user_id_user_id_fk", + "tableFrom": "staff", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + }, + "staff_ical_token_unique": { + "name": "staff_ical_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ical_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "preferred_date": { + "name": "preferred_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_time": { + "name": "preferred_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "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": { + "idx_waitlist_client_id": { + "name": "idx_waitlist_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_preferred_date": { + "name": "idx_waitlist_preferred_date", + "columns": [ + { + "expression": "preferred_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_status": { + "name": "idx_waitlist_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { + "name": "waitlist_entries_client_id_clients_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_pet_id_pets_id_fk": { + "name": "waitlist_entries_pet_id_pets_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_service_id_services_id_fk": { + "name": "waitlist_entries_service_id_services_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "active", + "notified", + "expired", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0020_snapshot.json b/packages/db/migrations/meta/0020_snapshot.json new file mode 100644 index 0000000..1ba0b0c --- /dev/null +++ b/packages/db/migrations/meta/0020_snapshot.json @@ -0,0 +1,2056 @@ +{ + "id": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c", + "prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "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 + }, + "notes": { + "name": "notes", + "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": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "confirmation_status": { + "name": "confirmation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_notes": { + "name": "customer_notes", + "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": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "appointments_confirmation_token_unique": { + "name": "appointments_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "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 + }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_unique": { + "name": "reminder_logs_appointment_id_reminder_type_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "services_name_unique": { + "name": "services_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ical_token": { + "name": "ical_token", + "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": { + "staff_user_id_user_id_fk": { + "name": "staff_user_id_user_id_fk", + "tableFrom": "staff", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + }, + "staff_ical_token_unique": { + "name": "staff_ical_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ical_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "preferred_date": { + "name": "preferred_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_time": { + "name": "preferred_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "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": { + "idx_waitlist_client_id": { + "name": "idx_waitlist_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_preferred_date": { + "name": "idx_waitlist_preferred_date", + "columns": [ + { + "expression": "preferred_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_status": { + "name": "idx_waitlist_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { + "name": "waitlist_entries_client_id_clients_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_pet_id_pets_id_fk": { + "name": "waitlist_entries_pet_id_pets_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_service_id_services_id_fk": { + "name": "waitlist_entries_service_id_services_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "active", + "notified", + "expired", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0021_snapshot.json b/packages/db/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000..7a57e53 --- /dev/null +++ b/packages/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,504 @@ +{ + "id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "prevId": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true }, + "provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true }, + "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, + "access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false }, + "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false }, + "id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false }, + "access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false }, + "password": { "name": "password", "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": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "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 }, + "notes": { "name": "notes", "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": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 }, + "pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" }, + "start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true }, + "end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false }, + "price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false }, + "series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false }, + "group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" }, + "confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false }, + "customer_notes": { "name": "customer_notes", "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": { + "appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" }, + "logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false }, + "logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false }, + "primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" }, + "accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false }, + "phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false }, + "address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false }, + "email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false }, + "status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" }, + "disabled_at": { "name": "disabled_at", "type": "timestamp", "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false }, + "products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false }, + "groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true }, + "page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false }, + "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, + "foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false }, + "status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" }, + "started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, + "ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, + "impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true }, + "quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 }, + "unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true }, + "share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true }, + "share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 }, + "tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 }, + "total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" }, + "payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false }, + "paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "notes": { "name": "notes", "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": { + "invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true }, + "sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false }, + "base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true }, + "active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true }, + "token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true }, + "ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false }, + "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, + "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, + "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": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true }, + "oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false }, + "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false }, + "role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" }, + "is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false }, + "active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true }, + "ical_token": { "name": "ical_token", "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": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] }, + "staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] }, + "staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true }, + "email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": 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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true }, + "value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true }, + "expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "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 }, + "pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true }, + "preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true }, + "status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" }, + "notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "expires_at": { "name": "expires_at", "type": "timestamp", "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": { + "idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, + "idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, + "idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] }, + "public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] }, + "public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] }, + "public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] }, + "public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] }, + "public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] }, + "public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { "columns": {}, "schemas": {}, "tables": {} } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0022_snapshot.json b/packages/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..a803ed0 --- /dev/null +++ b/packages/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,505 @@ +{ + "id": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f", + "prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true }, + "provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true }, + "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, + "access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false }, + "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false }, + "id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false }, + "access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false }, + "password": { "name": "password", "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": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "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 }, + "notes": { "name": "notes", "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": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 }, + "pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" }, + "start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true }, + "end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false }, + "price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false }, + "series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false }, + "group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" }, + "confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false }, + "customer_notes": { "name": "customer_notes", "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": { + "appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" }, + "logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false }, + "logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false }, + "logo_key": { "name": "logo_key", "type": "text", "primaryKey": false, "notNull": false }, + "primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" }, + "accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false }, + "phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false }, + "address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false }, + "email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false }, + "status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" }, + "disabled_at": { "name": "disabled_at", "type": "timestamp", "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false }, + "products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false }, + "groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, + "grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true }, + "page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false }, + "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, + "foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false }, + "status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" }, + "started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, + "ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, + "impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true }, + "quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 }, + "unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true }, + "share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true }, + "share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false }, + "client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 }, + "tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 }, + "total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" }, + "payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false }, + "paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "notes": { "name": "notes", "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": { + "invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, + "invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true }, + "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true }, + "sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false }, + "base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true }, + "duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true }, + "active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true }, + "token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true }, + "ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false }, + "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, + "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, + "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": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true }, + "oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false }, + "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false }, + "role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" }, + "is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false }, + "active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true }, + "ical_token": { "name": "ical_token", "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": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] }, + "staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] }, + "staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true }, + "email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": 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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, + "identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true }, + "value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true }, + "expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "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 }, + "pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true }, + "preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true }, + "preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true }, + "status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" }, + "notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false }, + "expires_at": { "name": "expires_at", "type": "timestamp", "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": { + "idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, + "idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, + "idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, + "waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] }, + "public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] }, + "public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] }, + "public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] }, + "public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] }, + "public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] }, + "public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { "columns": {}, "schemas": {}, "tables": {} } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0023_snapshot.json b/packages/db/migrations/meta/0023_snapshot.json new file mode 100644 index 0000000..d3c80ca --- /dev/null +++ b/packages/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,2148 @@ +{ + "id": "b43b79e0-feca-42ed-83cc-9ec67431c3cb", + "prevId": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "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 + }, + "notes": { + "name": "notes", + "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": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "confirmation_status": { + "name": "confirmation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_notes": { + "name": "customer_notes", + "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": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "appointments_confirmation_token_unique": { + "name": "appointments_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_provider_config": { + "name": "auth_provider_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_base_url": { + "name": "internal_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_provider_config_provider_id_unique": { + "name": "auth_provider_config_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "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 + }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_unique": { + "name": "reminder_logs_appointment_id_reminder_type_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "services_name_unique": { + "name": "services_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ical_token": { + "name": "ical_token", + "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": { + "staff_user_id_user_id_fk": { + "name": "staff_user_id_user_id_fk", + "tableFrom": "staff", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + }, + "staff_ical_token_unique": { + "name": "staff_ical_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ical_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "preferred_date": { + "name": "preferred_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_time": { + "name": "preferred_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "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": { + "idx_waitlist_client_id": { + "name": "idx_waitlist_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_preferred_date": { + "name": "idx_waitlist_preferred_date", + "columns": [ + { + "expression": "preferred_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_status": { + "name": "idx_waitlist_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { + "name": "waitlist_entries_client_id_clients_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_pet_id_pets_id_fk": { + "name": "waitlist_entries_pet_id_pets_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_service_id_services_id_fk": { + "name": "waitlist_entries_service_id_services_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "active", + "notified", + "expired", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0024_snapshot.json b/packages/db/migrations/meta/0024_snapshot.json new file mode 100644 index 0000000..511c1cd --- /dev/null +++ b/packages/db/migrations/meta/0024_snapshot.json @@ -0,0 +1,2226 @@ +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "prevId": "b43b79e0-feca-42ed-83cc-9ec67431c3cb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "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 + }, + "notes": { + "name": "notes", + "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": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "confirmation_status": { + "name": "confirmation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_notes": { + "name": "customer_notes", + "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": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "appointments_confirmation_token_unique": { + "name": "appointments_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_provider_config": { + "name": "auth_provider_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_base_url": { + "name": "internal_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_provider_config_provider_id_unique": { + "name": "auth_provider_config_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invoice_line_items_invoice_id": { + "name": "idx_invoice_line_items_invoice_id", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invoice_tip_splits_invoice_id": { + "name": "idx_invoice_tip_splits_invoice_id", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "idx_invoices_client_id": { + "name": "idx_invoices_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_status": { + "name": "idx_invoices_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_created_at": { + "name": "idx_invoices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "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 + }, + "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 + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_unique": { + "name": "reminder_logs_appointment_id_reminder_type_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "services_name_unique": { + "name": "services_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ical_token": { + "name": "ical_token", + "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": { + "staff_user_id_user_id_fk": { + "name": "staff_user_id_user_id_fk", + "tableFrom": "staff", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + }, + "staff_ical_token_unique": { + "name": "staff_ical_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ical_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "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 + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "preferred_date": { + "name": "preferred_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_time": { + "name": "preferred_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "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": { + "idx_waitlist_client_id": { + "name": "idx_waitlist_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_preferred_date": { + "name": "idx_waitlist_preferred_date", + "columns": [ + { + "expression": "preferred_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_status": { + "name": "idx_waitlist_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { + "name": "waitlist_entries_client_id_clients_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_pet_id_pets_id_fk": { + "name": "waitlist_entries_pet_id_pets_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_service_id_services_id_fk": { + "name": "waitlist_entries_service_id_services_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "active", + "notified", + "expired", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0026_snapshot.json b/packages/db/migrations/meta/0026_snapshot.json new file mode 100644 index 0000000..6e0ad37 --- /dev/null +++ b/packages/db/migrations/meta/0026_snapshot.json @@ -0,0 +1,103 @@ +{ + "id": "0026_stripe_payment", + "version": "7", + "dialect": "postgresql", + "tables": { + "authProviderConfig": { + "name": "auth_provider_config", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "providerId": { "name": "provider_id", "type": "text", "isNullable": false }, + "displayName": { "name": "display_name", "type": "text", "isNullable": false }, + "issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false }, + "internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true }, + "clientId": { "name": "client_id", "type": "text", "isNullable": false }, + "clientSecret": { "name": "client_secret", "type": "text", "isNullable": false }, + "scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" }, + "enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {} + }, + "businessSettings": { + "name": "business_settings", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" }, + "logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true }, + "logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true }, + "logoKey": { "name": "logo_key", "type": "text", "isNullable": true }, + "primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" }, + "accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {} + }, + "clients": { + "name": "clients", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "name": { "name": "name", "type": "text", "isNullable": false }, + "email": { "name": "email", "type": "text", "isNullable": true }, + "phone": { "name": "phone", "type": "text", "isNullable": true }, + "address": { "name": "address", "type": "text", "isNullable": true }, + "notes": { "name": "notes", "type": "text", "isNullable": true }, + "emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" }, + "smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" }, + "smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true }, + "smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true }, + "smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true }, + "stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true }, + "status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" }, + "disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } } + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true }, + "clientId": { "name": "client_id", "type": "uuid", "isNullable": false }, + "subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false }, + "taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" }, + "tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" }, + "totalCents": { "name": "total_cents", "type": "integer", "isNullable": false }, + "status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" }, + "paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true }, + "paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true }, + "stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true }, + "stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true }, + "paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true }, + "notes": { "name": "notes", "type": "text", "isNullable": true }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } }, + "foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } } + } + }, + "enums": { + "appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] }, + "client_status": { "name": "client_status", "values": ["active", "disabled"] }, + "impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] }, + "invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] }, + "payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] }, + "staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] }, + "waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] } + }, + "nativeEnums": {} +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..eef2244 --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,223 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1773771452946, + "tag": "0000_colossal_colossus", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1742241600000, + "tag": "0001_pet_health_alerts", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1773777600000, + "tag": "0002_invoices", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1742169600000, + "tag": "0003_recurring_series", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1773779939000, + "tag": "0004_reminder_logs", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1773783000000, + "tag": "0005_appointment_groups", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1773783600000, + "tag": "0006_pet_profile_attributes", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1773820800000, + "tag": "0007_tip_splitting", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1773907200000, + "tag": "0008_business_settings", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1773993600000, + "tag": "0009_client_soft_delete", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1742500800000, + "tag": "0010_impersonation_sessions", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1742587200000, + "tag": "0011_impersonation_indexes", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1774080000000, + "tag": "0012_pet_photo", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1774166400000, + "tag": "0013_appointment_confirmation", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1774252800000, + "tag": "0014_customer_notes", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1774339200000, + "tag": "0015_waitlist", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1774425600000, + "tag": "0016_ical_token", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1774512000000, + "tag": "0017_better_auth_tables", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1774598400000, + "tag": "0018_backfill_staff_user_id", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1774729055924, + "tag": "0019_concerned_sunfire", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1775050467192, + "tag": "0020_typical_daimon_hellstrom", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1775136867192, + "tag": "0021_pet_image", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1775223267192, + "tag": "0022_logo_key", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1775309667192, + "tag": "0023_auth_provider_config", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1775396067192, + "tag": "0024_invoice_indexes", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1775482467192, + "tag": "0025_rate_limit", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1775568867192, + "tag": "0026_stripe_payment", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1775655267192, + "tag": "0027_refunds", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1775741667192, + "tag": "0028_sms_reminders", + "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1775784467192, + "tag": "0029_db_indexes_constraints", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1775828067192, + "tag": "0030_messaging", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..4cdd0d9 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,39 @@ +{ + "name": "@groombook/db", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./src/index.ts", + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./src/index.ts" + }, + "./factories": { + "default": "./src/factories.ts", + "types": "./src/factories.ts" + } + }, + "scripts": { + "build": "tsc --project .", + "generate": "drizzle-kit generate", + "migrate": "drizzle-kit migrate", + "seed": "tsx src/seed.ts", + "reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", + "studio": "drizzle-kit studio", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "better-auth": "^1.5.6", + "drizzle-orm": "^0.38.4", + "postgres": "^3.4.5" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "drizzle-kit": "^0.30.4", + "tsx": "^4.19.0", + "typescript": "^5.7.3" + }, + "license": "AGPL-3.0-only" +} diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts new file mode 100644 index 0000000..541d5a3 --- /dev/null +++ b/packages/db/src/crypto.ts @@ -0,0 +1,94 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // 96-bit IV for GCM +const AUTH_TAG_LENGTH = 16; // 128-bit auth tag +const SALT_LENGTH = 16; + +/** + * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt. + * A unique random salt is generated per encryptSecret() call and prepended to the output. + */ +function deriveKey(secret: string, salt: Buffer): Buffer { + return scryptSync(secret, salt, 32); +} + +/** + * Encrypts a plaintext string using AES-256-GCM. + * Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag + */ +export function encryptSecret(plaintext: string): string { + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) { + throw new Error("BETTER_AUTH_SECRET environment variable is required"); + } + + const salt = randomBytes(SALT_LENGTH); + const key = deriveKey(secret, salt); + const iv = randomBytes(IV_LENGTH); + + const cipher = createCipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + + let ciphertext = cipher.update(plaintext, "utf8"); + ciphertext = Buffer.concat([ciphertext, cipher.final()]); + + const authTag = cipher.getAuthTag(); + + // Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag) + return [ + salt.toString("base64"), + iv.toString("base64"), + ciphertext.toString("base64"), + authTag.toString("base64"), + ].join(":"); +} + +/** + * Decrypts a ciphertext string produced by encryptSecret. + * Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag). + */ +export function decryptSecret(encrypted: string): string { + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) { + throw new Error("BETTER_AUTH_SECRET environment variable is required"); + } + + const parts = encrypted.split(":"); + + let salt: Buffer; + let iv: Buffer; + let ciphertext: Buffer; + let authTag: Buffer; + + if (parts.length === 4) { + // New format: salt:iv:ciphertext:authTag + salt = Buffer.from(parts[0]!, "base64"); + iv = Buffer.from(parts[1]!, "base64"); + ciphertext = Buffer.from(parts[2]!, "base64"); + authTag = Buffer.from(parts[3]!, "base64"); + } else if (parts.length === 3) { + // Legacy format: iv:ciphertext:authTag — use fixed package salt + salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); + iv = Buffer.from(parts[0]!, "base64"); + ciphertext = Buffer.from(parts[1]!, "base64"); + authTag = Buffer.from(parts[2]!, "base64"); + } else { + throw new Error( + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" + ); + } + + const key = deriveKey(secret, salt); + + const decipher = createDecipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + decipher.setAuthTag(authTag); + + let plaintext = decipher.update(ciphertext); + plaintext = Buffer.concat([plaintext, decipher.final()]); + + return plaintext.toString("utf8"); +} diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts new file mode 100644 index 0000000..cac71f7 --- /dev/null +++ b/packages/db/src/factories.ts @@ -0,0 +1,162 @@ +/** + * Test factories — build typed in-memory entities for unit tests. + * + * Each factory returns a fully-populated object with valid defaults. + * Pass an overrides object to customise specific fields. + * + * IDs are generated with a deterministic counter so tests produce stable, + * readable values (e.g. "staff-1", "client-2") without needing crypto. + * + * Usage: + * import { buildStaff, buildClient, buildPet } from "@groombook/db/factories"; + * + * const manager = buildStaff({ role: "manager" }); + * const client = buildClient({ name: "Alice Smith" }); + * const pet = buildPet({ clientId: client.id }); + */ + +import type { staff, clients, pets, services, appointments } from "./schema.js"; + +// ── Counter-based ID factory ───────────────────────────────────────────────── + +const counters: Record = {}; + +function nextId(prefix: string): string { + counters[prefix] = (counters[prefix] ?? 0) + 1; + return `${prefix}-${counters[prefix]}`; +} + +/** Reset all counters. Call in beforeEach() to keep tests independent. */ +export function resetFactoryCounters(): void { + for (const key of Object.keys(counters)) { + delete counters[key]; + } +} + +// ── Type aliases ───────────────────────────────────────────────────────────── + +export type StaffRow = typeof staff.$inferSelect; +export type ClientRow = typeof clients.$inferSelect; +export type PetRow = typeof pets.$inferSelect; +export type ServiceRow = typeof services.$inferSelect; +export type AppointmentRow = typeof appointments.$inferSelect; + +// ── Factories ──────────────────────────────────────────────────────────────── + +export function buildStaff(overrides: Partial = {}): StaffRow { + const id = nextId("staff"); + return { + id, + name: `Staff Member ${id}`, + email: `${id}@groombook.test`, + oidcSub: `oidc-${id}`, + userId: null, + role: "groomer", + isSuperUser: false, + active: true, + icalToken: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }; +} + +export function buildClient(overrides: Partial = {}): ClientRow { + const id = nextId("client"); + return { + id, + name: `Client ${id}`, + email: `${id}@example.com`, + phone: "555-0100", + address: "1 Main St, Springfield, CA 90000", + notes: null, + emailOptOut: false, + smsOptIn: false, + smsConsentDate: null, + smsOptOutDate: null, + smsConsentText: null, + stripeCustomerId: null, + status: "active", + disabledAt: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }; +} + +export function buildPet(overrides: Partial & { clientId: string }): PetRow { + const id = nextId("pet"); + const defaults: PetRow = { + id, + clientId: overrides.clientId, + name: `Pet ${id}`, + species: "Dog", + breed: "Mixed Breed", + weightKg: "15.00", + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + coatType: null, + petSizeCategory: null, + customFields: {}, + photoKey: null, + photoUploadedAt: null, + image: null, + coatType: null, + petSizeCategory: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + }; + return { ...defaults, ...overrides }; +} + +export function buildService(overrides: Partial = {}): ServiceRow { + const id = nextId("service"); + return { + id, + name: `Service ${id}`, + description: "A grooming service", + basePriceCents: 6500, + durationMinutes: 60, + defaultBufferMinutes: null, + active: true, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }; +} + +export function buildAppointment( + overrides: Partial & { clientId: string; petId: string; serviceId: string; staffId: string } +): AppointmentRow { + const id = nextId("appointment"); + const startTime = new Date("2025-06-01T10:00:00Z"); + const endTime = new Date("2025-06-01T11:00:00Z"); + const defaults: AppointmentRow = { + id, + clientId: overrides.clientId, + petId: overrides.petId, + serviceId: overrides.serviceId, + staffId: overrides.staffId, + batherStaffId: null, + seriesId: null, + seriesIndex: null, + groupId: null, + status: "scheduled", + startTime, + endTime, + notes: null, + priceCents: null, + confirmationStatus: "pending", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + }; + return { ...defaults, ...overrides }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..8b3b01f --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,20 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema.js"; + +export * from "./schema.js"; +export { encryptSecret, decryptSecret } from "./crypto.js"; +export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; + +let _db: ReturnType | null = null; + +export function getDb() { + if (_db) return _db; + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set"); + const client = postgres(url, { max: 10 }); + _db = drizzle(client, { schema }); + return _db; +} + +export type Db = ReturnType; diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts new file mode 100644 index 0000000..41c3ce8 --- /dev/null +++ b/packages/db/src/reset.ts @@ -0,0 +1,70 @@ +/** + * reset.ts — Drop all application tables and re-run migrations + seed. + * + * Intended for local development only. Never run against production. + * + * Usage: + * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts + */ + +import postgres from "postgres"; + +async function reset() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") { + console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true."); + process.exit(1); + } + + const client = postgres(url, { max: 1 }); + + console.log("Dropping all application tables...\n"); + + // Drop in dependency order (children before parents) + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ) LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + `; + + // Drop custom enums + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT typname FROM pg_type + WHERE typtype = 'e' AND typnamespace = ( + SELECT oid FROM pg_namespace WHERE nspname = 'public' + ) + ) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; + `; + + // Drop the drizzle migrations tracking table + await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; + await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; + + console.log("✓ All tables and enums dropped\n"); + + await client.end(); +} + +reset().catch((err) => { + console.error("Reset failed:", err); + process.exit(1); +}); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..bb3c8f6 --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,694 @@ +import { + boolean, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; + +// ─── Enums ──────────────────────────────────────────────────────────────────── + +export const appointmentStatusEnum = pgEnum("appointment_status", [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show", +]); + +export const staffRoleEnum = pgEnum("staff_role", [ + "groomer", + "receptionist", + "manager", +]); + +export const invoiceStatusEnum = pgEnum("invoice_status", [ + "draft", + "pending", + "paid", + "void", +]); + +export const paymentMethodEnum = pgEnum("payment_method", [ + "cash", + "card", + "check", + "other", +]); + +export const clientStatusEnum = pgEnum("client_status", [ + "active", + "disabled", +]); + +export const petSizeCategoryEnum = pgEnum("pet_size_category", [ + "small", + "medium", + "large", + "xlarge", +]); + +export const coatTypeEnum = pgEnum("coat_type", [ + "smooth", + "double", + "wire", + "curly", + "long", + "hairless", +]); + +// ─── Better-Auth Tables ────────────────────────────────────────────────────── + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").notNull().default(false), + image: text("image"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +// ─── Pet enums ───────────────────────────────────────────────────────────────── + +export const petSizeCategoryEnum = pgEnum("pet_size_category", [ + "small", + "medium", + "large", + "extra_large", +]); + +export const coatTypeEnum = pgEnum("coat_type", [ + "short", + "medium", + "long", + "double", + "wire", + "silky", + "curly", + "hairless", +]); + +// ─── Tables ─────────────────────────────────────────────────────────────────── + +export const clients = pgTable( + "clients", + { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + email: text("email").notNull(), + phone: text("phone"), + address: text("address"), + notes: text("notes"), + emailOptOut: boolean("email_opt_out").notNull().default(false), + smsOptIn: boolean("sms_opt_in").notNull().default(false), + smsConsentDate: timestamp("sms_consent_date"), + smsOptOutDate: timestamp("sms_opt_out_date"), + smsConsentText: text("sms_consent_text"), + stripeCustomerId: text("stripe_customer_id"), + status: clientStatusEnum("status").notNull().default("active"), + disabledAt: timestamp("disabled_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [index("idx_clients_email").on(t.email)] +); + +export const pets = pgTable( + "pets", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + name: text("name").notNull(), + species: text("species").notNull(), + breed: text("breed"), + weightKg: numeric("weight_kg", { precision: 5, scale: 2 }), + dateOfBirth: timestamp("date_of_birth"), + healthAlerts: text("health_alerts"), + groomingNotes: text("grooming_notes"), + cutStyle: text("cut_style"), + shampooPreference: text("shampoo_preference"), + specialCareNotes: text("special_care_notes"), + coatType: coatTypeEnum("coat_type"), + petSizeCategory: petSizeCategoryEnum("pet_size_category"), + customFields: jsonb("custom_fields").$type>().notNull().default({}), + photoKey: text("photo_key"), + photoUploadedAt: timestamp("photo_uploaded_at"), + image: text("image"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [index("idx_pets_client_id").on(t.clientId)] +); + +export const services = pgTable("services", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull().unique(), + description: text("description"), + basePriceCents: integer("base_price_cents").notNull(), + durationMinutes: integer("duration_minutes").notNull(), + defaultBufferMinutes: integer("default_buffer_minutes"), + active: boolean("active").notNull().default(true), + defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const bufferRules = pgTable( + "buffer_rules", + { + id: uuid("id").primaryKey().defaultRandom(), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + sizeCategory: petSizeCategoryEnum("size_category"), + coatType: coatTypeEnum("coat_type"), + bufferMinutes: integer("buffer_minutes").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + unique("uq_buffer_rules_service_size_coat").on( + t.serviceId, + t.sizeCategory, + t.coatType + ), + ] +); + +export const staff = pgTable("staff", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + // oidcSub links to the Authentik OIDC subject claim + oidcSub: text("oidc_sub").unique(), + // Better-Auth user ID — links staff business record to auth identity + userId: text("user_id").references(() => user.id, { onDelete: "set null" }), + role: staffRoleEnum("role").notNull().default("groomer"), + // Super users bypass appointment-booking restrictions and access admin panels + isSuperUser: boolean("is_super_user").notNull().default(false), + active: boolean("active").notNull().default(true), + // Token for iCal calendar feed subscription (no auth required) + icalToken: text("ical_token").unique(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const recurringSeries = pgTable("recurring_series", { + id: uuid("id").primaryKey().defaultRandom(), + // How many weeks between each appointment in the series + frequencyWeeks: integer("frequency_weeks").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +// appointmentGroups links multiple appointments from the same client visit. +// Each pet in the group gets its own appointment row with its own groomer. +export const appointmentGroups = pgTable("appointment_groups", { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + notes: text("notes"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const appointments = pgTable( + "appointments", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "restrict" }), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "restrict" }), + staffId: uuid("staff_id").references(() => staff.id, { + onDelete: "set null", + }), + // Optional secondary staff (bather/assistant) for tip-split tracking + batherStaffId: uuid("bather_staff_id").references(() => staff.id, { + onDelete: "set null", + }), + status: appointmentStatusEnum("status").notNull().default("scheduled"), + startTime: timestamp("start_time").notNull(), + endTime: timestamp("end_time").notNull(), + notes: text("notes"), + // Override price at time of booking (null = use service base price) + priceCents: integer("price_cents"), + // Recurring series support + seriesId: uuid("series_id").references(() => recurringSeries.id, { + onDelete: "set null", + }), + seriesIndex: integer("series_index"), + // Multi-pet group booking: links this appointment to others in the same visit + groupId: uuid("group_id").references(() => appointmentGroups.id, { + onDelete: "set null", + }), + // Customer confirmation/cancellation tracking + // Values: "pending" | "confirmed" | "cancelled" + confirmationStatus: text("confirmation_status").notNull().default("pending"), + confirmedAt: timestamp("confirmed_at"), + cancelledAt: timestamp("cancelled_at"), + // Token for tokenized email confirm/cancel links (no auth required) + confirmationToken: text("confirmation_token").unique(), + // Customer-provided note visible to groomer (500 char max, editable until appointment starts) + customerNotes: text("customer_notes"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_appointments_client_id").on(t.clientId), + index("idx_appointments_staff_id").on(t.staffId), + index("idx_appointments_start_time").on(t.startTime), + index("idx_appointments_status").on(t.status), + ] +); + +export const invoices = pgTable( + "invoices", + { + id: uuid("id").primaryKey().defaultRandom(), + appointmentId: uuid("appointment_id").references(() => appointments.id, { + onDelete: "restrict", + }), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + subtotalCents: integer("subtotal_cents").notNull(), + taxCents: integer("tax_cents").notNull().default(0), + tipCents: integer("tip_cents").notNull().default(0), + totalCents: integer("total_cents").notNull(), + status: invoiceStatusEnum("status").notNull().default("draft"), + paymentMethod: paymentMethodEnum("payment_method"), + paidAt: timestamp("paid_at"), + stripePaymentIntentId: text("stripe_payment_intent_id"), + stripeRefundId: text("stripe_refund_id"), + paymentFailureReason: text("payment_failure_reason"), + notes: text("notes"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_invoices_client_id").on(t.clientId), + index("idx_invoices_status").on(t.status), + index("idx_invoices_created_at").on(t.createdAt), + index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId), + ] +); + +export const invoiceLineItems = pgTable( + "invoice_line_items", + { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + description: text("description").notNull(), + quantity: integer("quantity").notNull().default(1), + unitPriceCents: integer("unit_price_cents").notNull(), + totalCents: integer("total_cents").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)] +); + +// Per-staff tip allocation calculated when an invoice is paid. +// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted. +export const invoiceTipSplits = pgTable( + "invoice_tip_splits", + { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }), + staffName: text("staff_name").notNull(), + sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(), + shareCents: integer("share_cents").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)] +); + +// Refund records with idempotency key support +export const refunds = pgTable( + "refunds", + { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "restrict" }), + stripeRefundId: text("stripe_refund_id").notNull(), + idempotencyKey: text("idempotency_key").unique(), + amountCents: integer("amount_cents"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_refunds_invoice_id").on(t.invoiceId), + index("idx_refunds_idempotency_key").on(t.idempotencyKey), + ] +); + +// Tracks which reminder emails have been sent per appointment (prevents duplicates). +// reminder_type values: "confirmation", "24h", "2h" +// channel values: "email", "sms" +export const reminderLogs = pgTable( + "reminder_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + appointmentId: uuid("appointment_id") + .notNull() + .references(() => appointments.id, { onDelete: "cascade" }), + // "confirmation" | "24h" | "2h" + reminderType: text("reminder_type").notNull(), + // "email" | "sms" + channel: text("channel").notNull().default("email"), + sentAt: timestamp("sent_at").notNull().defaultNow(), + }, + (t) => [unique().on(t.appointmentId, t.reminderType, t.channel)] +); + +// ─── Impersonation ────────────────────────────────────────────────────────── + +export const impersonationSessionStatusEnum = pgEnum( + "impersonation_session_status", + ["active", "ended", "expired"] +); + +export const impersonationSessions = pgTable( + "impersonation_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + staffId: uuid("staff_id") + .notNull() + .references(() => staff.id, { onDelete: "restrict" }), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + reason: text("reason"), + status: impersonationSessionStatusEnum("status") + .notNull() + .default("active"), + startedAt: timestamp("started_at").notNull().defaultNow(), + endedAt: timestamp("ended_at"), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [ + index("impersonation_sessions_staff_id_status_idx").on(t.staffId, t.status), + index("impersonation_sessions_client_id_idx").on(t.clientId), + ] +); + +export const impersonationAuditLogs = pgTable( + "impersonation_audit_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + sessionId: uuid("session_id") + .notNull() + .references(() => impersonationSessions.id, { onDelete: "cascade" }), + action: text("action").notNull(), + pageVisited: text("page_visited"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)] +); + +// ─── Messaging ─────────────────────────────────────────────────────────────── + +export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]); + +export const messageDirectionEnum = pgEnum("message_direction", [ + "inbound", + "outbound", +]); + +export const messageStatusEnum = pgEnum("message_status", [ + "queued", + "sent", + "delivered", + "failed", + "received", +]); + +export const messageConsentKindEnum = pgEnum("message_consent_kind", [ + "opt_in", + "opt_out", + "help", +]); + +export const conversations = pgTable( + "conversations", + { + id: uuid("id").primaryKey().defaultRandom(), + businessId: uuid("business_id").notNull(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + channel: messagingChannelEnum("channel").notNull(), + externalNumber: text("external_number").notNull(), + businessNumber: text("business_number").notNull(), + lastMessageAt: timestamp("last_message_at"), + status: text("status").notNull().default("active"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_conversations_business_id_last_message_at").on( + t.businessId, + t.lastMessageAt.desc() + ), + unique("uq_conversations_business_client_number").on( + t.businessId, + t.clientId, + t.businessNumber + ), + ] +); + +export const messages = pgTable( + "messages", + { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + direction: messageDirectionEnum("direction").notNull(), + body: text("body"), + status: messageStatusEnum("status").notNull().default("queued"), + providerMessageId: text("provider_message_id"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").notNull().defaultNow(), + deliveredAt: timestamp("delivered_at"), + readByClientAt: timestamp("read_by_client_at"), + }, + (t) => [ + index("idx_messages_conversation_id_created_at").on( + t.conversationId, + t.createdAt.desc() + ), + unique("uq_messages_provider_message_id").on(t.providerMessageId), + ] +); + +export const messageAttachments = pgTable( + "message_attachments", + { + id: uuid("id").primaryKey().defaultRandom(), + messageId: uuid("message_id") + .notNull() + .references(() => messages.id, { onDelete: "cascade" }), + contentType: text("content_type").notNull(), + url: text("url").notNull(), + size: integer("size").notNull(), + providerMediaId: text("provider_media_id"), + }, + (t) => [index("idx_message_attachments_message_id").on(t.messageId)] +); + +export const messageConsentEvents = pgTable( + "message_consent_events", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + businessId: uuid("business_id").notNull(), + kind: messageConsentKindEnum("kind").notNull(), + source: text("source"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("idx_message_consent_events_client_id").on(t.clientId)] +); + +export const businessSettings = pgTable("business_settings", { + id: uuid("id").primaryKey().defaultRandom(), + businessName: text("business_name").notNull().default("GroomBook"), + logoBase64: text("logo_base64"), + logoMimeType: text("logo_mime_type"), + logoKey: text("logo_key"), + primaryColor: text("primary_color").notNull().default("#4f8a6f"), + accentColor: text("accent_color").notNull().default("#8b7355"), + messagingPhoneNumber: text("messaging_phone_number"), + telnyxMessagingProfileId: text("telnyx_messaging_profile_id"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const groomingVisitLogs = pgTable("grooming_visit_logs", { + id: uuid("id").primaryKey().defaultRandom(), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "cascade" }), + appointmentId: uuid("appointment_id").references(() => appointments.id, { + onDelete: "set null", + }), + staffId: uuid("staff_id").references(() => staff.id, { + onDelete: "set null", + }), + cutStyle: text("cut_style"), + productsUsed: text("products_used"), + notes: text("notes"), + groomedAt: timestamp("groomed_at").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export const waitlistStatusEnum = pgEnum("waitlist_status", [ + "active", + "notified", + "expired", + "cancelled", +]); + +export const waitlistEntries = pgTable( + "waitlist_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "cascade" }), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + preferredDate: text("preferred_date").notNull(), + preferredTime: text("preferred_time").notNull(), + status: waitlistStatusEnum("status").notNull().default("active"), + notifiedAt: timestamp("notified_at"), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_waitlist_client_id").on(t.clientId), + index("idx_waitlist_preferred_date").on(t.preferredDate), + index("idx_waitlist_status").on(t.status), + ] +); + +// ─── Auth Provider Config ────────────────────────────────────────────────── + +export const authProviderConfig = pgTable("auth_provider_config", { + id: uuid("id").primaryKey().defaultRandom(), + providerId: text("provider_id").notNull().unique(), // e.g. "authentik", "okta", "entra-id" + displayName: text("display_name").notNull(), // shown on login button + issuerUrl: text("issuer_url").notNull(), // OIDC issuer/discovery URL + internalBaseUrl: text("internal_base_url"), // for hairpin NAT / K8s internal routing + clientId: text("client_id").notNull(), + clientSecret: text("client_secret").notNull(), // AES-256-GCM encrypted using BETTER_AUTH_SECRET + scopes: text("scopes").notNull().default("openid profile email"), + enabled: boolean("enabled").notNull().default(true), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +// ─── Buffer Rules ───────────────────────────────────────────────────────────── + +// Buffer time rules per service + pet size/coat combination. +// Covers service-level defaults and pet-specific overrides. +export const bufferRules = pgTable( + "buffer_rules", + { + id: uuid("id").primaryKey().defaultRandom(), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + // null sizeCategory means "any size" (wildcard) + sizeCategory: petSizeCategoryEnum("size_category"), + // null coatType means "any coat type" (wildcard) + coatType: coatTypeEnum("coat_type"), + // minutes to add to the service duration for this size/coat combo + bufferMinutes: integer("buffer_minutes").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // One rule per unique (service, size, coat) combination + unique("uq_buffer_rules_service_size_coat").on( + t.serviceId, + t.sizeCategory, + t.coatType + ), + index("idx_buffer_rules_service_id").on(t.serviceId), + ] +); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts new file mode 100644 index 0000000..79164fa --- /dev/null +++ b/packages/db/src/seed.ts @@ -0,0 +1,1233 @@ +/** + * Seed script — generates deterministic, PII-free test data for Groom Book. + * + * Creates: + * - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total) + * - 10 services + * - 500 clients, each with 1-3 dogs + * - ~2 500 appointments spread across the past 12 months + * - Invoices for completed appointments with line items and tip splits + * - Grooming visit logs for completed appointments + * + * Output is fully deterministic: the same seed value always produces the + * same rows with the same IDs. + * + * Usage: + * DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts + */ + +import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { eq, and, sql } from "drizzle-orm"; +import * as schema from "./schema.js"; + +// ── Seed profile configuration ───────────────────────────────────────────── + +type SeedProfile = "dev" | "uat" | "demo"; + +interface ProfileConfig { + staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; + clientCount: number; + appointmentsBackDays: number; + appointmentsForwardDays: number; + invoiceCount: number; + includeUatClients: boolean; +} + +const profiles: Record = { + dev: { + staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, + clientCount: 100, + appointmentsBackDays: 7, + appointmentsForwardDays: 30, + invoiceCount: 1000, + includeUatClients: false, + }, + uat: { + staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, + clientCount: 500, + appointmentsBackDays: 30, + appointmentsForwardDays: 90, + invoiceCount: 4000, + includeUatClients: true, + }, + demo: { + staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, + clientCount: 500, + appointmentsBackDays: 30, + appointmentsForwardDays: 90, + invoiceCount: 4000, + includeUatClients: true, + }, +}; + +function getProfile(): SeedProfile { + const raw = process.env.SEED_PROFILE?.toLowerCase(); + if (raw === "dev" || raw === "uat" || raw === "demo") { + return raw; + } + return "uat"; +} + +// ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── + +/** + * Returns a seeded pseudo-random number generator. + * Same seed → identical sequence of numbers every run. + */ +function createPrng(seed: number): () => number { + let s = seed | 0; + return function (): number { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const rand = createPrng(42); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Return a random element from an array using the seeded PRNG. */ +function pick(arr: T[]): T { + return arr[Math.floor(rand() * arr.length)]!; +} + +/** Return n distinct random elements from an array. */ +function pickN(arr: T[], n: number): T[] { + const shuffled = [...arr].sort(() => rand() - 0.5); + return shuffled.slice(0, n); +} + +function randInt(min: number, max: number): number { + return Math.floor(rand() * (max - min + 1)) + min; +} + +function randDate(start: Date, end: Date): Date { + return new Date(start.getTime() + rand() * (end.getTime() - start.getTime())); +} + +/** + * Generate a deterministic UUID v4 from the seeded PRNG. + * Conforms to RFC 4122 §4.4 (variant bits set correctly). + */ +function uuid(): string { + const hex = (n: number) => n.toString(16).padStart(2, "0"); + const bytes = Array.from({ length: 16 }, () => Math.floor(rand() * 256)); + bytes[6] = ((bytes[6]! & 0x0f) | 0x40); // version 4 + bytes[8] = ((bytes[8]! & 0x3f) | 0x80); // variant bits + return [ + bytes.slice(0, 4).map(hex).join(""), + bytes.slice(4, 6).map(hex).join(""), + bytes.slice(6, 8).map(hex).join(""), + bytes.slice(8, 10).map(hex).join(""), + bytes.slice(10, 16).map(hex).join(""), + ].join("-"); +} + +// ── Data pools ─────────────────────────────────────────────────────────────── + +const firstNames = [ + "Emma", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Sophia", "Mason", + "Isabella", "Lucas", "Mia", "Logan", "Charlotte", "Aiden", "Amelia", + "James", "Harper", "Benjamin", "Evelyn", "Elijah", "Abigail", "William", + "Emily", "Sebastian", "Elizabeth", "Henry", "Sofia", "Alexander", "Avery", + "Daniel", "Scarlett", "Michael", "Grace", "Jackson", "Chloe", "Owen", + "Victoria", "Jack", "Riley", "Caleb", "Aria", "Luke", "Luna", "Ryan", + "Zoey", "Nathan", "Penelope", "Carter", "Layla", "Dylan", "Nora", + "Andrew", "Lily", "Gabriel", "Eleanor", "Samuel", "Hannah", "David", + "Lillian", "Matthew", "Addison", "Joseph", "Aubrey", "Isaac", "Stella", + "Joshua", "Natalie", "Wyatt", "Zoe", "John", "Leah", "Leo", "Hazel", + "Julian", "Violet", "Christopher", "Aurora", "Jonathan", "Savannah", + "Lincoln", "Audrey", "Thomas", "Brooklyn", "Asher", "Bella", "Theodore", + "Claire", "Jaxon", "Skylar", "Robert", "Lucy", "Charles", "Paisley", + "Adrian", "Anna", "Miles", "Caroline", "Dominic", "Genesis", "Connor", +]; + +const lastNames = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", + "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", + "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", + "Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", + "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", "Green", + "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", + "Carter", "Roberts", "Gomez", "Phillips", "Evans", "Turner", "Diaz", + "Parker", "Cruz", "Edwards", "Collins", "Reyes", "Stewart", "Morris", + "Morales", "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", + "Cooper", "Peterson", "Bailey", "Reed", "Kelly", "Howard", "Ramos", + "Kim", "Cox", "Ward", "Richardson", "Watson", "Brooks", "Chavez", + "Wood", "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes", + "Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", "Long", + "Ross", "Foster", "Jimenez", +]; + +const dogNames = [ + "Buddy", "Max", "Charlie", "Cooper", "Rocky", "Bear", "Duke", "Tucker", + "Jack", "Oliver", "Milo", "Bentley", "Zeus", "Winston", "Beau", "Finn", + "Leo", "Teddy", "Louie", "Toby", "Harley", "Bailey", "Murphy", "Rex", + "Bruno", "Gus", "Diesel", "Moose", "Henry", "Archie", "Luna", "Bella", + "Daisy", "Lucy", "Sadie", "Molly", "Maggie", "Chloe", "Sophie", "Stella", + "Penny", "Zoey", "Ruby", "Rosie", "Lola", "Willow", "Nala", "Ginger", + "Coco", "Roxy", "Ellie", "Piper", "Gracie", "Millie", "Lady", "Pepper", + "Hazel", "Dixie", "Winnie", "Bonnie", "Maple", "Ivy", "Pearl", "Olive", +]; + +const dogBreeds = [ + "Golden Retriever", "Labrador Retriever", "Poodle", "German Shepherd", + "Bulldog", "Beagle", "Rottweiler", "Dachshund", "Yorkshire Terrier", + "Boxer", "Siberian Husky", "Cavalier King Charles Spaniel", + "Doberman Pinscher", "Great Dane", "Miniature Schnauzer", + "Shih Tzu", "Boston Terrier", "Bernese Mountain Dog", "Pomeranian", + "Havanese", "Cocker Spaniel", "Border Collie", "Shetland Sheepdog", + "Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise", + "West Highland White Terrier", "Vizsla", "Chihuahua", "Collie", + "Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd", + "Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle", + "Mixed Breed", "Mixed Breed", "Mixed Breed", +]; + +const cutStyles = [ + "Puppy Cut", "Teddy Bear Cut", "Lion Cut", "Breed Standard", + "Summer Shave", "Kennel Cut", "Lamb Cut", "Continental Clip", + "Sporting Clip", "Sanitary Trim", "Face & Feet Trim", "Full Groom", + null, +]; + +const shampoos = [ + "Oatmeal Sensitive", "Whitening Formula", "Flea & Tick", "Hypoallergenic", + "De-shedding", "Puppy Gentle", "Medicated", "Coconut Oil", + "Lavender Calm", null, +]; + +const healthAlerts = [ + null, null, null, null, null, // Most pets have none + "Sensitive skin — avoid harsh shampoos", + "Ear infection prone — dry ears thoroughly", + "Hip dysplasia — handle with care", + "Anxious — needs slow approach", + "Seizure history — avoid stress triggers", + "Skin allergies — use hypoallergenic products only", + "Aggressive when nails trimmed — muzzle required", + "Heart murmur — monitor during grooming", + "Diabetic — owner brings treats", +]; + +const streetNames = [ + "Main St", "Oak Ave", "Maple Dr", "Cedar Ln", "Elm St", "Pine Rd", + "Birch Way", "Walnut Ct", "Cherry Blvd", "Willow Pl", "Spruce Ter", + "Chestnut Cir", "Hickory Ln", "Magnolia Ave", "Sycamore Dr", + "Dogwood Rd", "Aspen Way", "Redwood Ct", "Juniper Blvd", "Poplar St", +]; + +const cities = [ + "Springfield", "Riverside", "Fairview", "Madison", "Georgetown", + "Clinton", "Salem", "Greenville", "Franklin", "Bristol", + "Manchester", "Oakland", "Burlington", "Arlington", "Ashland", +]; + +const states = ["CA", "TX", "NY", "FL", "IL", "PA", "OH", "GA", "NC", "MI"]; + +const groomingNotes = [ + null, null, null, + "Matting prone — brush out before bath", + "Loves the dryer", + "Nippy around paws", + "Very calm, easy to handle", + "Needs extra time for drying (thick coat)", + "Sensitive around face — use caution", + "Doesn't like water, use minimal bath time", + "Loves belly rubs — great way to calm down", + "Double coat — needs thorough de-shedding", + "Previous clipper burn — be gentle on belly", +]; + +const appointmentNotes = [ + null, null, null, null, + "Client requested extra brushing", + "Nail trim only — no bath", + "Teeth brushing added", + "Ear cleaning requested", + "New puppy — first groom, be gentle", + "Matted — may need extra time", + "Owner wants shorter cut than usual", + "Anal glands need expressing", + "Use gentle shampoo per vet recommendation", + "Client running late, pushed start by 15min", +]; + +const visitLogNotes = [ + null, null, + "Coat in great condition", + "Found a small mat behind left ear, brushed out", + "Nails were very long, trimmed carefully", + "Light shedding, used de-shedding tool", + "Slight skin irritation noticed on belly — flagged to owner", + "Pet was very well-behaved today", + "Required two rinse cycles — very dirty", + "Applied conditioning treatment for dry coat", +]; + +const productsUsed = [ + null, + "Oatmeal shampoo, conditioner", + "Whitening shampoo, detangler", + "De-shedding shampoo, FURminator", + "Hypoallergenic shampoo, ear cleaner", + "Flea & tick shampoo, nail grinder", + "Puppy shampoo, gentle conditioner", + "Medicated shampoo (vet prescribed), moisturizer", + "Coconut oil shampoo, leave-in conditioner, cologne", +]; + +const demoPetImages = [ + "/demo-pets/dog-golden-after.png", + "/demo-pets/dog-poodle-groomed.png", + "/demo-pets/dog-black-lab.png", + "/demo-pets/dog-shih-tzu.png", + "/demo-pets/dog-cocker-spaniel.png", + "/demo-pets/dog-schnauzer.png", + "/demo-pets/dog-maltese.png", + "/demo-pets/dog-dachshund.png", + "/demo-pets/dog-pomeranian.png", + "/demo-pets/dog-bichon-frise.png", + "/demo-pets/dog-golden-retriever.png", + "/demo-pets/dog-labrador.png", + "/demo-pets/dog-mixed-breed.png", + "/demo-pets/dog-poodle.png", + "/demo-pets/dog-terrier.png", + "/demo-pets/dog-afghan-hound.png", + "/demo-pets/dog-basset-brown-white.png", + "/demo-pets/dog-bichon-white-groomed.png", + "/demo-pets/dog-boxer-fawn-athletic.png", + "/demo-pets/dog-cavalier-cream-gentle.png", + "/demo-pets/dog-cocker-buff-friendly.png", + "/demo-pets/dog-corgi.png", + "/demo-pets/dog-dachshund-black-tan.png", + "/demo-pets/dog-golden-before.png", + "/demo-pets/dog-pomeranian-white-studio.png", + "/demo-pets/dog-schnauzer-black-groomed.png", + "/demo-pets/dog-setter-red-sunlit.png", + "/demo-pets/dog-sheepdog-merle-running.png", +]; + +const puggleImages = [ + "/demo-pets/dog-puggle-fawn-playful.png", + "/demo-pets/dog-puggle-black-sitting.png", + "/demo-pets/dog-puggle-cream-groomed.png", + "/demo-pets/dog-puggle-fawn-grooming.png", +]; + +// ── Service definitions ────────────────────────────────────────────────────── +// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent: +// first run inserts, subsequent runs update existing rows via ON CONFLICT (name). +const servicesDef = [ + { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 }, + { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 }, + { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 }, + { id: "b0000001-0000-0000-0000-000000000004", name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 }, + { id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 }, + { id: "b0000001-0000-0000-0000-000000000006", name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 }, + { id: "b0000001-0000-0000-0000-000000000007", name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 }, + { id: "b0000001-0000-0000-0000-000000000008", name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 }, + { id: "b0000001-0000-0000-0000-000000000009", name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 }, + { id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, +]; + +// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── + +/** + * Seeds only the minimal known users for prod/demo environments. + * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. + * Idempotent: skips creation if records already exist. + */ +async function seedKnownUsers() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + const client = postgres(url, { max: 5 }); + const db = drizzle(client, { schema }); + + console.log("Seeding known users (prod/demo mode)...\n"); + + const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; + const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; + + // ── Staff: Demo Manager ── + const [existingStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "demo-manager@groombook.dev")) + .limit(1); + + if (existingStaff) { + console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: KNOWN_STAFF_ID, + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager", + isSuperUser: true, + active: true, + }); + console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); + } + + // ── Staff: SEED_ADMIN_EMAIL admin ── + const adminEmail = process.env.SEED_ADMIN_EMAIL; + if (adminEmail) { + const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; + const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; + const [existingAdmin] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, adminEmail)) + .limit(1); + + if (existingAdmin) { + console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: ADMIN_STAFF_ID, + name: adminName, + email: adminEmail, + oidcSub: adminEmail, + role: "manager", + isSuperUser: true, + active: true, + }); + console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`); + } + } + + // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── + const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; + if (uatSuperOidcSub) { + const UAT_SUPER_STAFF_ID = "00000000-0000-0000-0000-000000000003"; + const [existingUatSuper] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "uat-super@groombook.dev")) + .limit(1); + + if (existingUatSuper) { + console.log(`✓ Staff 'UAT Super User' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: UAT_SUPER_STAFF_ID, + name: "UAT Super User", + email: "uat-super@groombook.dev", + oidcSub: uatSuperOidcSub, + role: "manager", + isSuperUser: true, + active: true, + }); + console.log(`✓ Created staff 'UAT Super User' (oidcSub: ${uatSuperOidcSub})`); + } + } + + // ── Staff: UAT Staff Groomer (oidcSub from SEED_UAT_STAFF_OIDC_SUB env var) ── + const uatStaffOidcSub = process.env.SEED_UAT_STAFF_OIDC_SUB; + if (uatStaffOidcSub) { + const UAT_STAFF_STAFF_ID = "00000000-0000-0000-0000-000000000004"; + const [existingUatStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + + if (existingUatStaff) { + console.log(`✓ Staff 'UAT Staff Groomer' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: UAT_STAFF_STAFF_ID, + name: "UAT Staff Groomer", + email: "uat-groomer@groombook.dev", + oidcSub: uatStaffOidcSub, + role: "groomer", + isSuperUser: false, + active: true, + }); + console.log(`✓ Created staff 'UAT Staff Groomer' (oidcSub: ${uatStaffOidcSub})`); + } + } + + // ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ── + const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? []; + const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? []; + const groomerCount = Math.min(groomerEmails.length, groomerNames.length); + for (let i = 0; i < groomerCount; i++) { + const email = groomerEmails[i]!; + const name = groomerNames[i]!; + // Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range + const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; + const [existingGroomer] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, email)) + .limit(1); + + if (existingGroomer) { + console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: staffId, + name, + email, + oidcSub: email, + role: "groomer", + isSuperUser: false, + active: true, + }); + console.log(`✓ Created staff groomer '${name}' (${email})`); + } + } + + // ── 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 so the hash format matches what + // better-auth validates at sign-in time. + // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64. + 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. + const demoSvcs = [ + { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, + ]; + for (const svc of demoSvcs) { + await db.insert(schema.services) + .values({ ...svc, active: true }) + .onConflictDoUpdate({ + target: schema.services.name, + set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, + }); + } + console.log(`✓ Seeded ${demoSvcs.length} services`); + + // ── Client: Demo Client ── + const [existingClient] = await db + .select() + .from(schema.clients) + .where(eq(schema.clients.email, "demo-client@example.com")) + .limit(1); + + let clientId: string; + if (existingClient) { + clientId = existingClient.id; + console.log(`✓ Client '${existingClient.name}' already exists — skipping`); + } else { + const [created] = await db + .insert(schema.clients) + .values({ + id: DEMO_CLIENT_ID, + name: "Demo Client", + email: "demo-client@example.com", + phone: "555-0001", + address: "1 Demo Street, Demo City, CA 90210", + }) + .returning(); + clientId = created!.id; + console.log("✓ Created client 'Demo Client'"); + } + + // ── Pets: Demo Dogs & Cats ── + const demoPets = [ + { id: DEMO_PET_ID, name: "Demo Dog", species: "Dog", breed: "Golden Retriever", weight: "30.00", dob: "2020-06-15", image: "/demo-pets/dog-golden-after.png" }, + { id: uuid(), name: "Fluffy", species: "Dog", breed: "Poodle", weight: "8.50", dob: "2019-03-22", image: "/demo-pets/dog-poodle-groomed.png" }, + { id: uuid(), name: "Shadow", species: "Dog", breed: "Black Labrador", weight: "35.00", dob: "2018-11-10", image: "/demo-pets/dog-black-lab.png" }, + { id: uuid(), name: "Bella", species: "Dog", breed: "Shih Tzu", weight: "4.50", dob: "2021-02-14", image: "/demo-pets/dog-shih-tzu.png" }, + { id: uuid(), name: "Max", species: "Dog", breed: "Cocker Spaniel", weight: "15.00", dob: "2019-07-08", image: "/demo-pets/dog-cocker-spaniel.png" }, + { id: uuid(), name: "Buddy", species: "Dog", breed: "Schnauzer", weight: "12.00", dob: "2020-05-20", image: "/demo-pets/dog-schnauzer.png" }, + { id: uuid(), name: "Daisy", species: "Dog", breed: "Maltese", weight: "3.50", dob: "2021-09-03", image: "/demo-pets/dog-maltese.png" }, + { id: uuid(), name: "Charlie", species: "Dog", breed: "Dachshund", weight: "6.00", dob: "2020-01-15", image: "/demo-pets/dog-dachshund.png" }, + { id: uuid(), name: "Lucy", species: "Dog", breed: "Pomeranian", weight: "2.50", dob: "2022-04-10", image: "/demo-pets/dog-pomeranian.png" }, + ]; + + for (const pet of demoPets) { + const [existing] = await db + .select() + .from(schema.pets) + .where(eq(schema.pets.id, pet.id)) + .limit(1); + + if (existing) { + console.log(`✓ Pet '${existing.name}' already exists — skipping`); + } else { + await db.insert(schema.pets).values({ + id: pet.id, + clientId, + name: pet.name, + species: pet.species, + breed: pet.breed, + weightKg: pet.weight, + dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), + image: pet.image, + }); + console.log(`✓ Created pet '${pet.name}'`); + } + } + + console.log("\nKnown-users seed complete!"); + await client.end(); +} + +// ── Main seed ──────────────────────────────────────────────────────────────── + +async function seed() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + if (process.env.SEED_KNOWN_USERS_ONLY === "true") { + await seedKnownUsers(); + return; + } + + const profile = getProfile(); + const cfg = profiles[profile]; + const client = postgres(url, { max: 5 }); + const db = drizzle(client, { schema }); + + console.log(`Seeding Groom Book database (profile: ${profile})...\n`); + + // ── Staff ── + const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => + ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) + ); + const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => + ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) + ); + const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) => + ({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) + ); + const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) => + ({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) + ); + + await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`); + + const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers]; + for (const s of allStaff) { + await db.insert(schema.staff) + .values({ + id: s.id, + name: s.name, + email: s.email, + role: s.role, + isSuperUser: s.isSuperUser, + active: true, + }) + .onConflictDoUpdate({ + target: schema.staff.email, + set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true }, + }); + } + const staffLabel = cfg.staffCount.bather > 0 + ? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)` + : `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`; + console.log(`✓ Created ${staffLabel}`); + + // ── SEED_ADMIN_EMAIL admin ── + const adminEmail = process.env.SEED_ADMIN_EMAIL; + if (adminEmail) { + const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; + const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; + await db.insert(schema.staff) + .values({ + id: ADMIN_STAFF_ID, + name: adminName, + email: adminEmail, + oidcSub: adminEmail, + role: "manager", + isSuperUser: true, + active: true, + }) + .onConflictDoUpdate({ + target: schema.staff.email, + set: { id: ADMIN_STAFF_ID, name: adminName, role: "manager", isSuperUser: true, active: true }, + }); + console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); + } + + // ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ── + const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? []; + const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? []; + const groomerCount = Math.min(groomerEmails.length, groomerNames.length); + for (let i = 0; i < groomerCount; i++) { + const email = groomerEmails[i]!; + const name = groomerNames[i]!; + const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; + await db.insert(schema.staff) + .values({ + id: staffId, + name, + email, + oidcSub: email, + role: "groomer", + isSuperUser: false, + active: true, + }) + .onConflictDoUpdate({ + target: schema.staff.email, + set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true }, + }); + console.log(`✓ Upserted groomer '${name}' (${email})`); + } + + // ── Services ── + // Upsert services using name as unique key. With deterministic IDs in + // servicesDef and TRUNCATE clearing downstream tables first, this is + // idempotent: first run inserts, subsequent runs update existing rows. + const serviceIds: string[] = []; + for (const s of servicesDef) { + serviceIds.push(s.id); + await db.insert(schema.services) + .values({ + id: s.id, + name: s.name, + description: s.desc, + basePriceCents: s.price, + durationMinutes: s.dur, + active: true, + }) + .onConflictDoUpdate({ + target: schema.services.name, + set: { description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true }, + }); + } + console.log(`✓ Created ${servicesDef.length} services`); + + // ── Clients & Pets ── + const now = new Date(); + const appointmentsBackDate = new Date(now); + appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays); + const appointmentsForwardDate = new Date(now); + appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays); + + interface ClientRecord { id: string; name: string } + interface PetRecord { id: string; clientId: string } + + const clientRecords: ClientRecord[] = []; + const petRecords: PetRecord[] = []; + + let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets + const clientBatchSize = 50; + for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) { + const clientBatch: (typeof schema.clients.$inferInsert)[] = []; + const petBatch: (typeof schema.pets.$inferInsert)[] = []; + + for (let i = 0; i < clientBatchSize; i++) { + const clientId = uuid(); + const first = pick(firstNames); + const last = pick(lastNames); + const name = `${first} ${last}`; + const emailDomain = pick(["gmail.com", "yahoo.com", "outlook.com", "icloud.com", "hotmail.com"]); + const email = `${first.toLowerCase()}.${last.toLowerCase()}${randInt(1, 99)}@${emailDomain}`; + const phone = `(${randInt(200, 999)}) ${randInt(200, 999)}-${String(randInt(1000, 9999))}`; + const addr = `${randInt(100, 9999)} ${pick(streetNames)}, ${pick(cities)}, ${pick(states)} ${String(randInt(10000, 99999))}`; + + clientBatch.push({ + id: clientId, + name, + email, + phone, + address: addr, + notes: rand() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null, + emailOptOut: rand() < 0.1, + }); + + clientRecords.push({ id: clientId, name }); + + // 1-3 pets per client + const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3; + for (let p = 0; p < petCount; p++) { + const petId = uuid(); + const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds); + const dob = new Date(now); + dob.setFullYear(dob.getFullYear() - randInt(1, 14)); + dob.setMonth(randInt(0, 11)); + + petBatch.push({ + id: petId, + clientId, + name: pick(dogNames), + species: "Dog", + breed, + weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)), + dateOfBirth: dob, + healthAlerts: pick(healthAlerts), + groomingNotes: pick(groomingNotes), + cutStyle: pick(cutStyles), + shampooPreference: pick(shampoos), + specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, + customFields: {}, + image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), + }); + + petRecords.push({ id: petId, clientId }); + petIndex++; + } + } + + for (const client of clientBatch) { + await db.insert(schema.clients) + .values(client) + .onConflictDoUpdate({ + target: schema.clients.id, + set: { name: client.name, email: client.email, phone: client.phone, address: client.address, notes: client.notes, emailOptOut: client.emailOptOut }, + }); + } + + for (const pet of petBatch) { + await db.insert(schema.pets) + .values(pet) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { + clientId: pet.clientId, + name: pet.name, + species: pet.species, + breed: pet.breed, + weightKg: pet.weightKg, + dateOfBirth: pet.dateOfBirth, + healthAlerts: pet.healthAlerts, + groomingNotes: pet.groomingNotes, + cutStyle: pet.cutStyle, + shampooPreference: pet.shampooPreference, + specialCareNotes: pet.specialCareNotes, + customFields: pet.customFields, + image: pet.image, + }, + }); + } + } + + console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`); + + // ── UAT test clients (guaranteed pending invoices) ───────────────────────────── + // These 5 clients are deterministic and documented in Shedward AGENTS.md so + // UAT can reliably find billing test data without searching. + if (cfg.includeUatClients) { + interface UatClient { + id: string; + name: string; + email: string; + phone: string; + address: string; + petId: string; + petName: string; + petBreed: string; + } + const uatClients: UatClient[] = [ + { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" }, + { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" }, + { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" }, + { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" }, + { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" }, + ]; + + for (const uc of uatClients) { + await db.insert(schema.clients) + .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) + .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); + await db.insert(schema.pets) + .values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) }) + .onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } }); + // Create one completed appointment for this client + const apptId = uuid(); + const svcIdx = 0; + const svc = servicesDef[svcIdx]!; + const completedTime = randDate(appointmentsBackDate, now); + completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); + const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000); + const uatGroomer = groomers[0]!; + const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer; + await db.insert(schema.appointments).values({ + id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id, + batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price, + }); + // Create a PENDING invoice for that appointment + const invoiceId = uuid(); + const taxCents = Math.round(svc.price * 0.08); + const totalCents = svc.price + taxCents; + await db.insert(schema.invoices).values({ + id: invoiceId, appointmentId: apptId, clientId: uc.id, subtotalCents: svc.price, + taxCents, tipCents: 0, totalCents, status: "pending" as const, + paymentMethod: null, paidAt: null, notes: null, + }); + await db.insert(schema.invoiceLineItems).values({ + id: uuid(), invoiceId, description: svc.name, quantity: 1, unitPriceCents: svc.price, totalCents: svc.price, + }); + await db.insert(schema.groomingVisitLogs).values({ + id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id, + cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime, + }); + } + console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`); + } + + // ── Appointments, Invoices, Visit Logs ── + // Generate ~5 appointments per client on average = ~2500 total + const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [ + "completed", "completed", "completed", "completed", "completed", + "completed", "completed", "scheduled", "confirmed", "cancelled", "no_show", + ]; + + let appointmentCount = 0; + let invoiceCount = 0; + let visitLogCount = 0; + + // Process in batches per client to keep memory manageable + const apptBatchSize = 100; + let apptBatch: (typeof schema.appointments.$inferInsert)[] = []; + let invoiceBatch: (typeof schema.invoices.$inferInsert)[] = []; + let lineItemBatch: (typeof schema.invoiceLineItems.$inferInsert)[] = []; + let tipSplitBatch: (typeof schema.invoiceTipSplits.$inferInsert)[] = []; + let visitLogBatch: (typeof schema.groomingVisitLogs.$inferInsert)[] = []; + + async function flushBatches() { + if (apptBatch.length > 0) { + await db.insert(schema.appointments).values(apptBatch); + apptBatch = []; + } + if (invoiceBatch.length > 0) { + await db.insert(schema.invoices).values(invoiceBatch); + invoiceBatch = []; + } + if (lineItemBatch.length > 0) { + await db.insert(schema.invoiceLineItems).values(lineItemBatch); + lineItemBatch = []; + } + if (tipSplitBatch.length > 0) { + await db.insert(schema.invoiceTipSplits).values(tipSplitBatch); + tipSplitBatch = []; + } + if (visitLogBatch.length > 0) { + await db.insert(schema.groomingVisitLogs).values(visitLogBatch); + visitLogBatch = []; + } + } + + // Group pets by client for efficient appointment generation + const petsByClient = new Map(); + for (const pet of petRecords) { + const arr = petsByClient.get(pet.clientId) ?? []; + arr.push(pet.id); + petsByClient.set(pet.clientId, arr); + } + + for (const client of clientRecords) { + const pets = petsByClient.get(client.id) ?? []; + // Each client visits ~3-8 times over the year + const visitCount = randInt(3, 8); + + for (let v = 0; v < visitCount; v++) { + // Pick a random pet for this visit + const petId = pick(pets); + const serviceIdx = randInt(0, serviceIds.length - 1); + const serviceId = serviceIds[serviceIdx]!; + const svc = servicesDef[serviceIdx]!; + const groomer = pick(groomers); + const bather = rand() < 0.6 ? pick(bathers) : null; + const status = pick(statuses); + + // Schedule within the configured appointment window + let startTime: Date; + if (status === "scheduled" || status === "confirmed") { + startTime = randDate(now, appointmentsForwardDate); + } else { + startTime = randDate(appointmentsBackDate, now); + } + // Snap to business hours (8am - 5pm) + startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); + const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); + + const apptId = uuid(); + const priceCents = rand() < 0.2 ? svc.price + randInt(-500, 1000) : null; + const effectivePrice = priceCents ?? svc.price; + + apptBatch.push({ + id: apptId, + clientId: client.id, + petId, + serviceId, + staffId: groomer.id, + batherStaffId: bather?.id ?? null, + status, + startTime, + endTime, + notes: pick(appointmentNotes), + priceCents, + }); + appointmentCount++; + + // Create invoice for completed appointments + if (status === "completed") { + const invoiceId = uuid(); + const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; + const taxCents = Math.round(effectivePrice * 0.08); + const totalCents = effectivePrice + taxCents + tipCents; + + const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; + const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; + + const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null; + invoiceBatch.push({ + id: invoiceId, + appointmentId: apptId, + clientId: client.id, + subtotalCents: effectivePrice, + taxCents, + tipCents, + totalCents, + status: invoiceStatus, + paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, + paidAt, + stripePaymentIntentId, + notes: rand() < 0.05 ? "Added extra service at checkout" : null, + }); + + // Line item + lineItemBatch.push({ + id: uuid(), + invoiceId, + description: svc.name, + quantity: 1, + unitPriceCents: effectivePrice, + totalCents: effectivePrice, + }); + + // Tip splits for paid invoices with tips + if (tipCents > 0 && invoiceStatus === "paid") { + if (bather) { + // 60/40 split groomer/bather + const groomerShare = Math.round(tipCents * 0.6); + const batherShare = tipCents - groomerShare; + tipSplitBatch.push( + { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, + { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, + ); + } else { + tipSplitBatch.push({ + id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents, + }); + } + } + + invoiceCount++; + + // Visit log + visitLogBatch.push({ + id: uuid(), + petId, + appointmentId: apptId, + staffId: groomer.id, + cutStyle: pick(cutStyles), + productsUsed: pick(productsUsed), + notes: pick(visitLogNotes), + groomedAt: endTime, + }); + visitLogCount++; + } + + // Flush periodically + if (apptBatch.length >= apptBatchSize) { + await flushBatches(); + } + } + } + + // Final flush + await flushBatches(); + + console.log(`✓ Created ${appointmentCount} appointments`); + console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); + + // ── Enforce target invoice count ─────────────────────────────────────────── + // If current invoice count is below target (due to profile having fewer + // clients/appointments than the target ratio), generate supplemental + // completed appointments for existing clients to fill the gap. + if (invoiceCount < cfg.invoiceCount) { + const additionalNeeded = cfg.invoiceCount - invoiceCount; + console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`); + + const existingClientIds = clientRecords.map(c => c.id); + const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20); + let supplementalCount = 0; + let supplementalInvoices = 0; + + for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) { + const clientId = pick(existingClientIds); + const pets = petsByClient.get(clientId) ?? []; + if (pets.length === 0) continue; + + const petId = pick(pets); + const serviceIdx = randInt(0, serviceIds.length - 1); + const serviceId = serviceIds[serviceIdx]!; + const svc = servicesDef[serviceIdx]!; + const groomer = pick(groomers); + const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null; + + let startTime = randDate(appointmentsBackDate, now); + startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); + const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); + const effectivePrice = svc.price; + + const apptId = uuid(); + apptBatch.push({ + id: apptId, clientId, petId, serviceId, + staffId: groomer.id, batherStaffId: bather?.id ?? null, + status: "completed", startTime, endTime, notes: null, priceCents: null, + }); + appointmentCount++; + supplementalCount++; + + const invoiceId = uuid(); + const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; + const taxCents = Math.round(effectivePrice * 0.08); + const totalCents = effectivePrice + taxCents + tipCents; + const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); + const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null; + + invoiceBatch.push({ + id: invoiceId, appointmentId: apptId, clientId, + subtotalCents: effectivePrice, taxCents, tipCents, totalCents, + status: "paid" as const, + paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", + paidAt, stripePaymentIntentId, notes: null, + }); + lineItemBatch.push({ + id: uuid(), invoiceId, description: svc.name, quantity: 1, + unitPriceCents: effectivePrice, totalCents: effectivePrice, + }); + if (tipCents > 0) { + if (bather) { + const groomerShare = Math.round(tipCents * 0.6); + const batherShare = tipCents - groomerShare; + tipSplitBatch.push( + { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, + { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, + ); + } else { + tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents }); + } + } + visitLogBatch.push({ + id: uuid(), petId, appointmentId: apptId, staffId: groomer.id, + cutStyle: pick(cutStyles), productsUsed: pick(productsUsed), + notes: pick(visitLogNotes), groomedAt: endTime, + }); + invoiceCount++; + supplementalInvoices++; + visitLogCount++; + + if (apptBatch.length >= apptBatchSize) { + await flushBatches(); + } + } + + await flushBatches(); + console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`); + console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); + } + console.log(`✓ Created ${visitLogCount} grooming visit logs`); + console.log("\nSeed complete!"); + + await client.end(); +} + +seed().catch((err) => { + console.error("Seed failed:", err); + process.exit(1); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..3b421a7 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..5a3a7c4 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,22 @@ +{ + "name": "@groombook/types", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./src/index.ts", + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc --project .", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.3" + }, + "license": "AGPL-3.0-only" +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..d53138e --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,227 @@ +// Shared domain types for Groom Book + +export type AppointmentStatus = + | "scheduled" + | "confirmed" + | "in_progress" + | "completed" + | "cancelled" + | "no_show"; + +export type ConfirmationStatus = "pending" | "confirmed" | "cancelled"; + +export type ClientStatus = "active" | "disabled"; + +export interface Client { + id: string; + name: string; + email: string | null; + phone: string | null; + address: string | null; + notes: string | null; + emailOptOut: boolean; + status: ClientStatus; + disabledAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Pet { + id: string; + clientId: string; + name: string; + species: string; + breed: string | null; + weightKg: number | null; + dateOfBirth: string | null; + healthAlerts: string | null; + groomingNotes: string | null; + cutStyle: string | null; + shampooPreference: string | null; + specialCareNotes: string | null; + coatType: string | null; + petSizeCategory: string | null; + preferredCuts: string[]; + medicalAlerts: MedicalAlert[]; + temperamentScore?: number; + temperamentFlags?: string[]; + customFields: Record; + photoKey?: string; + photoUploadedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface GroomingVisitLog { + id: string; + petId: string; + appointmentId: string | null; + staffId: string | null; + cutStyle: string | null; + productsUsed: string | null; + notes: string | null; + groomedAt: string; + createdAt: string; +} + +export interface Service { + id: string; + name: string; + description: string | null; + basePriceCents: number; + durationMinutes: number; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Staff { + id: string; + name: string; + email: string; + role: "groomer" | "receptionist" | "manager"; + isSuperUser: boolean; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface RecurringSeries { + id: string; + frequencyWeeks: number; + createdAt: string; +} + +export interface AppointmentGroup { + id: string; + clientId: string; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Appointment { + id: string; + clientId: string; + petId: string; + serviceId: string; + staffId: string | null; + batherStaffId: string | null; + status: AppointmentStatus; + startTime: string; + endTime: string; + notes: string | null; + priceCents: number | null; + seriesId: string | null; + seriesIndex: number | null; + groupId: string | null; + confirmationStatus: ConfirmationStatus; + confirmedAt: string | null; + cancelledAt: string | null; + confirmationToken: string | null; + customerNotes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface InvoiceTipSplit { + id: string; + invoiceId: string; + staffId: string | null; + staffName: string; + sharePct: string; + shareCents: number; + createdAt: string; +} + +export type InvoiceStatus = "draft" | "pending" | "paid" | "void"; +export type PaymentMethod = "cash" | "card" | "check" | "other"; + +export interface InvoiceLineItem { + id: string; + invoiceId: string; + description: string; + quantity: number; + unitPriceCents: number; + totalCents: number; + createdAt: string; +} + +export interface Invoice { + id: string; + appointmentId: string | null; + clientId: string; + subtotalCents: number; + taxCents: number; + tipCents: number; + totalCents: number; + status: InvoiceStatus; + paymentMethod: PaymentMethod | null; + paidAt: string | null; + stripePaymentIntentId: string | null; + stripeRefundId: string | null; + paymentFailureReason: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; + lineItems?: InvoiceLineItem[]; + // Transient fields populated from Stripe API (not stored in DB) + cardLast4?: string | null; + paymentStatus?: string | null; + tipSplits?: InvoiceTipSplit[]; +} + +// ─── Impersonation ────────────────────────────────────────────────────────── + +export type ImpersonationSessionStatus = "active" | "ended" | "expired"; + +export interface ImpersonationSession { + id: string; + staffId: string; + clientId: string; + reason: string | null; + status: ImpersonationSessionStatus; + startedAt: string; + endedAt: string | null; + expiresAt: string; + createdAt: string; +} + +export interface ImpersonationAuditLog { + id: string; + sessionId: string; + action: string; + pageVisited: string | null; + metadata: Record | null; + createdAt: string; +} + +export interface BusinessSettings { + id: string; + businessName: string; + logoBase64: string | null; + logoMimeType: string | null; + primaryColor: string; + accentColor: string; + createdAt: string; + updatedAt: string; +} + +// Paginated list response +export interface PaginatedList { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export type AlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + id: string; + type: string; + description: string; + severity: AlertSeverity; +} + +export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless"; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..3b421a7 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9857624..a108283 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,31 +6,35 @@ settings: importers: - .: {} - - apps/api: + .: dependencies: '@aws-sdk/client-s3': specifier: ^3.800.0 - version: 3.1041.0 + version: 3.1045.0 '@aws-sdk/s3-request-presigner': specifier: ^3.800.0 - version: 3.1041.0 + version: 3.1045.0 + '@groombook/db': + specifier: workspace:* + version: link:packages/db + '@groombook/types': + specifier: workspace:* + version: link:packages/types '@hono/node-server': specifier: ^1.13.7 - version: 1.19.14(hono@4.12.16) + version: 1.19.14(hono@4.12.18) '@hono/zod-validator': specifier: ^0.7.6 - version: 0.7.6(hono@4.12.16)(zod@4.4.2) + version: 0.7.6(hono@4.12.18)(zod@4.4.3) better-auth: specifier: ^1.5.6 - version: 1.6.9(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)) + version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)) drizzle-orm: specifier: ^0.38.4 - version: 0.38.4(kysely@0.28.16)(postgres@3.4.9) + version: 0.38.4(kysely@0.28.17)(postgres@3.4.9) hono: specifier: ^4.6.17 - version: 4.12.16 + version: 4.12.18 node-cron: specifier: ^3.0.3 version: 3.0.3 @@ -42,17 +46,17 @@ importers: version: 3.4.9 stripe: specifier: ^22.0.0 - version: 22.1.0(@types/node@22.19.17) + version: 22.1.1(@types/node@22.19.18) telnyx: specifier: ^1.23.0 version: 1.27.0 zod: specifier: ^4.3.6 - version: 4.4.2 + version: 4.4.3 devDependencies: '@types/node': specifier: ^22.10.7 - version: 22.19.17 + version: 22.19.18 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -61,10 +65,7 @@ importers: version: 6.4.23 '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)) - drizzle-kit: - specifier: ^0.30.4 - version: 0.30.6 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)) eslint: specifier: ^9.18.0 version: 9.39.4 @@ -76,10 +77,41 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.20.0 - version: 8.59.1(eslint@9.39.4)(typescript@5.9.3) + version: 8.59.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.19.17)(tsx@4.21.0) + version: 3.2.4(@types/node@22.19.18)(tsx@4.21.0) + + packages/db: + dependencies: + better-auth: + specifier: ^1.5.6 + version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)) + drizzle-orm: + specifier: ^0.38.4 + version: 0.38.4(kysely@0.28.17)(postgres@3.4.9) + postgres: + specifier: ^3.4.5 + version: 3.4.9 + devDependencies: + '@types/node': + specifier: ^22.10.7 + version: 22.19.18 + drizzle-kit: + specifier: ^0.30.4 + version: 0.30.6 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/types: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 packages: @@ -110,8 +142,8 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.1041.0': - resolution: {integrity: sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ==} + '@aws-sdk/client-s3@3.1045.0': + resolution: {integrity: sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.974.8': @@ -202,8 +234,8 @@ packages: resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1041.0': - resolution: {integrity: sha512-DlKsPQ8Z75wgeDSHbjUPNDQCYUF0OLBkqllZqFei61KIoQDqEeKUCwuCf6RhNLjaP4b8oSpBA9+FmUS+zm3xUg==} + '@aws-sdk/s3-request-presigner@3.1045.0': + resolution: {integrity: sha512-VDRF8GIuUPX+K4DUYrvcODj/h54LOmdJ7DhpLQ0wrYrdxzIiJEpi0n9jZ1bbjT2UxhwTbOorse5EGo+gnOK2aA==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.25': @@ -275,8 +307,8 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@better-auth/core@1.6.9': - resolution: {integrity: sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==} + '@better-auth/core@1.6.10': + resolution: {integrity: sha512-13h/rfSGMLl7zwyOb1BSlxAQZs2nQqn/xFI/bxB7zQuS95hVgTmNbKqhHtJYkNDtuJCcjEf1sNtLBHkvaPT/vw==} peerDependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -292,46 +324,46 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.9': - resolution: {integrity: sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==} + '@better-auth/drizzle-adapter@1.6.10': + resolution: {integrity: sha512-Ax0Jlpvuu35P3U6FtUGfkLAUmBwYIF+JwwtHt+jBlOEQNIiBhbLHh8ArQxrKJFRTDYv/XoSsrvJ5OluPsUFuuQ==} peerDependencies: - '@better-auth/core': ^1.6.9 + '@better-auth/core': ^1.6.10 '@better-auth/utils': 0.4.0 drizzle-orm: ^0.45.2 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.9': - resolution: {integrity: sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==} + '@better-auth/kysely-adapter@1.6.10': + resolution: {integrity: sha512-Mp27qHgnvNCkkVEMRwhtMVpiiVFBnww0V+bunRWNU8fkxsm6H+GIBihUQOnkSCnIjV7f2HNjrf5DeYI2IhDePQ==} peerDependencies: - '@better-auth/core': ^1.6.9 + '@better-auth/core': ^1.6.10 '@better-auth/utils': 0.4.0 kysely: ^0.28.14 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.9': - resolution: {integrity: sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==} + '@better-auth/memory-adapter@1.6.10': + resolution: {integrity: sha512-zXk7GXnOpafAjCJ3+boh8hTEmUozGCLQpCl4plH9sAix6UlMdpYmdDTEGd+I8zungiEK+jzw4oevy7IirzKrrw==} peerDependencies: - '@better-auth/core': ^1.6.9 + '@better-auth/core': ^1.6.10 '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.9': - resolution: {integrity: sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==} + '@better-auth/mongo-adapter@1.6.10': + resolution: {integrity: sha512-EkbK3j8qwE9STteWoUh7vkve7n7/jSlkI0e9onAwr3YuE+an8scG7BgTHoDXku2qPlVa+KFPmcWc1FX6K/N6xA==} peerDependencies: - '@better-auth/core': ^1.6.9 + '@better-auth/core': ^1.6.10 '@better-auth/utils': 0.4.0 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.6.9': - resolution: {integrity: sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==} + '@better-auth/prisma-adapter@1.6.10': + resolution: {integrity: sha512-p/eJXl/RtHLt/A75chX/P55gjMzo5tWSJyfAyICts1miGHFUsu6D2TmKSzF7KNtjucjjzRKmtFl6BwMOxXXf2Q==} peerDependencies: - '@better-auth/core': ^1.6.9 + '@better-auth/core': ^1.6.10 '@better-auth/utils': 0.4.0 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -341,10 +373,10 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.9': - resolution: {integrity: sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==} + '@better-auth/telemetry@1.6.10': + resolution: {integrity: sha512-7lcx4btKGe4tP7Y1Nk6MWQuaiKI+qOk08B4vZFJeNKxRXyCNjVmdck78NyOTEj3iaVxN69MiXDoBZ4fEdvVbBw==} peerDependencies: - '@better-auth/core': ^1.6.9 + '@better-auth/core': ^1.6.10 '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -904,141 +936,128 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] - libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} cpu: [x64] os: [win32] @@ -1266,75 +1285,78 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} - '@types/node@22.19.17': - resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@22.19.18': + resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} '@types/nodemailer@6.4.23': resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==} - '@typescript-eslint/eslint-plugin@8.59.1': - resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.1 + '@typescript-eslint/parser': ^8.59.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.1': - resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.1': - resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.59.1': - resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.59.1': - resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.1': - resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.59.1': - resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.59.1': - resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.1': - resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.59.1': - resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitest/coverage-v8@3.2.4': @@ -1421,8 +1443,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - better-auth@1.6.9: - resolution: {integrity: sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==} + better-auth@1.6.10: + resolution: {integrity: sha512-gzYaywJuhAkv9bTuFj1k6zaSKEAcabxAzYsBj0kXSMaQJVE9uS/qp2592IZmuvtMHO1ohLOP92jDPV6xVsZSoQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1500,8 +1522,8 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} buffer-from@1.1.2: @@ -1782,8 +1804,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} fast-xml-parser@5.7.2: resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} @@ -1870,8 +1892,8 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} - hono@4.12.16: - resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -1956,8 +1978,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.28.16: - resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==} + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} levn@0.4.1: @@ -2085,8 +2107,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.13: - resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} postgres@3.4.9: @@ -2112,8 +2134,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2123,8 +2145,8 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} hasBin: true @@ -2206,8 +2228,8 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - stripe@22.1.0: - resolution: {integrity: sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==} + stripe@22.1.1: + resolution: {integrity: sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -2215,8 +2237,8 @@ packages: '@types/node': optional: true - strnum@2.2.3: - resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} @@ -2273,8 +2295,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.59.1: - resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} + typescript-eslint@8.59.2: + resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2306,8 +2328,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.3.2: - resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2401,12 +2423,16 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.4.2: - resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: @@ -2462,7 +2488,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1041.0': + '@aws-sdk/client-s3@3.1045.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 @@ -2795,7 +2821,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1041.0': + '@aws-sdk/s3-request-presigner@3.1045.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.996.25 '@aws-sdk/types': 3.973.8 @@ -2895,50 +2921,50 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)': + '@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@opentelemetry/semantic-conventions': 1.40.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.5(zod@4.4.2) + better-call: 1.3.5(zod@4.4.3) jose: 6.2.3 - kysely: 0.28.16 + kysely: 0.28.17 nanostores: 1.3.0 - zod: 4.4.2 + zod: 4.4.3 - '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))': + '@better-auth/drizzle-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: - drizzle-orm: 0.38.4(kysely@0.28.16)(postgres@3.4.9) + drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9) - '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)': + '@better-auth/kysely-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: - kysely: 0.28.16 + kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/mongo-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/prisma-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -3219,14 +3245,14 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@hono/node-server@1.19.14(hono@4.12.16)': + '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: - hono: 4.12.16 + hono: 4.12.18 - '@hono/zod-validator@0.7.6(hono@4.12.16)(zod@4.4.2)': + '@hono/zod-validator@0.7.6(hono@4.12.18)(zod@4.4.3)': dependencies: - hono: 4.12.16 - zod: 4.4.2 + hono: 4.12.18 + zod: 4.4.3 '@humanfs/core@0.19.2': dependencies: @@ -3282,79 +3308,79 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@rollup/rollup-android-arm-eabi@4.60.2': + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true - '@rollup/rollup-android-arm64@4.60.2': + '@rollup/rollup-android-arm64@4.60.3': optional: true - '@rollup/rollup-darwin-arm64@4.60.2': + '@rollup/rollup-darwin-arm64@4.60.3': optional: true - '@rollup/rollup-darwin-x64@4.60.2': + '@rollup/rollup-darwin-x64@4.60.3': optional: true - '@rollup/rollup-freebsd-arm64@4.60.2': + '@rollup/rollup-freebsd-arm64@4.60.3': optional: true - '@rollup/rollup-freebsd-x64@4.60.2': + '@rollup/rollup-freebsd-x64@4.60.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.2': + '@rollup/rollup-linux-arm-musleabihf@4.60.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.2': + '@rollup/rollup-linux-arm64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.2': + '@rollup/rollup-linux-arm64-musl@4.60.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.2': + '@rollup/rollup-linux-loong64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.2': + '@rollup/rollup-linux-loong64-musl@4.60.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.2': + '@rollup/rollup-linux-ppc64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.2': + '@rollup/rollup-linux-ppc64-musl@4.60.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.2': + '@rollup/rollup-linux-riscv64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.2': + '@rollup/rollup-linux-riscv64-musl@4.60.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.2': + '@rollup/rollup-linux-s390x-gnu@4.60.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.2': + '@rollup/rollup-linux-x64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-x64-musl@4.60.2': + '@rollup/rollup-linux-x64-musl@4.60.3': optional: true - '@rollup/rollup-openbsd-x64@4.60.2': + '@rollup/rollup-openbsd-x64@4.60.3': optional: true - '@rollup/rollup-openharmony-arm64@4.60.2': + '@rollup/rollup-openharmony-arm64@4.60.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.2': + '@rollup/rollup-win32-arm64-msvc@4.60.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.2': + '@rollup/rollup-win32-ia32-msvc@4.60.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.2': + '@rollup/rollup-win32-x64-gnu@4.60.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.2': + '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true '@smithy/chunked-blob-reader-native@4.2.3': @@ -3701,26 +3727,28 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node-cron@3.0.11': {} - '@types/node@22.19.17': + '@types/node@22.19.18': dependencies: undici-types: 6.21.0 '@types/nodemailer@6.4.23': dependencies: - '@types/node': 22.19.17 + '@types/node': 22.19.18 - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 eslint: 9.39.4 ignore: 7.0.5 natural-compare: 1.4.0 @@ -3729,41 +3757,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) - '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.59.1': + '@typescript-eslint/scope-manager@8.59.2': dependencies: - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 - '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -3771,40 +3799,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.59.1': {} + '@typescript-eslint/types@8.59.2': {} - '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.59.1': + '@typescript-eslint/visitor-keys@8.59.2': dependencies: - '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3819,7 +3847,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.19.17)(tsx@4.21.0) + vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -3831,13 +3859,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.17)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.18)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@22.19.17)(tsx@4.21.0) + vite: 7.3.3(@types/node@22.19.18)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -3902,41 +3930,41 @@ snapshots: balanced-match@4.0.4: {} - better-auth@1.6.9(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)): + better-auth@1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)): dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9)) - '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16) - '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9)) + '@better-auth/kysely-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 - better-call: 1.3.5(zod@4.4.2) + better-call: 1.3.5(zod@4.4.3) defu: 6.1.7 jose: 6.2.3 - kysely: 0.28.16 + kysely: 0.28.17 nanostores: 1.3.0 - zod: 4.4.2 + zod: 4.4.3 optionalDependencies: drizzle-kit: 0.30.6 - drizzle-orm: 0.38.4(kysely@0.28.16)(postgres@3.4.9) - vitest: 3.2.4(@types/node@22.19.17)(tsx@4.21.0) + drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9) + vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.5(zod@4.4.2): + better-call@1.3.5(zod@4.4.3): dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 3.1.0 optionalDependencies: - zod: 4.4.2 + zod: 4.4.3 bowser@2.14.1: {} @@ -3949,7 +3977,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -4018,9 +4046,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9): + drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9): optionalDependencies: - kysely: 0.28.16 + kysely: 0.28.17 postgres: 3.4.9 dunder-proto@1.0.1: @@ -4160,7 +4188,7 @@ snapshots: '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -4204,7 +4232,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -4216,16 +4244,17 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-xml-builder@1.1.5: + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 fast-xml-parser@5.7.2: dependencies: '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 + fast-xml-builder: 1.2.0 path-expression-matcher: 1.5.0 - strnum: 2.2.3 + strnum: 2.3.0 fdir@6.5.0(picomatch@4.0.4): optionalDependencies: @@ -4262,7 +4291,7 @@ snapshots: '@petamoriken/float16': 3.9.3 debug: 4.4.3 env-paths: 3.0.0 - semver: 7.7.4 + semver: 7.8.0 shell-quote: 1.8.3 which: 4.0.0 transitivePeerDependencies: @@ -4315,7 +4344,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.16: {} + hono@4.12.18: {} html-escaper@2.0.2: {} @@ -4389,7 +4418,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.28.16: {} + kysely@0.28.17: {} levn@0.4.1: dependencies: @@ -4420,13 +4449,13 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 math-intrinsics@1.1.0: {} minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: @@ -4496,7 +4525,7 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.13: + postcss@8.5.14: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -4516,42 +4545,42 @@ snapshots: resolve-pkg-maps@1.0.0: {} - rollup@4.60.2: + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 rou3@0.7.12: {} safe-buffer@5.2.1: {} - semver@7.7.4: {} + semver@7.8.0: {} set-cookie-parser@3.1.0: {} @@ -4634,11 +4663,11 @@ snapshots: dependencies: js-tokens: 9.0.1 - stripe@22.1.0(@types/node@22.19.17): + stripe@22.1.1(@types/node@22.19.18): optionalDependencies: - '@types/node': 22.19.17 + '@types/node': 22.19.18 - strnum@2.2.3: {} + strnum@2.3.0: {} supports-color@7.2.0: dependencies: @@ -4692,12 +4721,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.59.1(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.59.2(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: @@ -4715,13 +4744,13 @@ snapshots: uuid@9.0.1: {} - vite-node@3.2.4(@types/node@22.19.17)(tsx@4.21.0): + vite-node@3.2.4(@types/node@22.19.18)(tsx@4.21.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@22.19.17)(tsx@4.21.0) + vite: 7.3.3(@types/node@22.19.18)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -4736,24 +4765,24 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@22.19.17)(tsx@4.21.0): + vite@7.3.3(@types/node@22.19.18)(tsx@4.21.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.13 - rollup: 4.60.2 + postcss: 8.5.14 + rollup: 4.60.3 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 22.19.17 + '@types/node': 22.19.18 fsevents: 2.3.3 tsx: 4.21.0 - vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0): + vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.18)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4771,11 +4800,11 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@22.19.17)(tsx@4.21.0) - vite-node: 3.2.4(@types/node@22.19.17)(tsx@4.21.0) + vite: 7.3.3(@types/node@22.19.18)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.18)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.19.17 + '@types/node': 22.19.18 transitivePeerDependencies: - jiti - less @@ -4817,6 +4846,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + xml-naming@0.1.0: {} + yocto-queue@0.1.0: {} - zod@4.4.2: {} + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06b6051..dee51e9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - "apps/*" + - "packages/*" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..08e2e37 --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"], + "labels": ["dependencies"], + "prConcurrentLimit": 5, + "packageRules": [ + {"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false}, + {"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"} + ] +} \ No newline at end of file diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts new file mode 100644 index 0000000..7b4db22 --- /dev/null +++ b/src/__tests__/auth.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mutable state to control mock behavior per test +let dbSelectResult: unknown[] = []; +const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })); +const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`); + +vi.mock("@groombook/db", () => { + const authProviderConfig = new Proxy( + { _name: "auth_provider_config" }, + { + get(target, prop) { + if (prop === "_name") return "auth_provider_config"; + if (prop === "$inferSelect") return {}; + return { table: "auth_provider_config", column: prop }; + }, + } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => dbSelectResult, + [Symbol.iterator]: function* () { + for (const item of dbSelectResult) yield item; + }, + 0: dbSelectResult[0], + length: dbSelectResult.length, + }), + }), + }), + }), + authProviderConfig, + eq: mockEq, + decryptSecret: mockDecryptSecret, + }; +}); + +async function reimportAuth() { + vi.resetModules(); + vi.doMock("@groombook/db", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => dbSelectResult, + [Symbol.iterator]: function* () { + for (const item of dbSelectResult) yield item; + }, + 0: dbSelectResult[0], + length: dbSelectResult.length, + }), + }), + }), + }), + authProviderConfig: {}, + eq: mockEq, + decryptSecret: mockDecryptSecret, + })); + const mod = await import("../lib/auth.js"); + return mod; +} + +describe("auth init", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + dbSelectResult = []; + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("falls back to env vars when DB returns empty", async () => { + process.env = { + ...originalEnv, + OIDC_ISSUER: "https://issuer.example.com", + OIDC_CLIENT_ID: "test-client-id", + OIDC_CLIENT_SECRET: "test-client-secret", + BETTER_AUTH_SECRET: "test-secret", + BETTER_AUTH_URL: "http://localhost:3000", + NODE_ENV: "test", + }; + + const { initAuth, getAuth } = await reimportAuth(); + await initAuth(); + expect(getAuth()).toBeDefined(); + }); + + it("uses DB config and decrypts clientSecret when DB has enabled provider", async () => { + const dbConfig = { + id: "config-id", + providerId: "okta", + displayName: "Okta", + issuerUrl: "https://okta.example.com", + internalBaseUrl: null, + clientId: "okta-client-id", + clientSecret: "encrypted:okta-secret", + scopes: "openid profile email", + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + dbSelectResult = [dbConfig]; + + process.env = { + ...originalEnv, + BETTER_AUTH_SECRET: "test-secret", + BETTER_AUTH_URL: "http://localhost:3000", + NODE_ENV: "test", + }; + + const { initAuth, getAuth } = await reimportAuth(); + await initAuth(); + expect(getAuth()).toBeDefined(); + expect(mockDecryptSecret).toHaveBeenCalledWith("encrypted:okta-secret"); + }); + + it("throws when BETTER_AUTH_SECRET is missing and AUTH_DISABLED is not set", async () => { + process.env = { + ...originalEnv, + OIDC_ISSUER: "", + OIDC_CLIENT_ID: "", + OIDC_CLIENT_SECRET: "", + NODE_ENV: "test", + }; + delete process.env.BETTER_AUTH_SECRET; + delete process.env.AUTH_DISABLED; + + const { initAuth } = await reimportAuth(); + await expect(initAuth()).rejects.toThrow( + "[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled" + ); + }); + + it("builds placeholder auth when AUTH_DISABLED=true without throwing", async () => { + process.env = { + ...originalEnv, + AUTH_DISABLED: "true", + NODE_ENV: "test", + }; + delete process.env.BETTER_AUTH_SECRET; + + const { initAuth, getAuth } = await reimportAuth(); + await expect(initAuth()).resolves.toBeUndefined(); + expect(getAuth()).toBeDefined(); + }); +}); diff --git a/src/__tests__/authProvider.test.ts b/src/__tests__/authProvider.test.ts new file mode 100644 index 0000000..c09754d --- /dev/null +++ b/src/__tests__/authProvider.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { authProviderRouter } from "../routes/authProvider.js"; + +// ─── Mock auth module ───────────────────────────────────────────────────────── + +vi.mock("../lib/auth.js", () => ({ + reinitAuth: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface MockStaff { + id: string; + role: string; + isSuperUser: boolean; +} + +// ─── Mock DB state ──────────────────────────────────────────────────────────── + +let dbRows: Record[] = []; +let deletedRows: string[] = []; +let insertedRows: Record[] = []; +let encryptCalls: string[] = []; + +function resetMock() { + dbRows = []; + deletedRows = []; + insertedRows = []; + encryptCalls = []; +} + +// ─── Mock staff context ─────────────────────────────────────────────────────── + +const mockSuperUser: MockStaff = { id: "staff-1", role: "manager", isSuperUser: true }; +const mockManager: MockStaff = { id: "staff-2", role: "manager", isSuperUser: false }; +const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: false }; + +// ─── Mock db module ─────────────────────────────────────────────────────────── + +vi.mock("@groombook/db", () => { + const authProviderConfig = new Proxy( + { _name: "auth_provider_config" }, + { + get(_target, prop) { + if (prop === "_name") return "auth_provider_config"; + if (prop === "$inferSelect") return {}; + return { table: "auth_provider_config", column: prop }; + }, + } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [...dbRows], + [Symbol.iterator]: function* () { + for (const item of dbRows) yield item; + }, + 0: dbRows[0], + length: dbRows.length, + }), + }), + }), + insert: () => ({ + values: (vals: Record) => { + insertedRows.push(vals); + return { + returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }], + }; + }, + }), + delete: () => { + // Execute immediately - route doesn't chain .returning() + deletedRows.push("all"); + return Promise.resolve([]); + }, + transaction: (fn: (tx: { + delete: () => Promise; + insert: () => { values: (v: Record) => { returning: () => T[] } }; + }) => Promise) => { + const tx = { + delete: () => { deletedRows.push("all"); return Promise.resolve([]); }, + insert: () => ({ + values: (vals: Record) => ({ + returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }] as T[], + }), + }), + }; + return fn(tx); + }, + }), + authProviderConfig, + eq: (_col: unknown, _val: unknown) => ({ col: _col, val: _val }), + encryptSecret: (val: string) => { + encryptCalls.push(val); + return `encrypted:${val}`; + }, + }; +}); + +// ─── Build test app ─────────────────────────────────────────────────────────── + +function makeApp(staff: MockStaff | null) { + const app = new Hono(); + // Inject staff context + super user guard per route + // Must match both exact path and wildcard subpaths + app.use( + "/admin/auth-provider/*", + async (c, next) => { + if (!staff) { + return c.json({ error: "Forbidden: no staff record resolved" }, 403); + } + if (!staff.isSuperUser) { + return c.json({ error: "Forbidden: super user privileges required" }, 403); + } + (c as any).set("staff", staff); + await next(); + } + ); + app.route("/admin/auth-provider", authProviderRouter as unknown as Hono); + return app; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function get(app: T, path: string, staff: MockStaff | null) { + const res = await app.request(path, { method: "GET" }, { allCtx: { staff } as { staff: MockStaff } }); + return { status: res.status, body: await res.json() }; +} + +async function put(app: T, path: string, body: unknown, staff: MockStaff | null) { + const res = await app.request(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, { allCtx: { staff } as { staff: MockStaff } }); + return { status: res.status, body: await res.json() }; +} + +async function post(app: T, path: string, body: unknown, staff: MockStaff | null) { + const res = await app.request(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, { allCtx: { staff } as { staff: MockStaff } }); + return { status: res.status, body: await res.json() }; +} + +async function del(app: T, path: string, staff: MockStaff | null) { + const res = await app.request(path, { method: "DELETE" }, { allCtx: { staff } as { staff: MockStaff } }); + return { status: res.status, body: await res.json() }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET /admin/auth-provider", () => { + beforeEach(resetMock); + + it("returns 404 when no provider configured", async () => { + dbRows = []; + const app = makeApp(mockSuperUser); + const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser); + expect(status).toBe(404); + expect(body.error).toBe("No auth provider configured"); + }); + + it("returns config with secret redacted", async () => { + dbRows = [{ + id: "prov-1", + providerId: "authentik", + displayName: "Authentik", + issuerUrl: "https://auth.example.com", + internalBaseUrl: null, + clientId: "client-123", + clientSecret: "encrypted:secret", + scopes: "openid profile email", + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }]; + const app = makeApp(mockSuperUser); + const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser); + expect(status).toBe(200); + expect(body.clientSecret).toBe("••••••••"); + expect(body.providerId).toBe("authentik"); + }); + + it("returns 403 when not super user", async () => { + dbRows = []; + const app = makeApp(mockManager); + const { status } = await get(app, "/admin/auth-provider", mockManager); + expect(status).toBe(403); + }); +}); + +describe("PUT /admin/auth-provider", () => { + beforeEach(resetMock); + + it("stores encrypted secret", async () => { + const app = makeApp(mockSuperUser); + const { status, body } = await put(app, "/admin/auth-provider", { + providerId: "authentik", + displayName: "Authentik SSO", + issuerUrl: "https://auth.example.com", + clientId: "my-client", + clientSecret: "my-secret", + scopes: "openid profile email", + }, mockSuperUser); + expect(status).toBe(200); + expect(encryptCalls).toContain("my-secret"); + expect(body.clientSecret).toBe("••••••••"); + expect(body.providerId).toBe("authentik"); + }); + + it("returns 400 for invalid schema", async () => { + const app = makeApp(mockSuperUser); + const { status } = await put(app, "/admin/auth-provider", { + providerId: "", + issuerUrl: "not-a-url", + }, mockSuperUser); + expect(status).toBe(400); + }); +}); + +describe("POST /admin/auth-provider/test", () => { + beforeEach(resetMock); + + it("returns ok=false for unreachable issuer", async () => { + const app = makeApp(mockSuperUser); + const { status, body } = await post(app, "/admin/auth-provider/test", { + providerId: "authentik", + displayName: "Authentik", + issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable + clientId: "client", + scopes: "openid profile email", + }, mockSuperUser); + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.error).toBeTruthy(); + }, 15000); // timeout must exceed the 10s fetch timeout in the route handler + + it("returns 400 for missing clientSecret (not required for test)", async () => { + const app = makeApp(mockSuperUser); + const { status } = await post(app, "/admin/auth-provider/test", { + providerId: "authentik", + displayName: "Authentik", + issuerUrl: "https://auth.example.com", + clientId: "client", + }, mockSuperUser); + expect(status).toBe(200); // clientSecret omitted intentionally for test + }); +}); + +describe("DELETE /admin/auth-provider", () => { + beforeEach(resetMock); + + it("deletes all config rows", async () => { + const app = makeApp(mockSuperUser); + const { status, body } = await del(app, "/admin/auth-provider", mockSuperUser); + expect(status).toBe(200); + expect(body.ok).toBe(true); + expect(deletedRows).toContain("all"); + }); + + it("returns 403 when not super user", async () => { + const app = makeApp(mockGroomer); + const { status } = await del(app, "/admin/auth-provider", mockGroomer); + expect(status).toBe(403); + }); +}); diff --git a/src/__tests__/calendar.test.ts b/src/__tests__/calendar.test.ts new file mode 100644 index 0000000..7287d88 --- /dev/null +++ b/src/__tests__/calendar.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { generateIcalToken } from "../routes/calendar.js"; + +describe("generateIcalToken", () => { + it("generates a 64-character hex token", () => { + const token = generateIcalToken(); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[a-f0-9]+$/); + }); + + it("generates unique tokens", () => { + const token1 = generateIcalToken(); + const token2 = generateIcalToken(); + expect(token1).not.toBe(token2); + }); +}); \ No newline at end of file diff --git a/src/__tests__/clients.test.ts b/src/__tests__/clients.test.ts new file mode 100644 index 0000000..a1dd0ad --- /dev/null +++ b/src/__tests__/clients.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const ACTIVE_CLIENT = { + id: "client-uuid-1", + name: "Alice", + email: "alice@example.com", + phone: "555-1234", + address: "1 Main St", + notes: null, + status: "active", + disabledAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const DISABLED_CLIENT = { + ...ACTIVE_CLIENT, + id: "client-uuid-2", + name: "Bob", + status: "disabled", + disabledAt: new Date(), +}; + +// ─── Queue-based mock DB ────────────────────────────────────────────────────── + +let selectRows: Record[] = []; +let appointmentRows: Record[] = []; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; +let deletedId: string | null = null; + +function resetMock() { + selectRows = []; + appointmentRows = []; + insertedValues = []; + updatedValues = []; + deletedId = null; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => { + const tableName = (table as { _name?: string })._name; + const rows = tableName === "appointments" ? appointmentRows : selectRows; + return makeChainable(rows); + }, + }), + insert: () => ({ + values: (vals: Record) => { + insertedValues.push(vals); + return { + returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }], + }; + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { + returning: () => + selectRows.length > 0 + ? [{ ...selectRows[0], ...vals }] + : [], + }; + }, + }), + }), + delete: () => ({ + where: () => { + deletedId = "client-uuid-1"; + return { + returning: () => + selectRows.length > 0 ? [selectRows[0]] : [], + }; + }, + }), + }), + clients, + appointments, + eq: vi.fn(), + and: vi.fn(), + or: vi.fn(), + }; +}); + +// ─── App setup ──────────────────────────────────────────────────────────────── + +const { clientsRouter } = await import("../routes/clients.js"); + +const app = new Hono(); +app.route("/clients", clientsRouter); + +function jsonRequest(method: string, path: string, body?: unknown) { + return app.request(path, { + method, + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetMock()); + +// ─── GET / ──────────────────────────────────────────────────────────────────── + +describe("GET /clients", () => { + it("returns active clients", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await app.request("/clients"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(1); + }); + + it("returns all clients when includeDisabled=true", async () => { + selectRows = [ACTIVE_CLIENT, DISABLED_CLIENT]; + const res = await app.request("/clients?includeDisabled=true"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it("returns empty array when no clients exist", async () => { + selectRows = []; + const res = await app.request("/clients"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +// ─── GET /:id ───────────────────────────────────────────────────────────────── + +describe("GET /clients/:id", () => { + it("returns a single client", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await app.request("/clients/client-uuid-1"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe("client-uuid-1"); + expect(body.name).toBe("Alice"); + }); + + it("returns 404 for a nonexistent client", async () => { + selectRows = []; + const res = await app.request("/clients/nonexistent"); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/not found/i); + }); +}); + +// ─── POST / ─────────────────────────────────────────────────────────────────── + +describe("POST /clients", () => { + it("creates a client with valid data", async () => { + const res = await jsonRequest("POST", "/clients", { + name: "Charlie", + email: "charlie@example.com", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("Charlie"); + expect(insertedValues).toHaveLength(1); + expect(insertedValues[0]!.name).toBe("Charlie"); + }); + + it("creates a client with name and email", async () => { + const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" }); + expect(res.status).toBe(201); + expect(insertedValues[0]!.name).toBe("Dana"); + expect(insertedValues[0]!.email).toBe("dana@example.com"); + }); + + it("rejects empty name", async () => { + const res = await jsonRequest("POST", "/clients", { name: "" }); + expect(res.status).toBe(400); + }); + + it("rejects invalid email format", async () => { + const res = await jsonRequest("POST", "/clients", { + name: "Eve", + email: "not-an-email", + }); + expect(res.status).toBe(400); + }); + + it("rejects missing body", async () => { + const res = await app.request("/clients", { method: "POST" }); + expect(res.status).toBe(400); + }); +}); + +// ─── PATCH /:id ─────────────────────────────────────────────────────────────── + +describe("PATCH /clients/:id", () => { + it("updates client fields", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await jsonRequest("PATCH", "/clients/client-uuid-1", { + name: "Alice Updated", + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Alice Updated"); + expect(updatedValues[0]!.name).toBe("Alice Updated"); + }); + + it("sets disabledAt when status is set to disabled", async () => { + selectRows = [ACTIVE_CLIENT]; + await jsonRequest("PATCH", "/clients/client-uuid-1", { + status: "disabled", + }); + expect(updatedValues[0]!.status).toBe("disabled"); + expect(updatedValues[0]!.disabledAt).toBeDefined(); + }); + + it("clears disabledAt when re-enabling", async () => { + selectRows = [DISABLED_CLIENT]; + await jsonRequest("PATCH", "/clients/client-uuid-2", { + status: "active", + }); + expect(updatedValues[0]!.disabledAt).toBeNull(); + }); + + it("returns 404 when client not found", async () => { + selectRows = []; + const res = await jsonRequest("PATCH", "/clients/nonexistent", { + name: "Ghost", + }); + expect(res.status).toBe(404); + }); +}); + +// ─── DELETE /:id ────────────────────────────────────────────────────────────── + +describe("DELETE /clients/:id", () => { + it("requires ?confirm=true", async () => { + const res = await app.request("/clients/client-uuid-1", { + method: "DELETE", + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/confirm/i); + }); + + it("deletes a client with ?confirm=true", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await app.request("/clients/client-uuid-1?confirm=true", { + method: "DELETE", + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(deletedId).toBe("client-uuid-1"); + }); + + it("returns 404 when client not found", async () => { + selectRows = []; + const res = await app.request("/clients/nonexistent?confirm=true", { + method: "DELETE", + }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/__tests__/confirmation.test.ts b/src/__tests__/confirmation.test.ts new file mode 100644 index 0000000..aaa30c2 --- /dev/null +++ b/src/__tests__/confirmation.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock appointment data ──────────────────────────────────────────────────── + +const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now +const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago + +const BASE_APPT = { + id: "appt-uuid-1", + clientId: "client-uuid-1", + petId: "pet-uuid-1", + serviceId: "service-uuid-1", + staffId: "staff-uuid-1", + batherStaffId: null, + status: "scheduled" as const, + startTime: FUTURE_TIME, + endTime: new Date(FUTURE_TIME.getTime() + 3600_000), + notes: null, + priceCents: null, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "pending", + confirmedAt: null, + cancelledAt: null, + confirmationToken: "valid-token-abc123", + createdAt: new Date(), + updatedAt: new Date(), +}; + +// ─── Shared mock DB state ───────────────────────────────────────────────────── + +let mockAppt: typeof BASE_APPT | null = BASE_APPT; +let lastUpdate: Record = {}; + +function resetMock() { + mockAppt = { ...BASE_APPT }; + lastUpdate = {}; +} + +vi.mock("@groombook/db", () => { + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => (mockAppt ? [mockAppt] : []), + }), + }), + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + lastUpdate = { ...vals }; + if (mockAppt) { + mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT; + } + return { returning: () => (mockAppt ? [mockAppt] : []) }; + }, + }), + }), + }), + appointments, + eq: () => ({}), + and: (..._clauses: unknown[]) => ({}), + }; +}); + +// ─── Book router (tokenized endpoints) ─────────────────────────────────────── + +async function makeBookApp() { + const { bookRouter } = await import("../routes/book.js"); + const app = new Hono(); + app.route("/api/book", bookRouter); + return app; +} + +// ─── Appointments router (portal endpoints) ──────────────────────────────── + +async function makeAppointmentsApp() { + const { appointmentsRouter } = await import("../routes/appointments.js"); + const app = new Hono(); + app.route("/api/appointments", appointmentsRouter); + return app; +} + +// ─── Tests: tokenized confirm endpoint ──────────────────────────────────────── + +describe("GET /api/book/confirm/:token", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeBookApp(); + }); + + it("redirects to /booking/confirmed on valid token and future appointment", async () => { + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/confirmed"); + }); + + it("sets confirmationStatus to confirmed", async () => { + await app.request("/api/book/confirm/valid-token-abc123"); + expect(lastUpdate.confirmationStatus).toBe("confirmed"); + expect(lastUpdate.confirmedAt).toBeInstanceOf(Date); + }); + + it("redirects to /booking/error when token not found", async () => { + mockAppt = null; + const res = await app.request("/api/book/confirm/bad-token"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/error when appointment is in the past", async () => { + mockAppt = { ...BASE_APPT, startTime: PAST_TIME }; + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/confirmed idempotently when already confirmed", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" }; + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/confirmed"); + }); + + it("redirects to /booking/error when appointment is already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); +}); + +// ─── Tests: tokenized cancel endpoint ──────────────────────────────────────── + +describe("GET /api/book/cancel/:token", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeBookApp(); + }); + + it("redirects to /booking/cancelled on valid token and future appointment", async () => { + const res = await app.request("/api/book/cancel/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/cancelled"); + }); + + it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => { + await app.request("/api/book/cancel/valid-token-abc123"); + expect(lastUpdate.confirmationStatus).toBe("cancelled"); + expect(lastUpdate.cancelledAt).toBeInstanceOf(Date); + expect(lastUpdate.confirmationToken).toBeNull(); + }); + + it("redirects to /booking/error when token not found", async () => { + mockAppt = null; + const res = await app.request("/api/book/cancel/bad-token"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/error when appointment is in the past", async () => { + mockAppt = { ...BASE_APPT, startTime: PAST_TIME }; + const res = await app.request("/api/book/cancel/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/error when already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/book/cancel/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); +}); + +// ─── Tests: portal confirm endpoint ────────────────────────────────────────── + +describe("POST /api/appointments/:id/confirm", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeAppointmentsApp(); + }); + + it("confirms a pending appointment", async () => { + const res = await app.request("/api/appointments/appt-uuid-1/confirm", { + method: "POST", + }); + expect(res.status).toBe(200); + expect(lastUpdate.confirmationStatus).toBe("confirmed"); + expect(lastUpdate.confirmedAt).toBeInstanceOf(Date); + }); + + it("returns 404 when appointment not found", async () => { + mockAppt = null; + const res = await app.request("/api/appointments/nonexistent/confirm", { + method: "POST", + }); + expect(res.status).toBe(404); + }); + + it("returns 409 when appointment is already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/appointments/appt-uuid-1/confirm", { + method: "POST", + }); + expect(res.status).toBe(409); + }); + + it("returns 200 idempotently when appointment is already confirmed", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" }; + const res = await app.request("/api/appointments/appt-uuid-1/confirm", { + method: "POST", + }); + expect(res.status).toBe(200); + }); +}); + +// ─── Tests: portal cancel endpoint ─────────────────────────────────────────── + +describe("POST /api/appointments/:id/cancel", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeAppointmentsApp(); + }); + + it("cancels a pending appointment and nullifies the token", async () => { + const res = await app.request("/api/appointments/appt-uuid-1/cancel", { + method: "POST", + }); + expect(res.status).toBe(200); + expect(lastUpdate.confirmationStatus).toBe("cancelled"); + expect(lastUpdate.cancelledAt).toBeInstanceOf(Date); + expect(lastUpdate.confirmationToken).toBeNull(); + }); + + it("returns 404 when appointment not found", async () => { + mockAppt = null; + const res = await app.request("/api/appointments/nonexistent/cancel", { + method: "POST", + }); + expect(res.status).toBe(404); + }); + + it("returns 409 when appointment is already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/appointments/appt-uuid-1/cancel", { + method: "POST", + }); + expect(res.status).toBe(409); + }); + + it("can cancel a confirmed appointment", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" }; + const res = await app.request("/api/appointments/appt-uuid-1/cancel", { + method: "POST", + }); + expect(res.status).toBe(200); + expect(lastUpdate.confirmationStatus).toBe("cancelled"); + }); +}); + +// ─── Tests: token generation helper ────────────────────────────────────────── + +describe("generateConfirmationToken", () => { + it("generates a 64-character hex string", async () => { + const { generateConfirmationToken } = await import("../routes/appointments.js"); + const token = generateConfirmationToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("generates unique tokens on each call", async () => { + const { generateConfirmationToken } = await import("../routes/appointments.js"); + const t1 = generateConfirmationToken(); + const t2 = generateConfirmationToken(); + expect(t1).not.toBe(t2); + }); +}); + +// ─── Tests: reminder email with action links ────────────────────────────────── + +describe("buildReminderEmail with confirmation token", () => { + it("includes confirm and cancel links when token is provided", async () => { + const { buildReminderEmail } = await import("../services/email.js"); + const mail = buildReminderEmail( + "client@example.com", + { + clientName: "Jane", + petName: "Biscuit", + serviceName: "Full Groom", + groomerName: null, + startTime: new Date(), + }, + 24, + "abc123token" + ); + expect(mail.text).toContain("abc123token"); + expect(mail.html as string).toContain("abc123token"); + expect(mail.html as string).toContain("Confirm Appointment"); + expect(mail.html as string).toContain("Cancel Appointment"); + }); + + it("omits action links when no token is provided", async () => { + const { buildReminderEmail } = await import("../services/email.js"); + const mail = buildReminderEmail( + "client@example.com", + { + clientName: "Jane", + petName: "Biscuit", + serviceName: "Full Groom", + groomerName: null, + startTime: new Date(), + }, + 24, + null + ); + expect(mail.html as string).not.toContain("Confirm Appointment"); + expect(mail.html as string).not.toContain("Cancel Appointment"); + }); +}); diff --git a/src/__tests__/crypto.test.ts b/src/__tests__/crypto.test.ts new file mode 100644 index 0000000..2602264 --- /dev/null +++ b/src/__tests__/crypto.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { encryptSecret, decryptSecret } from "@groombook/db"; + +describe("encryptSecret / decryptSecret", () => { + const originalEnv = process.env.BETTER_AUTH_SECRET; + + beforeEach(() => { + process.env.BETTER_AUTH_SECRET = "test-secret-key-for-unit-tests-32bytes!"; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.BETTER_AUTH_SECRET = originalEnv; + } else { + delete process.env.BETTER_AUTH_SECRET; + } + }); + + it("encrypts and decrypts a simple secret", () => { + const plaintext = "my-client-secret-123"; + const encrypted = encryptSecret(plaintext); + const decrypted = decryptSecret(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + it("produces output in salt:iv:ciphertext:authTag format", () => { + const encrypted = encryptSecret("test"); + const parts = encrypted.split(":"); + + expect(parts).toHaveLength(4); + // Each part should be valid base64 + parts.forEach((part) => { + expect(() => Buffer.from(part, "base64")).not.toThrow(); + }); + }); + + it("different plaintexts produce different ciphertexts", () => { + const encrypted1 = encryptSecret("secret1"); + const encrypted2 = encryptSecret("secret2"); + + expect(encrypted1).not.toBe(encrypted2); + }); + + it("same plaintext produces different ciphertexts (due to random IV)", () => { + const encrypted1 = encryptSecret("same-secret"); + const encrypted2 = encryptSecret("same-secret"); + + expect(encrypted1).not.toBe(encrypted2); + // But both should decrypt to the same value + expect(decryptSecret(encrypted1)).toBe("same-secret"); + expect(decryptSecret(encrypted2)).toBe("same-secret"); + }); + + it("throws if BETTER_AUTH_SECRET is not set", () => { + delete process.env.BETTER_AUTH_SECRET; + + expect(() => encryptSecret("test")).toThrow( + "BETTER_AUTH_SECRET environment variable is required" + ); + }); + + it("throws when decrypting invalid format (wrong number of parts)", () => { + const encrypted = encryptSecret("test"); + // Replace the last two parts with a single part to create a 2-part string + // This can't be parsed as either legacy (3 parts) or new (4 parts) format + const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, ""); + + expect(() => decryptSecret(invalid)).toThrow( + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" + ); + }); + + it("handles empty string secret", () => { + const plaintext = ""; + const encrypted = encryptSecret(plaintext); + const decrypted = decryptSecret(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + it("handles unicode secret", () => { + const plaintext = "密码🔐中文"; + const encrypted = encryptSecret(plaintext); + const decrypted = decryptSecret(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + it("handles long secret", () => { + const plaintext = "a".repeat(10000); + const encrypted = encryptSecret(plaintext); + const decrypted = decryptSecret(encrypted); + + expect(decrypted).toBe(plaintext); + }); +}); diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts new file mode 100644 index 0000000..6ff56de --- /dev/null +++ b/src/__tests__/email.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { + buildConfirmationEmail, + buildReminderEmail, +} from "../services/email.js"; + +const START = new Date("2026-03-25T15:00:00Z"); + +const BASE = { + clientName: "Jane Doe", + petName: "Biscuit", + serviceName: "Full Groom", + groomerName: "Alex", + startTime: START, +}; + +describe("buildConfirmationEmail", () => { + it("addresses the correct recipient", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.to).toBe("jane@example.com"); + }); + + it("includes the pet name in the subject", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.subject).toContain("Biscuit"); + }); + + it("includes confirmation wording in subject", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.subject).toMatch(/confirmed/i); + }); + + it("includes client name in the plain text body", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.text).toContain("Jane Doe"); + }); + + it("includes service name in plain text body", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.text).toContain("Full Groom"); + }); + + it("includes groomer name when provided", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.text).toContain("Alex"); + }); + + it("omits groomer when groomerName is null", () => { + const mail = buildConfirmationEmail("jane@example.com", { + ...BASE, + groomerName: null, + }); + expect(mail.text).not.toContain("with "); + }); + + it("includes HTML body", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.html).toBeTruthy(); + expect(mail.html).toContain("Biscuit"); + }); +}); + +describe("buildReminderEmail", () => { + it("addresses the correct recipient", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.to).toBe("jane@example.com"); + }); + + it("says 'tomorrow' for 24-hour reminder", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.subject).toContain("tomorrow"); + expect(mail.text).toContain("tomorrow"); + }); + + it("says 'in X hours' for sub-24-hour reminders", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 2); + expect(mail.subject).toContain("in 2 hours"); + expect(mail.text).toContain("in 2 hours"); + }); + + it("includes pet name in subject", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.subject).toContain("Biscuit"); + }); + + it("includes service name in plain text body", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.text).toContain("Full Groom"); + }); + + it("includes groomer name when provided", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.text).toContain("Alex"); + }); + + it("omits groomer when groomerName is null", () => { + const mail = buildReminderEmail("jane@example.com", { ...BASE, groomerName: null }, 24); + expect(mail.text).not.toContain("with "); + }); + + it("includes HTML body", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.html).toBeTruthy(); + expect(mail.html).toContain("Biscuit"); + }); +}); diff --git a/src/__tests__/factories.test.ts b/src/__tests__/factories.test.ts new file mode 100644 index 0000000..bdb7fad --- /dev/null +++ b/src/__tests__/factories.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + resetFactoryCounters, + buildStaff, + buildClient, + buildPet, + buildService, + buildAppointment, +} from "@groombook/db/factories"; + +describe("resetFactoryCounters", () => { + it("resets all counters so IDs restart from 1", () => { + buildStaff(); + buildStaff(); + buildClient(); + resetFactoryCounters(); + + const staff = buildStaff(); + const client = buildClient(); + + expect(staff.id).toBe("staff-1"); + expect(client.id).toBe("client-1"); + }); + + it("resets counters for every entity type", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + resetFactoryCounters(); + + expect(buildStaff().id).toBe("staff-1"); + expect(buildClient().id).toBe("client-1"); + expect(buildService().id).toBe("service-1"); + const c = buildClient(); + expect(buildPet({ clientId: c.id }).id).toBe("pet-1"); + const s = buildService(); + const p = buildPet({ clientId: c.id }); + expect( + buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id + ).toBe("appointment-1"); + }); +}); + +describe("counter determinism", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("increments staff IDs sequentially", () => { + expect(buildStaff().id).toBe("staff-1"); + expect(buildStaff().id).toBe("staff-2"); + expect(buildStaff().id).toBe("staff-3"); + }); + + it("increments client IDs sequentially", () => { + expect(buildClient().id).toBe("client-1"); + expect(buildClient().id).toBe("client-2"); + }); + + it("increments pet IDs sequentially", () => { + const client = buildClient(); + expect(buildPet({ clientId: client.id }).id).toBe("pet-1"); + expect(buildPet({ clientId: client.id }).id).toBe("pet-2"); + }); + + it("increments service IDs sequentially", () => { + expect(buildService().id).toBe("service-1"); + expect(buildService().id).toBe("service-2"); + }); + + it("increments appointment IDs sequentially", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" }; + + expect(buildAppointment(required).id).toBe("appointment-1"); + expect(buildAppointment(required).id).toBe("appointment-2"); + }); + + it("each entity type maintains its own independent counter", () => { + buildStaff(); + buildStaff(); + buildClient(); + + // staff counter is at 2; client counter is at 1 + expect(buildStaff().id).toBe("staff-3"); + expect(buildClient().id).toBe("client-2"); + }); +}); + +describe("override merging", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("buildStaff applies overrides over defaults", () => { + const staff = buildStaff({ role: "manager", name: "Boss" }); + + expect(staff.role).toBe("manager"); + expect(staff.name).toBe("Boss"); + expect(staff.id).toBe("staff-1"); + expect(staff.active).toBe(true); // default preserved + }); + + it("buildStaff id override is respected without disrupting the counter", () => { + const staff = buildStaff({ id: "custom-id" }); + + expect(staff.id).toBe("custom-id"); + // counter still ticked — next call gets staff-2 + expect(buildStaff().id).toBe("staff-2"); + }); + + it("buildClient applies overrides over defaults", () => { + const client = buildClient({ name: "Alice Smith", emailOptOut: true }); + + expect(client.name).toBe("Alice Smith"); + expect(client.emailOptOut).toBe(true); + expect(client.status).toBe("active"); // default preserved + }); + + it("buildPet merges overrides and sets clientId from required arg", () => { + const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" }); + + expect(pet.clientId).toBe("client-99"); + expect(pet.name).toBe("Fluffy"); + expect(pet.breed).toBe("Poodle"); + expect(pet.species).toBe("Dog"); // default preserved + }); + + it("buildService applies overrides over defaults", () => { + const service = buildService({ basePriceCents: 9900, active: false }); + + expect(service.basePriceCents).toBe(9900); + expect(service.active).toBe(false); + expect(service.durationMinutes).toBe(60); // default preserved + }); + + it("buildAppointment applies overrides over defaults", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + status: "confirmed", + notes: "allergic to lavender", + }); + + expect(appt.status).toBe("confirmed"); + expect(appt.notes).toBe("allergic to lavender"); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + // defaults preserved + expect(appt.batherStaffId).toBeNull(); + expect(appt.priceCents).toBeNull(); + }); +}); + +describe("buildAppointment required fields", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("produces a fully-populated AppointmentRow", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + expect(appt.id).toBeDefined(); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + expect(appt.serviceId).toBe(service.id); + expect(appt.staffId).toBe("staff-1"); + expect(appt.startTime).toBeInstanceOf(Date); + expect(appt.endTime).toBeInstanceOf(Date); + expect(appt.status).toBe("scheduled"); + expect(appt.batherStaffId).toBeNull(); + expect(appt.seriesId).toBeNull(); + expect(appt.seriesIndex).toBeNull(); + expect(appt.groupId).toBeNull(); + expect(appt.notes).toBeNull(); + expect(appt.priceCents).toBeNull(); + expect(appt.createdAt).toBeInstanceOf(Date); + expect(appt.updatedAt).toBeInstanceOf(Date); + }); + + // TypeScript compile-time enforcement: omitting any required field produces a type error. + // The overrides parameter type is `Partial & { clientId: string; petId: string; serviceId: string; staffId: string }`. + // The test below verifies the type signature is correct by using @ts-expect-error. + it("type error when required fields are missing — compile-time enforcement", () => { + // @ts-expect-error clientId is required + buildAppointment({ petId: "p", serviceId: "s", staffId: "st" }); + // @ts-expect-error petId is required + buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" }); + // @ts-expect-error serviceId is required + buildAppointment({ clientId: "c", petId: "p", staffId: "st" }); + // @ts-expect-error staffId is required + buildAppointment({ clientId: "c", petId: "p", serviceId: "s" }); + }); +}); diff --git a/src/__tests__/groomerIsolation.test.ts b/src/__tests__/groomerIsolation.test.ts new file mode 100644 index 0000000..9f0838e --- /dev/null +++ b/src/__tests__/groomerIsolation.test.ts @@ -0,0 +1,106 @@ +/** + * Groomer Isolation Tests + * + * Validates row-level data scoping for the groomer role. + * + * The role guard tests verify the core groomer identification logic. + * Integration tests with the real database validate the full filter behavior. + */ + +import { describe, it, expect } from "vitest"; +import type { StaffRow } from "../middleware/rbac.js"; + +// ─── Mock staff ─────────────────────────────────────────────────────────────── + +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 = { + ...MANAGER, + id: "staff-groomer-id", + oidcSub: "oidc-groomer-sub", + role: "groomer", + name: "Groomer Gary", + email: "groomer@example.com", +}; + +const RECEPTIONIST: StaffRow = { + ...MANAGER, + id: "staff-receptionist-id", + oidcSub: "oidc-receptionist-sub", + role: "receptionist", + name: "Receptionist Rita", + email: "receptionist@example.com", +}; + +// ─── Role guard ────────────────────────────────────────────────────────────── + +/** + * The isGroomer guard (staffRow?.role === "groomer") is the foundation of + * all row-level filtering in appointments.ts, clients.ts, and pets.ts. + * These tests verify it handles all roles correctly. + */ +describe("Groomer role guard", () => { + const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer"; + + it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false)); + it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false)); + it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true)); + + /** Safe fallback when staff context is not set (e.g., missing auth middleware) */ + it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false)); +}); + +// ─── Groomer filter data shapes ─────────────────────────────────────────────── + +/** + * These constants match the shape used in route handlers to validate + * the groomer filter conditions: + * or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id)) + * This verifies the groomer can see appointments they own OR bathe. + */ +describe("Groomer appointment filter data", () => { + const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null }; + const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id }; + const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null }; + + it("groomer appointment has groomer staffId", () => { + expect(GROOMER_APPT.staffId).toBe(GROOMER.id); + expect(GROOMER_APPT.batherStaffId).toBeNull(); + }); + + it("groomer can see appointment where they are the bather", () => { + expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id); + expect(BATHER_APPT.staffId).toBe(MANAGER.id); + }); + + it("other appointment is not assigned to groomer", () => { + expect(OTHER_APPT.staffId).toBe(MANAGER.id); + expect(OTHER_APPT.batherStaffId).toBeNull(); + }); + + it("filter: groomer sees only their appointments", () => { + const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT]; + const groomerView = all.filter( + (a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id + ); + expect(groomerView).toHaveLength(2); + expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]); + }); + + it("filter: manager sees all appointments", () => { + const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT]; + expect(all).toHaveLength(3); + }); +}); diff --git a/src/__tests__/impersonation.test.ts b/src/__tests__/impersonation.test.ts new file mode 100644 index 0000000..de7688d --- /dev/null +++ b/src/__tests__/impersonation.test.ts @@ -0,0 +1,560 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { buildStaff } from "@groombook/db/factories"; + +// ─── Mock data (built with factories for schema-safe defaults) ──────────────── + +const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" }); +const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" }); + +const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" }; + +const futureDate = () => new Date(Date.now() + 30 * 60_000); +const pastDate = () => new Date(Date.now() - 5 * 60_000); + +function makeSession(overrides: Record = {}) { + return { + id: "session-uuid-1", + staffId: MANAGER_STAFF.id, + clientId: CLIENT.id, + reason: "Testing portal", + status: "active" as string, + startedAt: new Date(), + endedAt: null as Date | null, + expiresAt: futureDate(), + createdAt: new Date(), + ...overrides, + }; +} + +function makeAuditLog(overrides: Record = {}) { + return { + id: "audit-uuid-1", + sessionId: "session-uuid-1", + action: "session_started", + pageVisited: null, + metadata: null, + createdAt: new Date(), + ...overrides, + }; +} + +// ─── Queue-based mock DB ───────────────────────────────────────────────────── + +let selectQueue: unknown[][] = []; +let insertedValues: Array<{ table: string; vals: unknown }> = []; +let updatedValues: Array<{ table: string; set: Record }> = []; + +function resetMock() { + selectQueue = []; + insertedValues = []; + updatedValues = []; +} + +/** + * Returns a chainable object that acts like a drizzle query result. + * Any method call (.where, .orderBy, .limit) returns the same chainable, + * but the FIRST terminal call (.where or .orderBy when no further chain) + * resolves the result from the queue. + * + * To handle `.where().orderBy()` chaining, we make the result of shifting + * also have .orderBy/.limit methods, and we wrap the shifted array in a proxy. + */ +function makeChainableResult(data: unknown[]): unknown { + // Make data act both as array and as chainable + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "orderBy" || prop === "limit") { + // Further chaining just returns the same data + return () => makeChainableResult(data); + } + // @ts-expect-error proxy access + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + function makeTable(name: string) { + return new Proxy( + { _name: name }, + { + get(target, prop) { + if (prop === "_name") return name; + if (prop === "$inferSelect") return {}; + return { table: name, column: prop }; + }, + } + ); + } + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => { + const data = selectQueue.shift() ?? []; + return makeChainableResult(data); + }, + orderBy: () => { + const data = selectQueue.shift() ?? []; + return makeChainableResult(data); + }, + limit: () => { + const data = selectQueue.shift() ?? []; + return makeChainableResult(data); + }, + }), + }), + insert: (table: { _name: string }) => ({ + values: (vals: unknown) => { + const tableName = table?._name ?? "unknown"; + insertedValues.push({ table: tableName, vals }); + return { + returning: () => { + if (tableName === "sessions") { + return [makeSession(vals as Record)]; + } + return [makeAuditLog(vals as Record)]; + }, + }; + }, + }), + update: (table: { _name: string }) => ({ + set: (data: Record) => ({ + where: () => { + const tableName = table?._name ?? "unknown"; + updatedValues.push({ table: tableName, set: data }); + return { + returning: () => { + const base = makeSession(); + return [{ ...base, ...data }]; + }, + }; + }, + }), + }), + }), + staff: makeTable("staff"), + clients: makeTable("clients"), + impersonationSessions: makeTable("sessions"), + impersonationAuditLogs: makeTable("auditLogs"), + eq: vi.fn(), + and: vi.fn(), + desc: vi.fn(), + }; +}); + +// ─── App setup ─────────────────────────────────────────────────────────────── + +const { impersonationRouter } = await import("../routes/impersonation.js"); +const { requireRole } = await import("../middleware/rbac.js"); + +/** + * Build a test app. If staffRow is null the middleware simulates + * resolveStaffMiddleware returning 403 (staff not found). An optional + * roleGuard applies requireRole(...roles) before the router. + */ +function createApp( + staffRow: (typeof MANAGER_STAFF) | null, + roleGuard?: string[] +) { + const app = new Hono(); + app.use("*", async (c, next) => { + if (!staffRow) { + return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403); + } + c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string }); + c.set("staff", staffRow as unknown as StaffRow); + await next(); + }); + if (roleGuard && roleGuard.length > 0) { + app.use("*", requireRole(...(roleGuard as Parameters)) as never); + } + app.route("/impersonation", impersonationRouter); + return app; +} + +function jsonPost(path: string, body: unknown) { + return { + method: "POST" as const, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +beforeEach(() => resetMock()); + +// ─── POST /sessions — Create session ───────────────────────────────────────── + +describe("POST /impersonation/sessions", () => { + it("creates a session for a manager", async () => { + const app = createApp(MANAGER_STAFF, ["manager"]); + selectQueue.push( + [CLIENT], // client lookup + [], // expireTimedOutSessions active query + [] // existing active check + ); + + const res = await app.request( + "/impersonation/sessions", + jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) + ); + + expect(res.status).toBe(201); + expect(insertedValues.some((v) => v.table === "sessions")).toBe(true); + expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true); + }); + + it("rejects non-managers via requireRole guard", async () => { + const app = createApp(GROOMER_STAFF, ["manager"]); + + const res = await app.request( + "/impersonation/sessions", + jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) + ); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/forbidden/i); + }); + + it("returns 403 when staff record not found", async () => { + const app = createApp(null); + + const res = await app.request( + "/impersonation/sessions", + jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) + ); + + expect(res.status).toBe(403); + }); + + it("returns 404 when client not found", async () => { + const app = createApp(MANAGER_STAFF, ["manager"]); + selectQueue.push( + [] // client not found + ); + + const res = await app.request( + "/impersonation/sessions", + jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) + ); + + expect(res.status).toBe(404); + }); + + it("returns 409 when active session already exists", async () => { + const app = createApp(MANAGER_STAFF, ["manager"]); + const existing = makeSession(); + selectQueue.push( + [CLIENT], // client lookup + [], // expireTimedOutSessions + [existing] // existing active session + ); + + const res = await app.request( + "/impersonation/sessions", + jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) + ); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already have an active/i); + }); +}); + +// ─── GET /sessions/:id — Authorization ─────────────────────────────────────── + +describe("GET /impersonation/sessions/:id", () => { + it("returns session for the owning staff member", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] // session lookup + ); + + const res = await app.request("/impersonation/sessions/session-uuid-1"); + expect(res.status).toBe(200); + }); + + it("returns 403 for a different staff member", async () => { + const app = createApp(GROOMER_STAFF); + const session = makeSession(); // owned by manager + selectQueue.push( + [session] // session lookup + ); + + const res = await app.request("/impersonation/sessions/session-uuid-1"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/not your session/i); + }); + + it("returns 404 for nonexistent session", async () => { + const app = createApp(MANAGER_STAFF); + selectQueue.push( + [] // no session + ); + + const res = await app.request("/impersonation/sessions/nonexistent"); + expect(res.status).toBe(404); + }); + + it("auto-expires a timed-out session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [session] // session lookup + ); + + const res = await app.request("/impersonation/sessions/session-uuid-1"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("expired"); + // Should have called update to mark expired + expect(updatedValues).toHaveLength(1); + expect(updatedValues[0]!.set.status).toBe("expired"); + }); +}); + +// ─── POST /sessions/:id/extend ─────────────────────────────────────────────── + +describe("POST /impersonation/sessions/:id/extend", () => { + it("extends an active non-expired session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] // session lookup + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/extend", + { method: "POST" } + ); + expect(res.status).toBe(200); + // Should have extended (updated expiresAt) and logged + expect(updatedValues).toHaveLength(1); + expect(insertedValues.some((v) => { + const vals = v.vals as Record; + return vals.action === "session_extended"; + })).toBe(true); + }); + + it("returns 400 when extending a time-expired session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [session] // session lookup + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/extend", + { method: "POST" } + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/expired/i); + }); + + it("returns 403 for non-owner", async () => { + const app = createApp(GROOMER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] // owned by manager + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/extend", + { method: "POST" } + ); + expect(res.status).toBe(403); + }); + + it("returns 400 for an ended session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession({ status: "ended" }); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/extend", + { method: "POST" } + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/not active/i); + }); +}); + +// ─── POST /sessions/:id/end ────────────────────────────────────────────────── + +describe("POST /impersonation/sessions/:id/end", () => { + it("ends an active non-expired session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/end", + { method: "POST" } + ); + expect(res.status).toBe(200); + expect(updatedValues).toHaveLength(1); + expect(updatedValues[0]!.set.status).toBe("ended"); + }); + + it("returns 400 when ending a time-expired session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/end", + { method: "POST" } + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/expired/i); + }); + + it("returns 403 for non-owner", async () => { + const app = createApp(GROOMER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/end", + { method: "POST" } + ); + expect(res.status).toBe(403); + }); +}); + +// ─── POST /sessions/:id/log — Authorization + expiry ───────────────────────── + +describe("POST /impersonation/sessions/:id/log", () => { + const logBody = { action: "page_visit", pageVisited: "/dashboard" }; + + it("logs an audit entry for the session owner", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/log", + jsonPost("/", logBody) + ); + expect(res.status).toBe(201); + expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true); + }); + + it("returns 403 for non-owner", async () => { + const app = createApp(GROOMER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/log", + jsonPost("/", logBody) + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/not your session/i); + }); + + it("returns 400 when session has expired by time", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/log", + jsonPost("/", logBody) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/expired/i); + }); + + it("returns 400 for an ended session", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession({ status: "ended" }); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/log", + jsonPost("/", logBody) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/not active/i); + }); +}); + +// ─── GET /sessions/:id/audit-log — Authorization ──────────────────────────── + +describe("GET /impersonation/sessions/:id/audit-log", () => { + it("returns audit logs for the session owner", async () => { + const app = createApp(MANAGER_STAFF); + const session = makeSession(); + const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })]; + selectQueue.push( + [session], // session lookup + logs // audit logs query (where + orderBy chain) + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/audit-log" + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it("returns 403 for non-owner", async () => { + const app = createApp(GROOMER_STAFF); + const session = makeSession(); + selectQueue.push( + [session] + ); + + const res = await app.request( + "/impersonation/sessions/session-uuid-1/audit-log" + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/not your session/i); + }); + + it("returns 404 for nonexistent session", async () => { + const app = createApp(MANAGER_STAFF); + selectQueue.push( + [] + ); + + const res = await app.request( + "/impersonation/sessions/nonexistent/audit-log" + ); + expect(res.status).toBe(404); + }); +}); diff --git a/src/__tests__/petPhotos.test.ts b/src/__tests__/petPhotos.test.ts new file mode 100644 index 0000000..29f22c9 --- /dev/null +++ b/src/__tests__/petPhotos.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.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 = { + ...MANAGER, + id: "staff-groomer-id", + oidcSub: "oidc-groomer-sub", + role: "groomer", + name: "Groomer Gary", + email: "groomer@example.com", +}; + +// ─── Shared mutable DB state ────────────────────────────────────────────────── + +const PET_ID = "pet-uuid-1234"; +const PHOTO_KEY = `pets/${PET_ID}/1700000000000.jpg`; + +let dbPetRow: Record | null; + +function resetDb() { + dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null }; +} + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("@groombook/db", () => { + const pets = new Proxy( + { _name: "pets" }, + { get(t, p) { return p === "_name" ? "pets" : {}; } } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => (dbPetRow ? [dbPetRow] : []), + }), + }), + update: () => ({ + set: () => ({ + where: () => ({ + returning: () => (dbPetRow ? [{ ...dbPetRow }] : []), + }), + }), + }), + }), + pets, + eq: vi.fn(), + }; +}); + +vi.mock("../lib/s3.js", () => ({ + getPresignedUploadUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-put"), + getPresignedGetUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-get"), + deleteObject: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Import after mocks are set up ─────────────────────────────────────────── + +const { petsRouter } = await import("../routes/pets.js"); + +// ─── App builder ───────────────────────────────────────────────────────────── + +function buildApp(staffRow: StaffRow) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" }); + c.set("staff", staffRow); + await next(); + }); + app.route("/pets", petsRouter); + return app; +} + +// ─── Reset before each test ─────────────────────────────────────────────────── + +beforeEach(() => { + resetDb(); + vi.clearAllMocks(); +}); + +// ─── POST /:petId/photo/upload-url ─────────────────────────────────────────── + +describe("POST /pets/:petId/photo/upload-url", () => { + it("returns presigned upload URL and object key for valid image contentType", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { uploadUrl: string; key: string }; + expect(body.uploadUrl).toBe("https://storage.example.com/presigned-put"); + expect(body.key).toMatch(/^pets\//); + expect(body.key).toContain(PET_ID); + }); + + it("rejects non-image contentType with 400", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "application/pdf", fileSizeBytes: 1024 }), + }); + expect(res.status).toBe(400); + }); + + it("rejects image/svg+xml with 400 (allowlist enforcement)", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "image/svg+xml", fileSizeBytes: 1024 }), + }); + expect(res.status).toBe(400); + }); + + it("rejects fileSizeBytes over 5 MB with 400", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 6 * 1024 * 1024 }), + }); + expect(res.status).toBe(400); + }); + + it("returns 404 when pet does not exist", async () => { + dbPetRow = null; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }), + }); + expect(res.status).toBe(404); + }); + + it("allows groomers to request an upload URL", async () => { + const app = buildApp(GROOMER); + const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "image/png", fileSizeBytes: 1024 }), + }); + expect(res.status).toBe(200); + }); +}); + +// ─── POST /:petId/photo/confirm ─────────────────────────────────────────────── + +describe("POST /pets/:petId/photo/confirm", () => { + it("confirms upload and returns ok: true", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: PHOTO_KEY }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 400 when key is missing", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 404 when pet does not exist", async () => { + dbPetRow = null; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: PHOTO_KEY }), + }); + expect(res.status).toBe(404); + }); + + it("returns 400 when key does not belong to the pet", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: "pets/other-pet-id/1700000000000.jpg" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/invalid key/i); + }); + + it("deletes old photo from storage when re-uploading", async () => { + const { deleteObject } = await import("../lib/s3.js"); + const oldKey = `pets/${PET_ID}/old.jpg`; + dbPetRow = { ...dbPetRow!, photoKey: oldKey }; + + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: PHOTO_KEY }), + }); + + expect(res.status).toBe(200); + expect(deleteObject).toHaveBeenCalledWith(oldKey); + }); +}); + +// ─── DELETE /:petId/photo ──────────────────────────────────────────────────── + +describe("DELETE /pets/:petId/photo", () => { + it("returns 404 with 'no photo' message when pet has no photo", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" }); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/no photo/i); + }); + + it("deletes photo and returns ok: true when photo exists", async () => { + dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY }; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 404 when pet does not exist", async () => { + dbPetRow = null; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" }); + expect(res.status).toBe(404); + }); +}); + +// ─── GET /:petId/photo ──────────────────────────────────────────────────────── + +describe("GET /pets/:petId/photo", () => { + it("returns 404 when pet has no photo", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo`); + expect(res.status).toBe(404); + }); + + it("returns presigned GET URL when photo exists", async () => { + dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY }; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo`); + expect(res.status).toBe(200); + const body = (await res.json()) as { url: string; photoKey: string }; + expect(body.url).toBe("https://storage.example.com/presigned-get"); + expect(body.photoKey).toBe(PHOTO_KEY); + }); + + it("returns 404 when pet does not exist", async () => { + dbPetRow = null; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/photo`); + expect(res.status).toBe(404); + }); + + it("groomer can read photo URL", async () => { + dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY }; + const app = buildApp(GROOMER); + const res = await app.request(`/pets/${PET_ID}/photo`); + expect(res.status).toBe(200); + }); +}); diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts new file mode 100644 index 0000000..73f05ff --- /dev/null +++ b/src/__tests__/portal.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); +const pastDate = () => new Date(Date.now() - 5 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: futureDate(), + createdAt: new Date(), +}; + +const EXPIRED_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: pastDate(), + createdAt: new Date(), +}; + +const APPOINTMENT = { + id: APPOINTMENT_ID, + clientId: CLIENT_ID, + startTime: futureDate(), + endTime: futureDate(), + customerNotes: null, + confirmationToken: "secret-token-leak-test", + status: "scheduled" as const, + confirmationStatus: "pending" as const, + confirmedAt: null, + cancelledAt: null, +}; + +let selectSessionRow: Record | null = null; +let selectAppointmentRow: Record | null = null; +let updatedValues: Record[] = []; + +function resetMock() { + selectSessionRow = null; + selectAppointmentRow = null; + updatedValues = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "appointments") { + return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + if (selectAppointmentRow) { + const updated = { ...selectAppointmentRow, ...vals }; + updatedValues.push(vals); + return [updated]; + } + return []; + }, + }), + }), + }), + }), + impersonationSessions, + appointments, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function jsonPatch(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => resetMock()); + +describe("PATCH /portal/appointments/:id/notes", () => { + it("returns updated appointment with safe fields only", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Please be gentle with Fido" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("id"); + expect(body).toHaveProperty("customerNotes", "Please be gentle with Fido"); + expect(body).toHaveProperty("updatedAt"); + expect(body).not.toHaveProperty("confirmationToken"); + expect(body).not.toHaveProperty("clientId"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 with ended session", async () => { + selectSessionRow = null; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 403 when appointment belongs to different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe("Forbidden"); + }); + + it("returns 422 for past appointment", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/past|in-progress|cannot edit/i); + }); + + it("returns 422 when appointment is in progress", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: new Date(Date.now() - 2 * 60 * 1000) }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 404 when appointment not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = null; + const res = await jsonPatch( + `/portal/appointments/nonexistent-id/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); + + it("accepts notes at exactly 500 characters", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + const longNote = "a".repeat(500); + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: longNote }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.customerNotes).toBe(longNote); + }); + + it("rejects notes exceeding 500 characters", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + const longNote = "a".repeat(501); + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: longNote }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(400); + }); +}); + +// ─── POST /portal/appointments/:id/confirm ──────────────────────────────────── + +function jsonPost(path: string, headers?: Record) { + return app.request(path, { + method: "POST", + headers, + }); +} + +describe("POST /portal/appointments/:id/confirm", () => { + it("confirms a pending appointment and returns updated status", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.confirmationStatus).toBe("confirmed"); + expect(body).toHaveProperty("confirmedAt"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/confirm`); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when appointment belongs to a different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + }); + + it("returns 422 when appointment is in the past", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when appointment is not pending confirmation", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "confirmed" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when cancelling an already-cancelled appointment", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 404 when appointment not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = null; + const res = await jsonPost( + `/portal/appointments/nonexistent-id/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); +}); + +// ─── POST /portal/appointments/:id/cancel ───────────────────────────────────── + +describe("POST /portal/appointments/:id/cancel", () => { + it("cancels a pending appointment and returns updated status", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("cancelled"); + expect(body.confirmationStatus).toBe("cancelled"); + expect(body).toHaveProperty("cancelledAt"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/cancel`); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when appointment belongs to a different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + }); + + it("returns 422 when appointment is in the past", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when appointment is already cancelled", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when appointment is already completed", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, status: "completed" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 404 when appointment not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = null; + const res = await jsonPost( + `/portal/appointments/nonexistent-id/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); +}); \ No newline at end of file diff --git a/src/__tests__/rbac.test.ts b/src/__tests__/rbac.test.ts new file mode 100644 index 0000000..f975316 --- /dev/null +++ b/src/__tests__/rbac.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; + +// ─── Mock staff data ────────────────────────────────────────────────────────── + +const MANAGER: StaffRow = { + id: "staff-manager-id", + oidcSub: "oidc-manager-sub", + userId: "ba-user-manager", + role: "manager", + isSuperUser: true, + name: "Manager McManager", + email: "manager@example.com", + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const RECEPTIONIST: StaffRow = { + ...MANAGER, + id: "staff-receptionist-id", + oidcSub: "oidc-receptionist-sub", + userId: "ba-user-receptionist", + role: "receptionist", + isSuperUser: false, + name: "Receptionist Rita", + email: "receptionist@example.com", +}; + +const GROOMER: StaffRow = { + ...MANAGER, + id: "staff-groomer-id", + oidcSub: "oidc-groomer-sub", + userId: "ba-user-groomer", + role: "groomer", + isSuperUser: false, + name: "Groomer Gary", + email: "groomer@example.com", +}; + +// ─── Mock DB ────────────────────────────────────────────────────────────────── + +let staffLookupResult: StaffRow | null = null; +let managerFallbackResult: StaffRow | null = MANAGER; + +vi.mock("@groombook/db", () => { + const staff = new Proxy( + { _name: "staff" }, + { + get(target, prop) { + if (prop === "_name") return "staff"; + if (prop === "$inferSelect") return {}; + return { table: "staff", column: prop }; + }, + } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => { + // dev mode fallback to first manager + return managerFallbackResult ? [managerFallbackResult] : []; + }, + [Symbol.iterator]: function* () { + if (staffLookupResult) yield staffLookupResult; + }, + 0: staffLookupResult, + length: staffLookupResult ? 1 : 0, + }), + }), + }), + }), + staff, + eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), + and: vi.fn((..._clauses: unknown[]) => ({})), + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function resetMocks() { + staffLookupResult = null; + managerFallbackResult = MANAGER; +} + +/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */ +function buildApp( + middleware: MiddlewareHandler, + handler?: (c: Context) => Response | Promise +) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" }); + await next(); + }); + app.use("*", middleware); + const h = handler ?? ((c: Context) => c.json({ ok: true })); + app.get("/test", h); + app.post("/test", h); + return app; +} + +/** Build app with staff pre-set in context (skips resolveStaffMiddleware). */ +function buildWithStaff( + staffRow: StaffRow, + guard: MiddlewareHandler +) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("jwtPayload", { sub: staffRow.userId ?? "" }); + c.set("staff", staffRow); + await next(); + }); + app.use("*", guard); + app.get("/test", (c) => c.json({ ok: true })); + app.post("/test", (c) => c.json({ ok: true })); + return app; +} + +// ─── Import middleware ──────────────────────────────────────────────────────── + +const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import( + "../middleware/rbac.js" +); + +beforeEach(() => resetMocks()); + +afterEach(() => { + delete process.env.AUTH_DISABLED; +}); + +// ─── resolveStaffMiddleware tests ───────────────────────────────────────────── + +describe("resolveStaffMiddleware", () => { + it("resolves staff from DB and sets it on context", async () => { + staffLookupResult = MANAGER; + let capturedStaff: StaffRow | null = null; + const app = buildApp(resolveStaffMiddleware, (c) => { + capturedStaff = c.get("staff"); + return c.json({ ok: true }); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(200); + expect(capturedStaff).not.toBeNull(); + expect(capturedStaff!.id).toBe(MANAGER.id); + }); + + it("returns 403 when no staff record found for the OIDC sub", async () => { + staffLookupResult = null; + const app = buildApp(resolveStaffMiddleware); + + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/no staff record/i); + }); + + it("dev mode: resolves staff by X-Dev-User-Id header", async () => { + process.env.AUTH_DISABLED = "true"; + staffLookupResult = GROOMER; + let capturedStaff: StaffRow | null = null; + const app = buildApp(resolveStaffMiddleware, (c) => { + capturedStaff = c.get("staff"); + return c.json({ ok: true }); + }); + + const res = await app.request("/test", { + headers: { "X-Dev-User-Id": GROOMER.id }, + }); + expect(res.status).toBe(200); + expect(capturedStaff!.role).toBe("groomer"); + }); + + it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => { + process.env.AUTH_DISABLED = "true"; + managerFallbackResult = MANAGER; + let capturedStaff: StaffRow | null = null; + const app = buildApp(resolveStaffMiddleware, (c) => { + capturedStaff = c.get("staff"); + return c.json({ ok: true }); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(200); + expect(capturedStaff!.role).toBe("manager"); + }); + + it("dev mode: returns 403 when no manager exists and no header provided", async () => { + process.env.AUTH_DISABLED = "true"; + managerFallbackResult = null; + const app = buildApp(resolveStaffMiddleware); + + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/no staff records found/i); + }); +}); + +// ─── requireRole tests ──────────────────────────────────────────────────────── + +describe("requireRole", () => { + it("allows access when staff role matches the only allowed role", async () => { + const app = buildWithStaff(MANAGER, requireRole("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows access when staff role is one of multiple allowed roles", async () => { + const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("returns 403 for an unauthorized role", async () => { + const app = buildWithStaff(GROOMER, requireRole("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/forbidden/i); + expect(body.error).toContain("groomer"); + }); + + it("includes the role name in the 403 error message", async () => { + const app = buildWithStaff(RECEPTIONIST, requireRole("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toContain("receptionist"); + }); + + it("groomer is blocked from manager+receptionist-only routes", async () => { + const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist")); + const res = await app.request("/test", { method: "POST" }); + expect(res.status).toBe(403); + }); + + it("manager passes all-role checks", async () => { + const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("returns 403 with JSON body (not plain text)", async () => { + const app = buildWithStaff(GROOMER, requireRole("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const contentType = res.headers.get("content-type") ?? ""; + expect(contentType).toContain("application/json"); + }); +}); + +// ─── requireSuperUser tests ───────────────────────────────────────────────── + +describe("requireSuperUser", () => { + it("allows access when staff is a super user", async () => { + const app = buildWithStaff(MANAGER, requireSuperUser()); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows access when manager is also a super user", async () => { + // MANAGER has isSuperUser: true + const app = buildWithStaff(MANAGER, requireSuperUser()); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("returns 403 for a non-super-user receptionist", async () => { + // RECEPTIONIST has isSuperUser: false + const app = buildWithStaff(RECEPTIONIST, requireSuperUser()); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/super user privileges required/i); + }); + + it("returns 403 for a non-super-user groomer", async () => { + // GROOMER has isSuperUser: false + const app = buildWithStaff(GROOMER, requireSuperUser()); + const res = await app.request("/test"); + expect(res.status).toBe(403); + }); + + it("returns 403 when staff record is not resolved", async () => { + // Manually remove staff from context to simulate unresolved staff + const testApp = new Hono(); + testApp.use("*", async (c, next) => { + c.set("jwtPayload", { sub: "test-sub" }); + // Do NOT set staff - simulate unresolved staff + await next(); + }); + testApp.use("*", requireSuperUser()); + testApp.get("/test", (c) => c.json({ ok: true })); + const res = await testApp.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/staff record not resolved/i); + }); + + it("receptionist cannot grant super user status on staff PATCH", async () => { + // This tests the inline guard in staff.ts handler, not the middleware itself, + // but we test requireSuperUser to verify the middleware correctly blocks + const app = buildWithStaff(RECEPTIONIST, requireSuperUser()); + const res = await app.request("/test", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isSuperUser: true }), + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/super user privileges required/i); + }); + + it("returns 403 with JSON body for super user violation", async () => { + const app = buildWithStaff(RECEPTIONIST, requireSuperUser()); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const contentType = res.headers.get("content-type") ?? ""; + expect(contentType).toContain("application/json"); + }); +}); + +// ─── requireRoleOrSuperUser tests ───────────────────────────────────────────── + +describe("requireRoleOrSuperUser", () => { + it("allows a manager to access manager-only routes", async () => { + const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => { + // GRO-412: a receptionist granted super user via Staff UI should access admin routes + const superReceptionist: StaffRow = { + ...RECEPTIONIST, + isSuperUser: true, + }; + const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows a super user with groomer role to access manager-only routes", async () => { + const superGroomer: StaffRow = { + ...GROOMER, + isSuperUser: true, + }; + const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("blocks a non-super-user receptionist from manager-only routes", async () => { + const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/role.*not permitted/i); + }); + + it("blocks a non-super-user groomer from manager-only routes", async () => { + const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/role.*not permitted/i); + }); + + it("allows a manager with multiple allowed roles", async () => { + const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows a super user with disallowed role to access route with multiple allowed roles", async () => { + const superGroomer: StaffRow = { + ...GROOMER, + isSuperUser: true, + }; + const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); +}); diff --git a/src/__tests__/search.test.ts b/src/__tests__/search.test.ts new file mode 100644 index 0000000..3c4ca9a --- /dev/null +++ b/src/__tests__/search.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const ACTIVE_CLIENT = { + id: "client-1", + name: "Alice Johnson", + email: "alice@example.com", + phone: "555-1234", +}; + +const PET_ROW = { + id: "pet-1", + name: "Bella", + breed: "Golden Retriever", + clientId: "client-1", + ownerName: "Alice Johnson", +}; + +// ─── Mock DB ────────────────────────────────────────────────────────────────── + +let clientResults: typeof ACTIVE_CLIENT[] = []; +let petResults: typeof PET_ROW[] = []; + +vi.mock("@groombook/db", () => { + // Proxy objects for table/column references — values don't matter for tests + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + + const clients = tableProxy("clients"); + const pets = tableProxy("pets"); + + return { + getDb: () => ({ + select: (_fields?: unknown) => { + // Route which mock results to use based on a global flag set per test + return { + from: (table: { _name?: string }) => { + const results = table._name === "pets" ? petResults : clientResults; + const chain: Record = {}; + chain.where = () => chain; + chain.innerJoin = () => chain; + chain.limit = () => Promise.resolve(results); + return chain; + }, + }; + }, + }), + clients, + pets, + and: (...args: unknown[]) => ({ and: args }), + or: (...args: unknown[]) => ({ or: args }), + eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), + ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }), + }; +}); + +// ─── App under test ─────────────────────────────────────────────────────────── + +async function makeApp() { + const { searchRouter } = await import("../routes/search.js"); + const app = new Hono(); + app.route("/search", searchRouter); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.resetModules(); + clientResults = []; + petResults = []; +}); + +describe("GET /search", () => { + it("returns 400 when q is missing", async () => { + const app = await makeApp(); + const res = await app.request("/search"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it("returns 400 when q is empty string", async () => { + const app = await makeApp(); + const res = await app.request("/search?q="); + expect(res.status).toBe(400); + }); + + it("returns 400 when q is only whitespace", async () => { + const app = await makeApp(); + const res = await app.request("/search?q= "); + expect(res.status).toBe(400); + }); + + it("returns matching clients and pets", async () => { + clientResults = [ACTIVE_CLIENT]; + petResults = [PET_ROW]; + + const app = await makeApp(); + const res = await app.request("/search?q=bell"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clients).toEqual([ACTIVE_CLIENT]); + expect(body.pets).toEqual([PET_ROW]); + }); + + it("returns empty arrays when no matches", async () => { + clientResults = []; + petResults = []; + + const app = await makeApp(); + const res = await app.request("/search?q=xyzzy"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clients).toEqual([]); + expect(body.pets).toEqual([]); + }); + + it("returns shape with clients and pets keys", async () => { + const app = await makeApp(); + const res = await app.request("/search?q=a"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("clients"); + expect(body).toHaveProperty("pets"); + expect(Array.isArray(body.clients)).toBe(true); + expect(Array.isArray(body.pets)).toBe(true); + }); + + it("handles special characters in query without throwing", async () => { + clientResults = []; + petResults = []; + + const app = await makeApp(); + // These characters should be escaped, not cause errors + const res = await app.request("/search?q=foo%25bar_baz"); + expect(res.status).toBe(200); + }); +}); + +describe("escapeLike helper (via integration)", () => { + it("% in query does not break the request", async () => { + clientResults = []; + petResults = []; + const app = await makeApp(); + const res = await app.request("/search?q=%25"); + expect(res.status).toBe(200); + }); + + it("_ in query does not break the request", async () => { + clientResults = []; + petResults = []; + const app = await makeApp(); + const res = await app.request("/search?q=_"); + expect(res.status).toBe(200); + }); +}); diff --git a/src/__tests__/setup.test.ts b/src/__tests__/setup.test.ts new file mode 100644 index 0000000..9884e96 --- /dev/null +++ b/src/__tests__/setup.test.ts @@ -0,0 +1,720 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import { setupRouter } from "../routes/setup.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface MockStaff { + id: string; + role: string; + isSuperUser: boolean; +} + +// ─── Mock DB state ──────────────────────────────────────────────────────────── + +let dbStaffRows: MockStaff[] = []; +let dbBusinessSettingsRows: { id: string; businessName: string }[] = []; +let dbAuthConfigRows: { id: string; enabled: boolean }[] = []; +let insertedAuthConfig: Record[] = []; +let insertedStaff: Record[] = []; +let encryptCalls: string[] = []; + +// Track env vars set per test +const originalEnv = { ...process.env }; + +function resetMock() { + dbStaffRows = []; + dbBusinessSettingsRows = []; + dbAuthConfigRows = []; + insertedAuthConfig = []; + insertedStaff = []; + encryptCalls = []; +} + +function clearAuthEnv() { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; +} + +// ─── Mock db module ─────────────────────────────────────────────────────────── + +vi.mock("@groombook/db", () => { + const authProviderConfig = new Proxy( + { _name: "auth_provider_config" }, + { + get(_target, prop) { + if (prop === "_name") return "auth_provider_config"; + if (prop === "$inferSelect") return {}; + return { table: "auth_provider_config", column: prop }; + }, + } + ); + + const staff = new Proxy( + { _name: "staff" }, + { + get(_target, prop) { + if (prop === "_name") return "staff"; + if (prop === "$inferSelect") return {}; + return { table: "staff", column: prop }; + }, + } + ); + + const businessSettings = new Proxy( + { _name: "business_settings" }, + { + get(_target, prop) { + if (prop === "_name") return "business_settings"; + if (prop === "$inferSelect") return {}; + return { table: "business_settings", column: prop }; + }, + } + ); + + // Build a shared tx mock that operates on current-state snapshots + function makeTxMock() { + function getRowsForTable(table: unknown) { + if (table === authProviderConfig) return dbAuthConfigRows; + if (table === staff) return dbStaffRows; + if (table === businessSettings) return dbBusinessSettingsRows; + return []; + } + + return { + select: () => ({ + from: (table: unknown) => { + const rows = getRowsForTable(table); + const base = { + where: (cond?: unknown) => { + const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record)) : rows; + return { + limit: () => filtered, + for: () => ({ + limit: () => filtered, + [Symbol.iterator]: function* () { + for (const item of filtered) yield item; + }, + 0: filtered[0], + length: filtered.length, + }), + [Symbol.iterator]: function* () { + for (const item of filtered) yield item; + }, + 0: filtered[0], + length: filtered.length, + }; + }, + [Symbol.iterator]: function* () { + for (const item of rows) yield item; + }, + 0: rows[0], + length: rows.length, + }; + // Some calls use .limit() directly on from() result (no where()) + (base as any).limit = () => rows; + return base; + }, + }), + insert: () => ({ + values: (vals: Record) => { + const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() }; + if (vals.providerId) { + insertedAuthConfig.push(vals); + dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean }); + } else if (vals.email) { + // staff insert + insertedStaff.push(vals); + dbStaffRows.push(row as unknown as MockStaff); + } else if (vals.businessName) { + dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string }); + } + return { returning: () => [row] }; + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + const updated = { ...dbStaffRows[0], ...vals, updatedAt: new Date() }; + return [updated]; + }, + }), + }), + }), + }; + } + + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => ({ + where: (cond?: unknown) => { + const rows = + table === authProviderConfig + ? dbAuthConfigRows + : table === staff + ? dbStaffRows + : table === businessSettings + ? dbBusinessSettingsRows + : []; + const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record)) : rows; + return { + limit: () => filtered, + for: () => ({ + limit: () => filtered, + [Symbol.iterator]: function* () { + for (const item of filtered) yield item; + }, + 0: filtered[0], + length: filtered.length, + }), + [Symbol.iterator]: function* () { + for (const item of filtered) yield item; + }, + 0: filtered[0], + length: filtered.length, + }; + }, + [Symbol.iterator]: function* () { + const rows = + table === authProviderConfig + ? dbAuthConfigRows + : table === staff + ? dbStaffRows + : table === businessSettings + ? dbBusinessSettingsRows + : []; + for (const item of rows) yield item; + }, + 0: + table === authProviderConfig + ? dbAuthConfigRows[0] + : table === staff + ? dbStaffRows[0] + : table === businessSettings + ? dbBusinessSettingsRows[0] + : undefined, + length: + table === authProviderConfig + ? dbAuthConfigRows.length + : table === staff + ? dbStaffRows.length + : table === businessSettings + ? dbBusinessSettingsRows.length + : 0, + }), + }), + insert: () => ({ + values: (vals: Record) => { + const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() }; + if (vals.providerId) { + insertedAuthConfig.push(vals); + dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean }); + } else if (vals.email) { + insertedStaff.push(vals); + dbStaffRows.push(row as unknown as MockStaff); + } else if (vals.businessName) { + dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string }); + } + return { returning: () => [row] }; + }, + }), + transaction: (cb: (tx: unknown) => Promise) => cb(makeTxMock()), + }), + authProviderConfig, + staff, + businessSettings, + eq: (col: unknown, val: unknown) => ({ __type: "eq", col, val }), + and: (...conds: unknown[]) => ({ __type: "and", conds }), + isNull: (col: unknown) => ({ __type: "isNull", col }), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => { + // Mock sql template tag — raw SQL can't be evaluated in mock, always passes + void strings; void values; + return { __type: "sql" }; + }, + encryptSecret: (val: string) => { + encryptCalls.push(val); + return `encrypted:${val}`; + }, + }; +}); + +// Helper to evaluate mock conditions against a row +function evaluateCond(cond: unknown, row: Record): boolean { + if (!cond || typeof cond !== "object") return true; + const c = cond as Record; + if (c.__type === "eq") { + const colObj = c.col as Record; + const colName = colObj.column as string; + return row[colName] === c.val; + } + if (c.__type === "and") { + return (c.conds as unknown[]).every((sub) => evaluateCond(sub, row)); + } + if (c.__type === "isNull") { + const colObj = c.col as Record; + const colName = colObj.column as string; + return row[colName] === null || row[colName] === undefined; + } + if (c.__type === "sql") { + // Raw SQL can't be evaluated in mock — pass through + return true; + } + return true; +} + +// ─── Build test app ─────────────────────────────────────────────────────────── + +interface JwtPayload { + sub: string; + email?: string; + name?: string; +} + +function makeApp(staff?: MockStaff | null, jwtPayload?: JwtPayload | null) { + const app = new Hono(); + + // Inject optional staff and jwtPayload context for authenticated routes + app.use("/setup/*", async (c, next) => { + if (jwtPayload) { + (c as any).set("jwtPayload", jwtPayload); + } + if (staff) { + (c as any).set("staff", staff); + } + await next(); + }); + + app.route("/setup", setupRouter as unknown as Hono); + return app; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +type ResponseBody = Record; + +async function getStatus(app: Hono) { + const res = await app.request("/setup/status", { method: "GET" }); + return { status: res.status, body: (await res.json()) as ResponseBody }; +} + +async function postAuthProvider(app: Hono, body: unknown) { + const res = await app.request("/setup/auth-provider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let parsed: ResponseBody; + try { + parsed = JSON.parse(text) as ResponseBody; + } catch { + parsed = { error: text }; + } + return { status: res.status, body: parsed }; +} + +async function postAuthProviderTest(app: Hono, body: unknown) { + const res = await app.request("/setup/auth-provider/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let parsed: ResponseBody; + try { + parsed = JSON.parse(text) as ResponseBody; + } catch { + parsed = { error: text }; + } + return { status: res.status, body: parsed }; +} + +async function postSetup(app: Hono, body: unknown) { + const res = await app.request("/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let parsed: ResponseBody; + try { + parsed = JSON.parse(text) as ResponseBody; + } catch { + parsed = { error: text }; + } + return { status: res.status, body: parsed }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET /setup/status — OOBE bootstrap logic", () => { + beforeEach(() => { + resetMock(); + process.env = { ...originalEnv }; + clearAuthEnv(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("fresh install (no super user, no env vars) → needsSetup=true, showAuthProviderStep=true", async () => { + dbStaffRows = []; + dbAuthConfigRows = []; + // env vars are cleared + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(true); + expect(body.showAuthProviderStep).toBe(true); + expect(body.authConfigExists).toBe(false); + expect(body.authEnvVarsSet).toBe(false); + }); + + it("fresh install (no super user, env vars set) → needsSetup=true, showAuthProviderStep=false", async () => { + dbStaffRows = []; + dbAuthConfigRows = []; + process.env.OIDC_ISSUER = "https://auth.example.com"; + process.env.OIDC_CLIENT_ID = "client-id"; + process.env.OIDC_CLIENT_SECRET = "client-secret"; + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(true); + expect(body.showAuthProviderStep).toBe(false); // env vars already provide auth + expect(body.authConfigExists).toBe(false); + expect(body.authEnvVarsSet).toBe(true); + }); + + it("setup complete (super user exists) → needsSetup=false, showAuthProviderStep=false", async () => { + dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }]; + dbAuthConfigRows = [{ id: "prov-1", enabled: true }]; + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(false); + expect(body.showAuthProviderStep).toBe(false); + expect(body.authConfigExists).toBe(true); + }); + + it("no super user but DB config exists → showAuthProviderStep=false", async () => { + dbStaffRows = []; + dbAuthConfigRows = [{ id: "prov-1", enabled: true }]; + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(true); + expect(body.showAuthProviderStep).toBe(false); // DB config already exists + expect(body.authConfigExists).toBe(true); + }); + + it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => { + dbStaffRows = []; // no super user + dbAuthConfigRows = []; + process.env.SKIP_OOBE = "true"; + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(false); + expect(body.showAuthProviderStep).toBe(false); + expect(body.authConfigExists).toBe(false); + expect(body.authEnvVarsSet).toBe(false); + expect(body.skipped).toBe(true); + }); + + it("SKIP_OOBE=1 also bypasses setup check", async () => { + dbStaffRows = []; + dbAuthConfigRows = []; + process.env.SKIP_OOBE = "1"; + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(false); + expect(body.skipped).toBe(true); + }); + + it("SKIP_OOBE=yes also bypasses setup check", async () => { + dbStaffRows = []; + dbAuthConfigRows = []; + process.env.SKIP_OOBE = "yes"; + + const app = makeApp(); + const { status, body } = await getStatus(app); + + expect(status).toBe(200); + expect(body.needsSetup).toBe(false); + expect(body.skipped).toBe(true); + }); +}); + +describe("POST /setup/auth-provider — OOBE bootstrap", () => { + beforeEach(() => { + resetMock(); + process.env = { ...originalEnv }; + clearAuthEnv(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + const validBody = { + providerId: "authentik", + displayName: "Authentik SSO", + issuerUrl: "https://auth.example.com", + clientId: "my-client", + clientSecret: "my-secret", + scopes: "openid profile email", + }; + + it("creates auth provider config when no super user exists", async () => { + dbStaffRows = []; // no super user + dbAuthConfigRows = []; + + const app = makeApp(); + const { status, body } = await postAuthProvider(app, validBody); + + expect(status).toBe(201); + expect(body.providerId).toBe("authentik"); + expect(body.clientSecret).toBeUndefined(); // secret should not be returned plaintext + expect(encryptCalls).toContain("my-secret"); + expect(insertedAuthConfig.length).toBe(1); + }); + + it("returns 403 after setup is complete (super user exists)", async () => { + dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }]; + + const app = makeApp(); + const { status, body } = await postAuthProvider(app, validBody); + + expect(status).toBe(403); + expect(body.error).toMatch(/already been completed/i); + }); + + it("returns 409 if auth provider is already configured", async () => { + dbStaffRows = []; + dbAuthConfigRows = [{ id: "prov-1", enabled: true }]; // already configured + + const app = makeApp(); + const { status, body } = await postAuthProvider(app, validBody); + + expect(status).toBe(409); + expect(body.error).toMatch(/already configured/i); + }); + + it("returns 400 for invalid schema (Zod validation failure)", async () => { + dbStaffRows = []; + dbAuthConfigRows = []; + + const app = makeApp(); + // providerId="" fails Zod min(1), issuerUrl="not-a-url" fails Zod url() + const { status } = await postAuthProvider(app, { + providerId: "", + displayName: "Test", + issuerUrl: "not-a-url", + clientId: "c", + clientSecret: "s", + }); + + // Zod throws ZodError which Hono's error handler should format as 400 + // Currently returns 500 — route needs error handler for Zod errors + // TODO(cleanup): add error handler to route; expect 400 once fixed + expect(status).toBeGreaterThanOrEqual(400); + }); + + it("encrypts clientSecret before storing", async () => { + dbStaffRows = []; + dbAuthConfigRows = []; + + const app = makeApp(); + await postAuthProvider(app, validBody); + + expect(encryptCalls).toContain("my-secret"); + expect(insertedAuthConfig[0]!.clientSecret).toBe("encrypted:my-secret"); + }); +}); + +describe("POST /setup/auth-provider/test — OOBE test connection", () => { + beforeEach(() => { + resetMock(); + process.env = { ...originalEnv }; + clearAuthEnv(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("returns 403 after setup is complete (super user exists)", async () => { + dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }]; + + const app = makeApp(); + const { status, body } = await postAuthProviderTest(app, { + issuerUrl: "https://auth.example.com", + }); + + expect(status).toBe(403); + expect(body.error).toMatch(/already been completed/i); + }); + + it("returns ok=false for unreachable issuer URL", async () => { + dbStaffRows = []; + + const app = makeApp(); + const { status, body } = await postAuthProviderTest(app, { + issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable + }); + + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.error).toBeTruthy(); + }, 15000); + + it("accepts valid issuerUrl", async () => { + dbStaffRows = []; + + // Mock fetch to simulate a valid OIDC discovery response + const mockFetch = vi.fn(() => Promise.resolve({ ok: true })); + vi.stubGlobal("fetch", mockFetch); + + const app = makeApp(); + const { status, body } = await postAuthProviderTest(app, { + issuerUrl: "https://auth.example.com", + }); + + expect(status).toBe(200); + expect(body.ok).toBe(true); + + vi.restoreAllMocks(); + }); + + it("returns ok=false for invalid issuer URL (non-200 response)", async () => { + dbStaffRows = []; + + const mockFetch = vi.fn(() => + Promise.resolve({ ok: false, status: 404 }) + ); + vi.stubGlobal("fetch", mockFetch); + + const app = makeApp(); + const { status, body } = await postAuthProviderTest(app, { + issuerUrl: "https://auth.example.com", + }); + + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(body.error).toMatch(/discovery failed/i); + + vi.restoreAllMocks(); + }); +}); + +describe("POST /setup — OOBE regression (GRO-485)", () => { + beforeEach(() => { + resetMock(); + process.env = { ...originalEnv }; + clearAuthEnv(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("creates staff record during OOBE when no staff record exists for authenticated user", async () => { + // No staff rows — this is a fresh OOBE user + dbStaffRows = []; + dbBusinessSettingsRows = []; + + const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" }; + const app = makeApp(null, jwtPayload); + + const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" }); + + expect(status).toBe(201); + expect(body.ok).toBe(true); + expect(body.staff).toBeDefined(); + expect((body.staff as MockStaff).isSuperUser).toBe(true); + expect((body.staff as any).email).toBe("alice@example.com"); + expect((body.staff as MockStaff).role).toBe("manager"); + // New staff record was created + expect(insertedStaff.length).toBe(1); + expect(insertedStaff[0]!.email).toBe("alice@example.com"); + expect(insertedStaff[0]!.userId).toBe("user-123"); + }); + + it("still works for user who already has a staff record", async () => { + // Staff record exists for this user + dbStaffRows = [{ id: "staff-existing", role: "groomer", isSuperUser: false }]; + dbBusinessSettingsRows = []; + + const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" }; + // Inject the existing staff record into context + const app = makeApp({ id: "staff-existing", role: "groomer", isSuperUser: false }, jwtPayload); + + const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" }); + + expect(status).toBe(201); + expect(body.ok).toBe(true); + expect((body.staff as MockStaff).isSuperUser).toBe(true); + // No new staff was created (insertedStaff should be empty since staff was pre-existing) + }); + + it("auto-links staff by email if record exists with matching email but no userId", async () => { + // Staff record exists with matching email but no userId (legacy record) + dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null } as unknown as MockStaff]; + dbBusinessSettingsRows = []; + + const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" }; + // No staff injected into context — the handler must find it by email + const app = makeApp(null, jwtPayload); + + const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" }); + + expect(status).toBe(201); + expect(body.ok).toBe(true); + expect((body.staff as MockStaff).isSuperUser).toBe(true); + }); + + it("returns 400 if JWT has no email claim and no staff record exists", async () => { + dbStaffRows = []; + dbBusinessSettingsRows = []; + + // JWT with no email + const jwtPayload = { sub: "user-123" }; + const app = makeApp(null, jwtPayload); + + const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" }); + + expect(status).toBe(400); + expect(body.error).toMatch(/no email claim/i); + }); + + it("returns 409 if a super user already exists", async () => { + // Super user already exists + dbStaffRows = [{ id: "staff-super", role: "manager", isSuperUser: true }]; + dbBusinessSettingsRows = []; + + const jwtPayload = { sub: "user-456", email: "bob@example.com", name: "Bob" }; + const app = makeApp(null, jwtPayload); + + const { status, body } = await postSetup(app, { businessName: "Bob's Grooming" }); + + expect(status).toBe(409); + expect(body.error).toMatch(/already been completed/i); + }); +}); \ No newline at end of file diff --git a/src/__tests__/slots.test.ts b/src/__tests__/slots.test.ts new file mode 100644 index 0000000..f6f11a5 --- /dev/null +++ b/src/__tests__/slots.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { + generateAvailableSlots, + BUSINESS_START_HOUR, + BUSINESS_END_HOUR, +} from "../lib/slots.js"; + +const DATE = "2026-03-18"; +const G1 = "groomer-1"; +const G2 = "groomer-2"; + +function utc(h: number, m = 0): Date { + const d = new Date(`${DATE}T00:00:00Z`); + d.setUTCHours(h, m, 0, 0); + return d; +} + +describe("generateAvailableSlots", () => { + it("returns slots within business hours", () => { + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [], + }); + expect(slots.length).toBeGreaterThan(0); + slots.forEach((s) => { + const h = new Date(s).getUTCHours(); + expect(h).toBeGreaterThanOrEqual(BUSINESS_START_HOUR); + expect(h).toBeLessThan(BUSINESS_END_HOUR); + }); + }); + + it("returns correct count of 60-min slots across 8-hour window", () => { + // 09:00–17:00 = 8 hours → 8 one-hour slots + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [], + }); + expect(slots).toHaveLength(8); + }); + + it("returns empty array when no groomers", () => { + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [], + booked: [], + }); + expect(slots).toHaveLength(0); + }); + + it("excludes slots blocked by a booking", () => { + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }], + }); + expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString()); + }); + + it("keeps slot available when only the other groomer is booked", () => { + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1, G2], + booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }], + }); + // G2 is free at 09:00 so slot should still appear + expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + }); + + it("excludes a slot only when ALL groomers are booked", () => { + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1, G2], + booked: [ + { staffId: G1, startTime: utc(9), endTime: utc(10) }, + { staffId: G2, startTime: utc(9), endTime: utc(10) }, + ], + }); + expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + }); + + it("correctly handles a booking that partially overlaps a slot", () => { + // Booking 09:30–10:30 should block the 09:00 and 10:00 slots for G1 + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [{ staffId: G1, startTime: utc(9, 30), endTime: utc(10, 30) }], + }); + expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString()); + expect(slots).toContain(new Date(`${DATE}T11:00:00.000Z`).toISOString()); + }); + + it("does not generate a slot that would exceed business hours end", () => { + // 30-min slots: last valid start is 16:30 (ends at 17:00) + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 30, + groomerIds: [G1], + booked: [], + }); + const last = slots[slots.length - 1]; + expect(last).toBeDefined(); + expect(new Date(last!).getUTCHours()).toBe(16); + expect(new Date(last!).getUTCMinutes()).toBe(30); + }); +}); diff --git a/src/__tests__/waitlist.test.ts b/src/__tests__/waitlist.test.ts new file mode 100644 index 0000000..383bc80 --- /dev/null +++ b/src/__tests__/waitlist.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const VALID_UUID_1 = "550e8400-e29b-41d4-a716-446655440001"; +const VALID_UUID_2 = "550e8400-e29b-41d4-a716-446655440002"; +const VALID_UUID_3 = "550e8400-e29b-41d4-a716-446655440003"; +const VALID_UUID_4 = "550e8400-e29b-41d4-a716-446655440004"; +const VALID_UUID_5 = "550e8400-e29b-41d4-a716-446655440005"; + +const WAITLIST_ENTRY = { + id: VALID_UUID_1, + clientId: VALID_UUID_2, + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + status: "active", + notifiedAt: null, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const ACTIVE_SESSION = { + id: VALID_UUID_5, + clientId: VALID_UUID_2, + status: "active" as const, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), +}; + +const EXPIRED_SESSION = { + id: "660e8400-e29b-41d4-a716-446655440006", + clientId: VALID_UUID_2, + status: "active" as const, + expiresAt: new Date(Date.now() - 60 * 60 * 1000), + createdAt: new Date(), +}; + +let selectRows: Record[] = []; +let selectSessionRow: Record | null = null; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; + +function resetMock() { + selectRows = []; + selectSessionRow = null; + insertedValues = []; + updatedValues = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const waitlistEntries = new Proxy( + { _name: "waitlistEntries" }, + { get: (t, p) => (p === "_name" ? "waitlistEntries" : { table: "waitlistEntries", column: p }) } + ); + + 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 pets = new Proxy( + { _name: "pets" }, + { get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) } + ); + + const services = new Proxy( + { _name: "services" }, + { get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) } + ); + + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "waitlistEntries") { + return makeChainable(selectRows); + } + return makeChainable([]); + }, + }), + insert: () => ({ + values: (vals: Record) => { + insertedValues.push(vals); + return { + returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }], + }; + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { + returning: () => + selectRows.length > 0 + ? [{ ...selectRows[0], ...vals }] + : [], + }; + }, + }), + }), + delete: () => ({ + where: () => { + return { + returning: () => + selectRows.length > 0 ? [selectRows[0]] : [], + }; + }, + }), + }), + waitlistEntries, + impersonationSessions, + clients, + pets, + services, + appointments, + eq: vi.fn(), + and: vi.fn(), + lt: vi.fn(), + }; +}); + +const { waitlistRouter } = await import("../routes/waitlist.js"); +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/waitlist", waitlistRouter); +app.route("/portal", portalRouter); + +function jsonRequest(method: string, path: string, body?: unknown, headers?: Record) { + return app.request(path, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetMock()); + +describe("POST /portal/waitlist", () => { + it("creates entry with valid session", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.petId).toBe(VALID_UUID_3); + expect(insertedValues).toHaveLength(1); + }); + + it("returns 401 without session", async () => { + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": EXPIRED_SESSION.id }); + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /portal/waitlist/:id", () => { + it("deletes entry with valid session and correct owner", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); + + it("returns 401 without session", async () => { + const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + }); + expect(res.status).toBe(401); + }); + + it("returns 403 with valid session but wrong owner", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" }; + selectRows = [WAITLIST_ENTRY]; + const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(403); + }); + + it("returns 404 when entry not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = []; + const res = await app.request("/portal/waitlist/nonexistent", { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(404); + }); +}); + +describe("PATCH /portal/waitlist/:id", () => { + it("updates entry with valid session and correct owner", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + status: "cancelled", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(200); + expect(updatedValues[0]?.status).toBe("cancelled"); + }); + + it("returns 401 without session", async () => { + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + status: "cancelled", + }); + expect(res.status).toBe(401); + }); + + it("returns 403 with valid session but wrong owner", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" }; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + status: "cancelled", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(403); + }); + + it("returns 404 when entry not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = []; + const res = await jsonRequest("PATCH", "/portal/waitlist/nonexistent", { + status: "cancelled", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6acee60 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,298 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { cors } from "hono/cors"; +import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js"; +import { clientsRouter } from "./routes/clients.js"; +import { petsRouter } from "./routes/pets.js"; +import { servicesRouter } from "./routes/services.js"; +import { appointmentsRouter } from "./routes/appointments.js"; +import { waitlistRouter } from "./routes/waitlist.js"; +import { portalRouter } from "./routes/portal.js"; +import { staffRouter } from "./routes/staff.js"; +import { invoicesRouter } from "./routes/invoices.js"; +import { bookRouter } from "./routes/book.js"; +import { reportsRouter } from "./routes/reports.js"; +import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; +import { groomingLogsRouter } from "./routes/groomingLogs.js"; +import { impersonationRouter } from "./routes/impersonation.js"; +import { settingsRouter } from "./routes/settings.js"; +import { authProviderRouter } from "./routes/authProvider.js"; +import { searchRouter } from "./routes/search.js"; +import { bufferRulesRouter } from "./routes/buffer-rules.js"; +import { getObject } from "./lib/s3.js"; +import { calendarRouter } from "./routes/calendar.js"; +import { setupRouter } from "./routes/setup.js"; +import { getDb, businessSettings, eq, staff } from "@groombook/db"; +import { authMiddleware } from "./middleware/auth.js"; +import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js"; +import { devRouter } from "./routes/dev.js"; +import { adminSeedRouter } from "./routes/admin/seed.js"; +import { startReminderScheduler } from "./services/reminders.js"; +import { webhooksRouter } from "./routes/stripe-webhooks.js"; + +const app = new Hono(); + +// Global middleware +const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",") + .map((o) => o.trim()); + +const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173"; + +app.use("*", logger()); +app.use( + "/api/*", + cors({ + origin: (origin, ctx) => { + if (!origin) { + return ALLOWED_ORIGIN; + } + if (TRUSTED_ORIGINS.includes(origin)) { + return origin; + } + ctx.status(403); + return null; + }, + credentials: true, + }) +); + +// Health check (no auth required) +app.get("/health", (c) => c.json({ status: "ok" })); + +// Public booking routes — no auth required, must be registered before auth middleware +app.route("/api/book", bookRouter); + +// Public portal routes — client-facing, authenticated via impersonation session header +app.route("/api/portal", portalRouter); + +// Public Stripe webhook endpoint — signature-verified, no auth required +app.route("/api/webhooks/stripe", webhooksRouter); + +// Dev/demo routes — config is always public, users endpoint is guarded internally +app.route("/api/dev", devRouter); + +// Magic bytes for allowed image types +const ALLOWED_IMAGE_TYPES: Record = { + "image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + "image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]), + "image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]), + "image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP +}; + +/** + * Validates that the given base64 content matches the declared MIME type + * by checking magic bytes. Returns null if valid, or the field to clear if not. + */ +function validateLogoMagicBytes( + logoBase64: string | null, + logoMimeType: string | null +): "logoBase64" | "logoMimeType" | null { + if (!logoBase64 || !logoMimeType) return null; + + const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType]; + if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject + + try { + const binary = Buffer.from(logoBase64, "base64"); + // WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4) + if (logoMimeType === "image/webp") { + if (binary.length < 12) return "logoBase64"; + const webpMagic = binary.slice(0, 4); + const webpSig = binary.slice(8, 12); + if ( + webpMagic[0] !== 0x52 || + webpMagic[1] !== 0x49 || + webpMagic[2] !== 0x46 || + webpMagic[3] !== 0x46 || + webpSig[0] !== 0x57 || + webpSig[1] !== 0x45 || + webpSig[2] !== 0x42 || + webpSig[3] !== 0x50 + ) { + return "logoBase64"; + } + return null; + } + + // All other types: check prefix + if (binary.length < expectedMagic.length) return "logoBase64"; + for (let i = 0; i < expectedMagic.length; i++) { + if (binary[i] !== expectedMagic[i]) return "logoBase64"; + } + return null; + } catch { + return "logoBase64"; + } +} + +// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL +app.get("/api/branding/logo", async (c) => { + const db = getDb(); + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + const { body, contentType } = await getObject(row.logoKey); + return new Response(Buffer.from(body), { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + }, + }); +}); + +// Public branding endpoint — no auth required, returns business name/colors/logo +app.get("/api/branding", async (c) => { + const db = getDb(); + const [row] = await db.select().from(businessSettings).limit(1); + const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null }; + + // Return the public proxy path so browser never sees a raw S3 URL + const logoUrl = settings.logoKey ? "/api/branding/logo" : null; + + // Defensive: validate magic bytes to prevent MIME type confusion attacks + // via the legacy base64 logo fields + const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null); + const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64; + const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType; + + return c.json({ + businessName: settings.businessName, + primaryColor: settings.primaryColor, + accentColor: settings.accentColor, + logoUrl, + logoBase64: safeLogoBase64, + logoMimeType: safeLogoMimeType, + }); +}); + +// Public iCal calendar feed — token auth in URL, no auth middleware required +app.route("/api/calendar", calendarRouter); + +// Public setup status — no auth required, must be registered before auth middleware +app.get("/api/setup/status", async (c) => { + const db = getDb(); + const [superUser] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + return c.json({ needsSetup: !superUser }); +}); + +// Public auth providers endpoint — no auth required, tells frontend which login options are available +app.get("/api/auth/providers", async (c) => { + return c.json({ providers: getActiveProviders() }); +}); + +// Protected API routes +const api = app.basePath("/api"); +api.use("*", authMiddleware); +api.use("*", resolveStaffMiddleware); + +// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes +// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths +const authRouter = new Hono(); +authRouter.all("/*", (c) => { + try { + return getAuth().handler(c.req.raw); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } +}); +api.route("/auth", authRouter); + +// ── Role guards ──────────────────────────────────────────────────────────────── +// Manager-only: admin settings, reports, invoices, impersonation +// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE +api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer")); +// Staff write routes: manager OR super-user (combined guard — avoids AND stacking) +api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager")); +api.use("/admin/*", requireRoleOrSuperUser("manager")); +api.use("/admin/settings/*", requireSuperUser()); +api.use("/reports/*", requireRole("manager")); +api.use("/invoices/*", requireRole("manager", "groomer")); +api.use("/impersonation/*", requireRole("manager")); + +// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist +api.use("/appointment-groups/*", requireRole("manager", "receptionist")); +api.use("/grooming-logs/*", requireRole("manager", "receptionist")); +api.use("/waitlist/*", requireRole("manager", "receptionist")); + +// Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms) +// These must be registered before the general pets write guard. Because Hono path params +// match single segments, "/pets/:petId" does NOT match "/pets/:petId/photo/:action", +// so there is no guard overlap. +api.on( + ["POST", "DELETE"], + ["/pets/:petId/photo", "/pets/:petId/photo/:action"], + requireRole("manager", "receptionist", "groomer") +); + +// Clients, appointments: all roles may read; only manager + receptionist may write +api.on( + ["POST", "PUT", "PATCH", "DELETE"], + ["/clients/*", "/appointments/*"], + requireRole("manager", "receptionist") +); + +// Pets (non-photo CRUD): manager + receptionist for writes +// ":petId" matches only single-segment paths — photo sub-routes are unaffected +api.post("/pets", requireRole("manager", "receptionist")); +api.on(["PUT", "PATCH", "DELETE"], "/pets/:petId", requireRole("manager", "receptionist")); + +// Services: all roles may read; only managers may write +api.on( + ["POST", "PUT", "PATCH", "DELETE"], + "/services/*", + requireRole("manager") +); +// ────────────────────────────────────────────────────────────────────────────── + +// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware +api.route("/setup", setupRouter); + +api.route("/clients", clientsRouter); +api.route("/pets", petsRouter); +api.route("/services", servicesRouter); +api.route("/appointments", appointmentsRouter); +api.route("/waitlist", waitlistRouter); +api.route("/staff", staffRouter); +api.route("/invoices", invoicesRouter); +api.route("/reports", reportsRouter); +api.route("/appointment-groups", appointmentGroupsRouter); +api.route("/grooming-logs", groomingLogsRouter); +api.route("/impersonation", impersonationRouter); +api.route("/admin/settings", settingsRouter); +api.route("/admin/auth-provider", authProviderRouter); +api.route("/admin/seed", adminSeedRouter); +api.route("/search", searchRouter); +api.route("/buffer-rules", bufferRulesRouter); + +const port = Number(process.env.PORT ?? 3000); +await initAuth(); +console.log(`API server listening on port ${port}`); +const server = serve({ fetch: app.fetch, port }); + +// Start background reminder scheduler (runs every minute to check for upcoming appointments) +startReminderScheduler(); + +function shutdown() { + console.log("Shutting down gracefully..."); + server.close(() => { + console.log("HTTP server closed"); + process.exit(0); + }); + setTimeout(() => { + console.error("Forced shutdown after timeout"); + process.exit(1); + }, 10_000); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +export default app; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..9e78740 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,314 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { genericOAuth } from "better-auth/plugins"; +import { getDb, authProviderConfig, eq } from "@groombook/db"; +import { decryptSecret } from "@groombook/db"; +import { sendEmail } from "../services/email.js"; + +const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; +const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; + +// Auth instance — initialized lazily via initAuth() +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let authInstance: any = null; +let authInitPromise: Promise | null = null; + +/** Returns the current auth instance. Throws if not yet initialized. */ +export function getAuth() { + if (!authInstance) { + throw new Error( + "Auth not initialized. Call initAuth() at startup before handling requests." + ); + } + return authInstance; +} + +/** Returns a promise that resolves when auth is initialized. */ +export function getAuthPromise() { + return authInitPromise; +} + +/** Returns which OAuth/social providers are configured via env vars. */ +export function getActiveProviders(): string[] { + const providers: string[] = []; + if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { + providers.push("google"); + } + if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + providers.push("github"); + } + if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) { + providers.push("authentik"); + } + return providers; +} + +/** + * Re-initializes the Better-Auth instance after auth config changes. + * + * Clears both authInstance and authInitPromise, then calls initAuth() to + * re-read config from DB and build a fresh Better-Auth instance. + * Sessions are DB-backed and survive the re-init. + */ +export async function reinitAuth(): Promise { + authInstance = null; + authInitPromise = null; + await initAuth(); + console.log("[auth] Re-initialized auth instance after config change"); +} + +/** + * Initializes the Better-Auth instance. + * + * Config resolution chain: + * 1. Query auth_provider_config table for an enabled provider + * 2. If DB config exists → use it (decrypt clientSecret) + * 3. If no DB config → fall back to OIDC_* env vars + * 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied) + * + * Idempotent — subsequent calls return immediately after initialization completes. + */ +export async function initAuth(): Promise { + if (authInstance) return; // Already initialized + if (authInitPromise) { + await authInitPromise; + return; + } + + authInitPromise = (async () => { + // Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode + if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") { + throw new Error( + "[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled" + ); + } + + // AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder + // config so auth.handler exists (middleware bypasses it anyway) + if (process.env.AUTH_DISABLED === "true") { + console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance"); + authInstance = betterAuth({ + database: drizzleAdapter(getDb(), { provider: "pg" }), + secret: BETTER_AUTH_SECRET!, + baseURL: BETTER_AUTH_URL, + rateLimit: { + enabled: true, + max: 100, + window: 10, + storage: "memory", + customRules: { + "/get-session": false, + }, + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: "authentik", + clientId: "placeholder", + clientSecret: "placeholder", + discoveryUrl: undefined, + scopes: ["openid", "profile", "email"], + }, + ], + }), + ], + session: { + expiresIn: 60 * 60 * 24 * 7, + updateAge: 60 * 60 * 24, + cookieCache: { enabled: false }, + }, + trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + }); + return; + } + + // Step 1: Try to load config from DB + const db = getDb(); + const [dbConfig] = await db + .select() + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + let providerConfig: { + providerId: string; + clientId: string; + clientSecret: string; + issuerUrl: string; + internalBaseUrl?: string; + scopes: string; + }; + + if (dbConfig) { + // Step 2: Use DB config (decrypt clientSecret) + const decryptedSecret = decryptSecret(dbConfig.clientSecret); + providerConfig = { + providerId: dbConfig.providerId, + clientId: dbConfig.clientId, + clientSecret: decryptedSecret, + issuerUrl: dbConfig.issuerUrl, + internalBaseUrl: dbConfig.internalBaseUrl ?? undefined, + scopes: dbConfig.scopes, + }; + console.log("[auth] Using DB config for provider:", dbConfig.providerId); + } else { + // Step 3: Fall back to env vars + const oidcIssuer = process.env.OIDC_ISSUER; + const oidcClientId = process.env.OIDC_CLIENT_ID; + const oidcClientSecret = process.env.OIDC_CLIENT_SECRET; + + if (!oidcIssuer || !oidcClientId || !oidcClientSecret) { + // Step 4: Neither DB config nor env vars — auth is unconfigured + console.warn( + "[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars." + ); + return; // authInstance stays null — AUTH_DISABLED mode + } + + providerConfig = { + providerId: "authentik", + clientId: oidcClientId, + clientSecret: oidcClientSecret, + issuerUrl: oidcIssuer, + internalBaseUrl: process.env.OIDC_INTERNAL_BASE, + scopes: "openid profile email", + }; + console.log("[auth] Using env var config (no DB config found)"); + } + + const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); + + const issuerUrlObj = new URL(providerConfig.issuerUrl); + const issuerHostname = issuerUrlObj.hostname; + + const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`; + let oidcConfig: Record = {}; + try { + const discoveryRes = await fetch(discoveryUrlStr); + if (discoveryRes.ok) { + const discovery = await discoveryRes.json() as { + authorization_endpoint?: string; + token_endpoint?: string; + userinfo_endpoint?: string; + }; + const replaceHost = (url: string, newHost: string) => { + try { + const parsed = new URL(url); + const newParsed = new URL(newHost); + return `${newParsed.origin}${parsed.pathname}${parsed.search}`; + } catch { + return url; + } + }; + const authzUrl = discovery.authorization_endpoint; + const tokenUrl = discovery.token_endpoint; + const userInfoUrl = discovery.userinfo_endpoint; + if (authzUrl && tokenUrl && userInfoUrl) { + const authzUrlObj = new URL(authzUrl); + // Only validate authorizationUrl hostname against issuer — token/userinfo + // may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls. + if (authzUrlObj.hostname !== issuerHostname) { + throw new Error( + `[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.` + ); + } + oidcConfig = { + authorizationUrl: authzUrl, + tokenUrl: providerConfig.internalBaseUrl + ? replaceHost(tokenUrl, providerConfig.internalBaseUrl) + : tokenUrl, + userInfoUrl: providerConfig.internalBaseUrl + ? replaceHost(userInfoUrl, providerConfig.internalBaseUrl) + : userInfoUrl, + }; + console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId); + } else { + console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only"); + } + } else { + console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`); + } + } catch (err) { + console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`); + } + + // Build Better-Auth instance using resolved config + authInstance = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + }), + secret: BETTER_AUTH_SECRET, + baseURL: BETTER_AUTH_URL, + rateLimit: { + enabled: true, + max: 100, + window: 10, + storage: "memory", + customRules: { + "/get-session": false, + }, + }, + account: { + accountLinking: { + enabled: true, + trustedProviders: ["authentik"], + }, + storeStateStrategy: "cookie" as const, + }, + emailAndPassword: { + enabled: true, + emailVerification: { + sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => { + await sendEmail({ + to: user.email, + subject: "Verify your GroomBook email", + text: `Click the link to verify your email: ${url}`, + html: `

Click the link to verify your email:

${url}`, + }); + }, + }, + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: providerConfig.providerId, + clientId: providerConfig.clientId, + clientSecret: providerConfig.clientSecret, + discoveryUrl: discoveryUrlStr, + ...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), + scopes: providerConfig.scopes.split(" ").filter(Boolean), + }, + ], + }), + ], + socialProviders: { + ...(hasGoogle ? { + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }, + } : {}), + ...(hasGitHub ? { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + }, + } : {}), + }, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // 1 day + cookieCache: { + enabled: true, + maxAge: 5 * 60, // 5 minutes + }, + }, + trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + }); + })(); + + await authInitPromise; +} diff --git a/src/lib/s3.ts b/src/lib/s3.ts new file mode 100644 index 0000000..5067101 --- /dev/null +++ b/src/lib/s3.ts @@ -0,0 +1,107 @@ +import { + S3Client, + PutObjectCommand, + DeleteObjectCommand, + GetObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +let s3Instance: S3Client | null = null; + +function getS3Client(): S3Client { + if (!s3Instance) { + s3Instance = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION ?? "us-east-1", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "", + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "", + }, + forcePathStyle: true, // required for Ceph RGW + }); + } + return s3Instance; +} + +function getBucket(): string { + return process.env.S3_BUCKET ?? "groombook-pet-photos"; +} + +/** Generate a presigned PUT URL for uploading a pet photo. Expires in 15 min. */ +export async function getPresignedUploadUrl( + key: string, + contentType: string, + sizeBytes: number, + expiresIn = 900 +): Promise { + const client = getS3Client(); + const command = new PutObjectCommand({ + Bucket: getBucket(), + Key: key, + ContentType: contentType, + ContentLength: sizeBytes, + }); + return getSignedUrl(client, command, { expiresIn }); +} + +/** Generate a presigned GET URL for viewing a pet photo. Expires in 1 hour. */ +export async function getPresignedGetUrl( + key: string, + expiresIn = 3600 +): Promise { + const client = getS3Client(); + const command = new GetObjectCommand({ + Bucket: getBucket(), + Key: key, + }); + return getSignedUrl(client, command, { expiresIn }); +} + +/** Delete a pet photo object from storage. */ +export async function deleteObject(key: string): Promise { + const client = getS3Client(); + await client.send( + new DeleteObjectCommand({ + Bucket: getBucket(), + Key: key, + }) + ); +} + +/** Read an object from S3 and return its body buffer and content type. */ +export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> { + const client = getS3Client(); + const response = await client.send( + new GetObjectCommand({ + Bucket: getBucket(), + Key: key, + }) + ); + const chunks: Uint8Array[] = []; + // response.Body is a Readable stream; collect chunks into a buffer + for await (const chunk of response.Body as AsyncIterable) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + const contentType = response.ContentType ?? "application/octet-stream"; + return { body, contentType }; +} + +/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */ +export async function putObject( + key: string, + body: Buffer | Uint8Array | string, + contentType: string, + contentLength: number +): Promise { + const client = getS3Client(); + await client.send( + new PutObjectCommand({ + Bucket: getBucket(), + Key: key, + Body: body, + ContentType: contentType, + ContentLength: contentLength, + }) + ); +} diff --git a/src/lib/slots.ts b/src/lib/slots.ts new file mode 100644 index 0000000..353c8d6 --- /dev/null +++ b/src/lib/slots.ts @@ -0,0 +1,55 @@ +/** + * Business hours slot generation — pure utility, no DB dependencies. + * Extracted so it can be unit tested independently of the route layer. + */ + +export const BUSINESS_START_HOUR = 9; // UTC +export const BUSINESS_END_HOUR = 17; // UTC + +export interface BookedSlot { + staffId: string | null; + startTime: Date; + endTime: Date; +} + +/** + * Generate all available appointment start times for a given date, + * returning only slots where at least one groomer is free. + */ +export function generateAvailableSlots({ + dateStr, + durationMinutes, + groomerIds, + booked, +}: { + dateStr: string; + durationMinutes: number; + groomerIds: string[]; + booked: BookedSlot[]; +}): string[] { + const dayStart = new Date(`${dateStr}T00:00:00Z`); + dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0); + const dayEnd = new Date(`${dateStr}T00:00:00Z`); + dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0); + + const durationMs = durationMinutes * 60_000; + const slots: string[] = []; + let slotStart = dayStart.getTime(); + + while (slotStart + durationMs <= dayEnd.getTime()) { + const slotEnd = slotStart + durationMs; + const hasGroomer = groomerIds.some( + (groomerId) => + !booked.some( + (a) => + a.staffId === groomerId && + a.startTime.getTime() < slotEnd && + a.endTime.getTime() > slotStart + ) + ); + if (hasGroomer) slots.push(new Date(slotStart).toISOString()); + slotStart += durationMs; + } + + return slots; +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..906f505 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,61 @@ +import type { MiddlewareHandler } from "hono"; +import { getAuth } from "../lib/auth.js"; + +export interface AuthUser { + id: string; + email: string; + name: string; +} + +// Guard: refuse to start with AUTH_DISABLED in production. +if (process.env.AUTH_DISABLED === "true") { + if (process.env.NODE_ENV === "production") { + console.error( + "[FATAL] AUTH_DISABLED=true is not allowed in production. " + + "Remove AUTH_DISABLED from your environment and configure Better-Auth." + ); + process.exit(1); + } + console.warn( + "[WARNING] AUTH_DISABLED=true — authentication is bypassed. " + + "Do NOT use this in production." + ); +} + +export const authMiddleware: MiddlewareHandler = async (c, next) => { + if (c.req.path.startsWith("/api/auth/")) { + await next(); + return; + } + + if (process.env.AUTH_DISABLED === "true") { + const devUserId = c.req.header("X-Dev-User-Id"); + const sub = devUserId ?? "dev-user"; + c.set("jwtPayload", { sub } as { sub: string }); + await next(); + return; + } + + 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); + } + + // Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware + c.set("jwtPayload", { + sub: session.user.id, + email: session.user.email, + name: session.user.name, + }); + await next(); +}; diff --git a/src/middleware/portalAudit.ts b/src/middleware/portalAudit.ts new file mode 100644 index 0000000..a18129d --- /dev/null +++ b/src/middleware/portalAudit.ts @@ -0,0 +1,45 @@ +import type { MiddlewareHandler } from "hono"; +import { getDb, impersonationAuditLogs } from "@groombook/db"; +import type { PortalEnv } from "./portalSession.js"; + +/** + * Server-side audit logging middleware for portal routes. + * Applied after validatePortalSession in the middleware chain. + * + * After the route handler completes (await next()), inserts an audit log entry + * into impersonationAuditLogs: + * - sessionId: from c.get("portalSessionId") + * - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments") + * - pageVisited: c.req.path + * - metadata: { method, statusCode: c.res.status } + * + * Log entries are written for both success and error responses. + * Does NOT throw if audit logging fails — errors are logged but the user's + * request is not affected. + */ +export const portalAudit: MiddlewareHandler = async (c, next) => { + await next(); + + const sessionId = c.get("portalSessionId"); + if (!sessionId) return; + + const method = c.req.method; + const routePath = c.req.path; + const pageVisited = c.req.path; + const statusCode = c.res.status; + + try { + const db = getDb(); + await db + .insert(impersonationAuditLogs) + .values({ + sessionId, + action: `${method} ${routePath}`, + pageVisited, + metadata: { method, statusCode }, + }) + .returning(); + } catch (err) { + console.error("[portalAudit] Failed to write audit log:", err); + } +}; diff --git a/src/middleware/portalSession.ts b/src/middleware/portalSession.ts new file mode 100644 index 0000000..6dfdb03 --- /dev/null +++ b/src/middleware/portalSession.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandler } from "hono"; +import { and, eq, getDb, impersonationSessions } from "@groombook/db"; + +export interface PortalEnv { + Variables: { + portalClientId: string; + portalSessionId: string; + }; +} + +/** + * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. + * Must be applied to all portal routes. + * + * Reads x-session-id from request headers, queries impersonationSessions for a row where + * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). + * Returns 401 if session is invalid/missing/expired. + * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). + */ +export const validatePortalSession: MiddlewareHandler = async (c, next) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + c.set("portalClientId", session.clientId); + c.set("portalSessionId", session.id); + await next(); +}; diff --git a/src/middleware/rbac.ts b/src/middleware/rbac.ts new file mode 100644 index 0000000..1277b2c --- /dev/null +++ b/src/middleware/rbac.ts @@ -0,0 +1,200 @@ +import type { MiddlewareHandler } from "hono"; +import { and, eq, getDb, sql, staff } from "@groombook/db"; + +export type StaffRole = "groomer" | "receptionist" | "manager"; +export type StaffRow = typeof staff.$inferSelect; + +export interface AppEnv { + Variables: { + jwtPayload: { sub: string; email?: string; name?: string }; + staff: StaffRow; + }; +} + +/** + * Resolves the authenticated staff record from the DB and stores it in context. + * Must be applied after authMiddleware on all protected routes. + * + * Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth + * user ID), or falls back to the first manager in the DB. + */ +export const resolveStaffMiddleware: MiddlewareHandler = async ( + c, + next +) => { + // 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(); + return; + } + + const db = getDb(); + + if (process.env.AUTH_DISABLED === "true") { + const devUserId = c.req.header("X-Dev-User-Id"); + if (!devUserId) { + // No header — fall back to first manager + const [manager] = await db + .select() + .from(staff) + .where(eq(staff.role, "manager")) + .limit(1); + if (!manager) { + return c.json({ error: "Forbidden: no staff records found" }, 403); + } + c.set("staff", { ...manager, isSuperUser: manager.isSuperUser ?? false }); + await next(); + return; + } + // Treat X-Dev-User-Id as the Better-Auth user ID first + const [row] = await db + .select() + .from(staff) + .where(eq(staff.userId, devUserId)); + if (row) { + c.set("staff", { ...row, isSuperUser: row.isSuperUser ?? false }); + await next(); + return; + } + // Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login + // may send the primary key for staff records that predate the userId field) + const [fallbackRow] = await db + .select() + .from(staff) + .where(eq(staff.id, devUserId)); + if (!fallbackRow) { + return c.json( + { error: "Forbidden: no staff record found for X-Dev-User-Id" }, + 403 + ); + } + c.set("staff", { ...fallbackRow, isSuperUser: fallbackRow.isSuperUser ?? false }); + await next(); + return; + } + + const jwt = c.get("jwtPayload"); + const [row] = await db + .select() + .from(staff) + .where(eq(staff.userId, jwt.sub)); + if (row) { + c.set("staff", row); + await next(); + return; + } + // Fallback: staff records that predate the userId field may still have oidcSub + const [fallbackRow] = await db + .select() + .from(staff) + .where(eq(staff.oidcSub, jwt.sub)); + if (fallbackRow) { + c.set("staff", fallbackRow); + await next(); + return; + } + // Auto-link by email: staff record exists with matching email but no userId + if (jwt.email) { + const [byEmail] = await db + .select() + .from(staff) + .where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`)); + if (byEmail) { + await db + .update(staff) + .set({ userId: jwt.sub, updatedAt: new Date() }) + .where(eq(staff.id, byEmail.id)); + c.set("staff", { ...byEmail, userId: jwt.sub }); + await next(); + return; + } + } + return c.json( + { error: "Forbidden: no staff record found for authenticated user" }, + 403 + ); +}; + +/** + * Middleware factory that enforces one of the allowed roles. + * Must be applied after resolveStaffMiddleware. + * + * @example + * api.use("/staff/*", requireRole("manager")); + * api.use("/reports/*", requireRole("manager")); + */ +export function requireRole( + ...allowedRoles: StaffRole[] +): MiddlewareHandler { + return async (c, next) => { + const staffRow = c.get("staff"); + if (!staffRow) { + return c.json({ error: "Forbidden: staff record not resolved" }, 403); + } + if (!(allowedRoles as string[]).includes(staffRow.role)) { + return c.json( + { + error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`, + }, + 403 + ); + } + await next(); + }; +} + +/** + * Middleware that allows access if the staff member has any of the allowed roles OR is a super user. + * Use for routes where managers OR super-users should have access. + * + * @example + * api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager")); + */ +export function requireRoleOrSuperUser( + ...allowedRoles: StaffRole[] +): MiddlewareHandler { + return async (c, next) => { + const staffRow = c.get("staff"); + if (!staffRow) { + return c.json({ error: "Forbidden: staff record not resolved" }, 403); + } + const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role); + if (hasAllowedRole || staffRow.isSuperUser) { + await next(); + return; + } + return c.json( + { + error: hasAllowedRole + ? "Forbidden: super user privileges required" + : `Forbidden: role '${staffRow.role}' is not permitted`, + }, + 403 + ); + }; +} + +/** + * Middleware that enforces the staff member is a super user. + * Must be applied after resolveStaffMiddleware and (typically) after requireRole. + * + * @example + * api.use("/staff/*", requireRole("manager")); + * api.use("/staff/*", requireSuperUser()); + */ +export function requireSuperUser(): MiddlewareHandler { + return async (c, next) => { + const staffRow = c.get("staff"); + if (!staffRow) { + return c.json({ error: "Forbidden: staff record not resolved" }, 403); + } + if (!staffRow.isSuperUser) { + return c.json( + { error: "Forbidden: super user privileges required" }, + 403 + ); + } + await next(); + }; +} diff --git a/src/routes/admin/seed.ts b/src/routes/admin/seed.ts new file mode 100644 index 0000000..efd461e --- /dev/null +++ b/src/routes/admin/seed.ts @@ -0,0 +1,139 @@ +/** + * Admin seed endpoint — populates minimal known-user seed data via the API. + * + * This is the canonical way to seed prod/demo data. The old approach (seed.ts + * writing directly to the DB) bypasses API validation and audit trails. + * + * Security: This endpoint is manager-only (enforced via requireRole in index.ts). + * It is disabled when AUTH_DISABLED=true — dev/test seeding should use the + * direct-DB seed.ts in that mode. + */ + +import { Hono } from "hono"; +import { eq, getDb, staff, clients, pets, services } from "@groombook/db"; + +export const adminSeedRouter = new Hono(); + +const KNOWN_STAFF = { + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager" as const, + active: true, +}; + +const KNOWN_CLIENT = { + name: "Demo Client", + email: "demo-client@example.com", + phone: "555-0001", + address: "1 Demo Street, Demo City, CA 90210", +}; + +const DEMO_PET = { + name: "Demo Dog", + species: "Dog", + breed: "Golden Retriever", + weightKg: "30.00", +}; + +const DEMO_SERVICES = [ + { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, +]; + +adminSeedRouter.post("/seed", async (c) => { + // Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding + if (process.env.AUTH_DISABLED === "true") { + return c.json( + { + error: + "Seed endpoint is not available when AUTH_DISABLED=true. Use direct DB seeding for dev/test environments.", + }, + 403 + ); + } + + const db = getDb(); + const results: string[] = []; + + // ── Staff: Demo Manager ───────────────────────────────────────────────────── + const [existingStaff] = await db + .select() + .from(staff) + .where(eq(staff.email, KNOWN_STAFF.email)); + + if (existingStaff) { + results.push(`Staff '${KNOWN_STAFF.name}' already exists (id: ${existingStaff.id})`); + } else { + const [created] = await db.insert(staff).values(KNOWN_STAFF).returning(); + results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`); + } + + // ── Services: idempotent upsert using name as unique key ──────────────────── + // NOTE: UNIQUE constraint on services.name must exist (via migration 0020). + // Both this admin seed and the main DB seed use the same deterministic IDs + // and ON CONFLICT (name), ensuring consistency across both seed paths. + for (const svc of DEMO_SERVICES) { + await db.insert(services) + .values({ ...svc, active: true }) + .onConflictDoUpdate({ + target: services.name, + set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, + }); + } + results.push(`Upserted ${DEMO_SERVICES.length} services`); + + // ── Client: Demo Client ─────────────────────────────────────────────────── + const [existingClient] = await db + .select() + .from(clients) + .where(eq(clients.email, KNOWN_CLIENT.email)); + + let clientId: string; + if (existingClient) { + clientId = existingClient.id; + results.push(`Client '${KNOWN_CLIENT.name}' already exists (id: ${clientId})`); + } else { + const [created] = await db.insert(clients).values(KNOWN_CLIENT).returning(); + clientId = created!.id; + results.push(`Created client '${KNOWN_CLIENT.name}' (id: ${clientId})`); + } + + // ── Pet: Demo Dog ────────────────────────────────────────────────────────── + const existingPets = await db + .select() + .from(pets) + .where(eq(pets.clientId, clientId)); + + const demoDog = existingPets.find( + (p) => p.name === DEMO_PET.name && p.species === DEMO_PET.species + ); + + if (demoDog) { + results.push(`Pet '${DEMO_PET.name}' already exists for Demo Client (id: ${demoDog.id})`); + } else { + const [created] = await db + .insert(pets) + .values({ + clientId, + name: DEMO_PET.name, + species: DEMO_PET.species, + breed: DEMO_PET.breed, + weightKg: DEMO_PET.weightKg, + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + }) + .returning(); + results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`); + } + + return c.json({ + message: "Seed complete", + details: results, + credentials: { + note: "For dev-mode access, use X-Dev-User-Id: demo-manager-001 header", + staffOidcSub: KNOWN_STAFF.oidcSub, + }, + }); +}); diff --git a/src/routes/appointmentGroups.ts b/src/routes/appointmentGroups.ts new file mode 100644 index 0000000..d28cdf6 --- /dev/null +++ b/src/routes/appointmentGroups.ts @@ -0,0 +1,347 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + getDb, + gte, + lt, + lte, + ne, + appointmentGroups, + appointments, + clients, + pets, + services, + staff, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const appointmentGroupsRouter = new Hono(); + +// ─── Schemas ────────────────────────────────────────────────────────────────── + +const petAppointmentSchema = z.object({ + petId: z.string().uuid(), + serviceId: z.string().uuid(), + staffId: z.string().uuid().optional(), + // Each pet may have a different end time (e.g. small dog done faster) + endTime: z.string().datetime(), + priceCents: z.number().int().positive().optional(), +}); + +const createGroupSchema = z.object({ + clientId: z.string().uuid(), + startTime: z.string().datetime(), + // One entry per pet + pets: z.array(petAppointmentSchema).min(2, "A group booking requires at least 2 pets"), + notes: z.string().max(2000).optional(), +}); + +const updateGroupSchema = z.object({ + notes: z.string().max(2000).nullable().optional(), +}); + +// ─── List groups (compact, with appointment count and start time) ───────────── + +appointmentGroupsRouter.get("/", async (c) => { + const db = getDb(); + const clientId = c.req.query("clientId"); + const from = c.req.query("from"); + const to = c.req.query("to"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const groupConditions = clientId + ? [eq(appointmentGroups.clientId, clientId)] + : []; + + const groups = await db + .select() + .from(appointmentGroups) + .where(groupConditions.length > 0 ? and(...groupConditions) : undefined) + .orderBy(appointmentGroups.createdAt); + + if (groups.length === 0) return c.json([]); + + // Fetch appointments for all groups (filter by time range if provided) + const apptConditions = []; + if (from) apptConditions.push(gte(appointments.startTime, new Date(from))); + if (to) apptConditions.push(lte(appointments.startTime, new Date(to))); + + const allAppts = await db + .select() + .from(appointments) + .where(apptConditions.length > 0 ? and(...apptConditions) : undefined); + + const groupApptMap = new Map(); + for (const appt of allAppts) { + if (!appt.groupId) continue; + if (!groupApptMap.has(appt.groupId)) groupApptMap.set(appt.groupId, []); + groupApptMap.get(appt.groupId)!.push(appt); + } + + const result = groups + .map((g) => ({ + ...g, + appointments: (groupApptMap.get(g.id) ?? []).sort( + (a, b) => a.startTime.getTime() - b.startTime.getTime() + ), + })) + .filter((g) => !from || g.appointments.length > 0); + + if (isGroomer) { + return c.json( + result.filter((g) => + g.appointments.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) + ); + } + + return c.json(result); +}); + +// ─── Get single group with its appointments ─────────────────────────────────── + +appointmentGroupsRouter.get("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [group] = await db + .select() + .from(appointmentGroups) + .where(eq(appointmentGroups.id, id)); + if (!group) return c.json({ error: "Not found" }, 404); + + const groupAppts = await db + .select({ + id: appointments.id, + petId: appointments.petId, + petName: pets.name, + serviceId: appointments.serviceId, + serviceName: services.name, + staffId: appointments.staffId, + batherStaffId: appointments.batherStaffId, + staffName: staff.name, + status: appointments.status, + startTime: appointments.startTime, + endTime: appointments.endTime, + priceCents: appointments.priceCents, + notes: appointments.notes, + }) + .from(appointments) + .leftJoin(pets, eq(appointments.petId, pets.id)) + .leftJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where(eq(appointments.groupId, id)) + .orderBy(appointments.startTime); + + if ( + isGroomer && + !groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) { + return c.json({ error: "Forbidden" }, 403); + } + + const [client] = await db + .select({ name: clients.name, email: clients.email }) + .from(clients) + .where(eq(clients.id, group.clientId)); + + return c.json({ ...group, client, appointments: groupAppts }); +}); + +// ─── Create group booking ───────────────────────────────────────────────────── + +appointmentGroupsRouter.post( + "/", + zValidator("json", createGroupSchema), + async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (staffRow?.role === "groomer") { + return c.json( + { error: "Forbidden: groomers cannot create group bookings" }, + 403 + ); + } + const body = c.req.valid("json"); + const startTime = new Date(body.startTime); + + // Verify client exists + const [client] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.id, body.clientId)); + if (!client) return c.json({ error: "Client not found" }, 404); + + // Verify all pets belong to this client + const petIds = body.pets.map((p) => p.petId); + const petRows = await db + .select({ id: pets.id, clientId: pets.clientId }) + .from(pets) + .where(eq(pets.clientId, body.clientId)); + const ownedPetIds = new Set(petRows.map((p) => p.id)); + const unauthorized = petIds.filter((id) => !ownedPetIds.has(id)); + if (unauthorized.length > 0) { + return c.json({ error: `Pet(s) not found for this client: ${unauthorized.join(", ")}` }, 422); + } + + // Deduplicate pets in a single booking + if (new Set(petIds).size !== petIds.length) { + return c.json({ error: "Each pet can only appear once per group booking" }, 422); + } + + try { + const result = await db.transaction(async (tx) => { + // Check conflicts for each staff member + for (const pet of body.pets) { + if (!pet.staffId) continue; + const endTime = new Date(pet.endTime); + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, pet.staffId), + lt(appointments.startTime, endTime), + gte(appointments.endTime, startTime), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign( + new Error(`Staff conflict for pet ${pet.petId}`), + { statusCode: 409, petId: pet.petId, staffId: pet.staffId } + ); + } + } + + // Create the group record + const [group] = await tx + .insert(appointmentGroups) + .values({ clientId: body.clientId, notes: body.notes ?? null }) + .returning(); + if (!group) throw new Error("Failed to create appointment group"); + + // Create one appointment per pet + const createdAppts = []; + for (const pet of body.pets) { + const endTime = new Date(pet.endTime); + const [appt] = await tx + .insert(appointments) + .values({ + clientId: body.clientId, + petId: pet.petId, + serviceId: pet.serviceId, + staffId: pet.staffId ?? null, + startTime, + endTime, + priceCents: pet.priceCents ?? null, + groupId: group.id, + }) + .returning(); + if (appt) createdAppts.push(appt); + } + + return { group, appointments: createdAppts }; + }); + + return c.json(result, 201); + } catch (err: unknown) { + const e = err as Error & { statusCode?: number }; + if (e.statusCode === 409) { + return c.json({ error: "A staff member has a conflicting appointment at this time", detail: e.message }, 409); + } + throw err; + } + } +); + +// ─── Update group notes ─────────────────────────────────────────────────────── + +appointmentGroupsRouter.patch( + "/:id", + zValidator("json", updateGroupSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [group] = await db + .select({ id: appointmentGroups.id }) + .from(appointmentGroups) + .where(eq(appointmentGroups.id, id)); + if (!group) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const groupAppts = await db + .select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId }) + .from(appointments) + .where(eq(appointments.groupId, id)); + if ( + !groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) { + return c.json({ error: "Forbidden" }, 403); + } + } + + const [updated] = await db + .update(appointmentGroups) + .set({ ...body, updatedAt: new Date() }) + .where(eq(appointmentGroups.id, id)) + .returning(); + + if (!updated) return c.json({ error: "Not found" }, 404); + return c.json(updated); + } +); + +// ─── Cancel all appointments in a group ────────────────────────────────────── + +appointmentGroupsRouter.delete("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [group] = await db + .select({ id: appointmentGroups.id }) + .from(appointmentGroups) + .where(eq(appointmentGroups.id, id)); + if (!group) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const groupAppts = await db + .select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId }) + .from(appointments) + .where(eq(appointments.groupId, id)); + if ( + !groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ) { + return c.json({ error: "Forbidden" }, 403); + } + } + + await db + .update(appointments) + .set({ status: "cancelled", updatedAt: new Date() }) + .where(eq(appointments.groupId, id)); + + return c.json({ ok: true }); +}); diff --git a/src/routes/appointments.ts b/src/routes/appointments.ts new file mode 100644 index 0000000..62e65c2 --- /dev/null +++ b/src/routes/appointments.ts @@ -0,0 +1,845 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { randomBytes } from "node:crypto"; +import { + and, + eq, + getDb, + gte, + lt, + lte, + ne, + or, + appointments, + clients, + pets, + recurringSeries, + reminderLogs, + services, + staff, +} from "@groombook/db"; +import { buildConfirmationEmail, sendEmail } from "../services/email.js"; +import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; +import type { AppEnv } from "../middleware/rbac.js"; + +async function withRetry( + fn: () => Promise, + maxRetries: number, + delayMs: number, + context: string +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await fn(); + return; + } catch (err) { + lastError = err; + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + } + console.error(`[appointments] ${context}: ${lastError}`); +} + +export const appointmentsRouter = new Hono(); + +const createAppointmentSchema = z.object({ + clientId: z.string().uuid(), + petId: z.string().uuid(), + serviceId: z.string().uuid(), + staffId: z.string().uuid().optional(), + batherStaffId: z.string().uuid().optional(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), + notes: z.string().max(2000).optional(), + priceCents: z.number().int().positive().optional(), + // Optional recurrence: creates a series of N appointments every frequencyWeeks weeks + recurrence: z + .object({ + frequencyWeeks: z.number().int().min(1).max(52), + count: z.number().int().min(2).max(52), + }) + .refine( + (r) => r.frequencyWeeks * r.count <= 52, + { message: "Recurrence series must not exceed 1 year" } + ) + .optional(), +}); + +const updateAppointmentSchema = z.object({ + staffId: z.string().uuid().nullable().optional(), + batherStaffId: z.string().uuid().nullable().optional(), + status: z + .enum([ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show", + ]) + .optional(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional(), + notes: z.string().max(2000).nullable().optional(), + priceCents: z.number().int().positive().nullable().optional(), + // When updating a series member, optionally propagate the change + cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(), +}); + +// List appointments, optionally filtered by date range or staffId. +// Groomers see only their own appointments (staffId or batherStaffId). +appointmentsRouter.get("/", async (c) => { + const db = getDb(); + const from = c.req.query("from"); + const to = c.req.query("to"); + const staffId = c.req.query("staffId"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const conditions = []; + if (from) conditions.push(gte(appointments.startTime, new Date(from))); + if (to) conditions.push(lte(appointments.startTime, new Date(to))); + if (staffId) conditions.push(eq(appointments.staffId, staffId)); + + // Groomer: restrict to their own appointments (as groomer or bather) + if (isGroomer) { + conditions.push( + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ); + } + + const rows = + conditions.length > 0 + ? await db + .select() + .from(appointments) + .where(and(...conditions)) + .orderBy(appointments.startTime) + : await db + .select() + .from(appointments) + .orderBy(appointments.startTime); + + return c.json(rows); +}); + +appointmentsRouter.get("/:id", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + const [row] = await db + .select() + .from(appointments) + .where(eq(appointments.id, c.req.param("id"))); + if (!row) return c.json({ error: "Not found" }, 404); + // Groomer: 403 if not assigned as groomer or bather + if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) { + return c.json({ error: "Forbidden" }, 403); + } + return c.json(row); +}); + +appointmentsRouter.post( + "/", + zValidator("json", createAppointmentSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const start = new Date(body.startTime); + const end = new Date(body.endTime); + + if (end <= start) { + return c.json({ error: "endTime must be after startTime" }, 422); + } + + const { recurrence, ...apptFields } = body; + + // Wrap conflict check + insert in a transaction to prevent double-booking + // race conditions under concurrent load (fixes #18). + let firstRow: typeof appointments.$inferSelect; + try { + firstRow = await db.transaction(async (tx) => { + // Conflict check applies to the first occurrence only; subsequent + // occurrences are spread weeks apart so conflicts are unlikely and can + // be resolved individually if needed. + if (apptFields.staffId) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, apptFields.staffId), + lt(appointments.startTime, end), + gte(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + if (apptFields.batherStaffId) { + const bathConflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, apptFields.batherStaffId), + eq(appointments.batherStaffId, apptFields.batherStaffId) + ), + lt(appointments.startTime, end), + gte(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (bathConflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + if (!recurrence) { + // Single appointment + const [inserted] = await tx + .insert(appointments) + .values({ ...apptFields, startTime: start, endTime: end }) + .returning(); + if (!inserted) throw new Error("Insert failed"); + return inserted; + } + + // Create recurring series + const seriesRows = await tx + .insert(recurringSeries) + .values({ frequencyWeeks: recurrence.frequencyWeeks }) + .returning(); + const series = seriesRows[0]; + if (!series) throw new Error("Failed to create recurring series"); + + const durationMs = end.getTime() - start.getTime(); + const intervalMs = + recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000; + + let first: typeof appointments.$inferSelect | undefined; + const conflictingInstances: number[] = []; + for (let i = 0; i < recurrence.count; i++) { + const instanceStart = new Date(start.getTime() + i * intervalMs); + const instanceEnd = new Date( + instanceStart.getTime() + durationMs + ); + + if (apptFields.staffId) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, apptFields.staffId), + lt(appointments.startTime, instanceEnd), + gte(appointments.endTime, instanceStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (conflicts.length > 0) { + conflictingInstances.push(i); + } + } + + if (apptFields.batherStaffId) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, apptFields.batherStaffId), + eq(appointments.batherStaffId, apptFields.batherStaffId) + ), + lt(appointments.startTime, instanceEnd), + gte(appointments.endTime, instanceStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (conflicts.length > 0) { + conflictingInstances.push(i); + } + } + + const [inserted] = await tx + .insert(appointments) + .values({ + ...apptFields, + startTime: instanceStart, + endTime: instanceEnd, + seriesId: series.id, + seriesIndex: i, + }) + .returning(); + if (!inserted) throw new Error(`Insert failed for occurrence ${i}`); + if (i === 0) first = inserted; + } + + if (conflictingInstances.length > 0) { + throw Object.assign( + new Error( + `Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}` + ), + { statusCode: 409 } + ); + } + + if (!first) throw new Error("No appointments created"); + return first; + }); + } catch (err: unknown) { + if ( + err instanceof Error && + (err as Error & { statusCode?: number }).statusCode === 409 + ) { + return c.json( + { error: "Staff member has a conflicting appointment at this time" }, + 409 + ); + } + throw err; + } + + // Send confirmation email (fire-and-forget — never fails the request) + withRetry( + () => sendConfirmationEmail(db, firstRow), + 2, + 1000, + `Failed to send confirmation email for appointment ${firstRow.id}` + ); + + return c.json(firstRow, 201); + } +); + +// ─── Confirmation email helper ───────────────────────────────────────────── + +async function sendConfirmationEmail( + db: ReturnType, + appt: typeof appointments.$inferSelect +): Promise { + const [row] = await db + .select({ + clientName: clients.name, + clientEmail: clients.email, + clientEmailOptOut: clients.emailOptOut, + petName: pets.name, + serviceName: services.name, + groomerName: staff.name, + }) + .from(appointments) + .innerJoin(clients, eq(clients.id, appointments.clientId)) + .innerJoin(pets, eq(pets.id, appointments.petId)) + .innerJoin(services, eq(services.id, appointments.serviceId)) + .leftJoin(staff, eq(staff.id, appointments.staffId)) + .where(eq(appointments.id, appt.id)) + .limit(1); + + if (!row) return; + const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row; + + if (!clientEmail || clientEmailOptOut) return; + if (!petName || !serviceName) return; + + const sent = await sendEmail( + buildConfirmationEmail(clientEmail, { + clientName, + petName, + serviceName, + groomerName: groomerName ?? null, + startTime: appt.startTime, + }) + ); + + if (sent) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: "confirmation" }) + .onConflictDoNothing(); + } +} + +appointmentsRouter.patch( + "/:id", + zValidator("json", updateAppointmentSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + const { cascadeMode = "this_only", ...updateFields } = body; + + // ── Cascade update (this_and_future / all) ──────────────────────────────── + if (cascadeMode !== "this_only") { + let row: typeof appointments.$inferSelect | undefined; + try { + row = await db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) { + throw Object.assign(new Error("not found"), { statusCode: 404 }); + } + + // Compute time deltas and apply them uniformly across the series so + // all instances shift by the same amount (e.g. rescheduled 1 hr later). + const startDeltaMs = updateFields.startTime + ? new Date(updateFields.startTime).getTime() - + current.startTime.getTime() + : 0; + const endDeltaMs = updateFields.endTime + ? new Date(updateFields.endTime).getTime() - + current.endTime.getTime() + : 0; + + // Validate resulting times on the anchor appointment + const newStart = new Date( + current.startTime.getTime() + startDeltaMs + ); + const newEnd = new Date(current.endTime.getTime() + endDeltaMs); + if (newEnd <= newStart) { + throw Object.assign(new Error("end before start"), { + statusCode: 422, + }); + } + + // Determine which appointments to update + let whereClause; + if (current.seriesId && current.seriesIndex !== null) { + whereClause = + cascadeMode === "this_and_future" + ? and( + eq(appointments.seriesId, current.seriesId), + gte(appointments.seriesIndex, current.seriesIndex), + ) + : eq(appointments.seriesId, current.seriesId); + } else { + // Not part of a series — fall back to single update + whereClause = eq(appointments.id, id); + } + + const affected = await tx + .select() + .from(appointments) + .where(whereClause); + + let firstUpdated: typeof appointments.$inferSelect | undefined; + for (const appt of affected) { + const newStart = + startDeltaMs !== 0 + ? new Date(appt.startTime.getTime() + startDeltaMs) + : appt.startTime; + const newEnd = + endDeltaMs !== 0 + ? new Date(appt.endTime.getTime() + endDeltaMs) + : appt.endTime; + const newStaffId = + updateFields.staffId !== undefined + ? updateFields.staffId + : appt.staffId; + const newBatherStaffId = + updateFields.batherStaffId !== undefined + ? updateFields.batherStaffId + : appt.batherStaffId; + + if ( + newStaffId && + (startDeltaMs !== 0 || + endDeltaMs !== 0 || + updateFields.staffId !== undefined) + ) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, newStaffId), + lt(appointments.startTime, newEnd), + gte(appointments.endTime, newStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, appt.id), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + if ( + newBatherStaffId && + (startDeltaMs !== 0 || + endDeltaMs !== 0 || + updateFields.batherStaffId !== undefined) + ) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, newBatherStaffId), + eq(appointments.batherStaffId, newBatherStaffId) + ), + lt(appointments.startTime, newEnd), + gte(appointments.endTime, newStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, appt.id), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + const apptUpdate: Record = { + updatedAt: new Date(), + }; + if (updateFields.staffId !== undefined) + apptUpdate.staffId = updateFields.staffId; + if (updateFields.notes !== undefined) + apptUpdate.notes = updateFields.notes; + if (updateFields.status !== undefined) + apptUpdate.status = updateFields.status; + if (updateFields.priceCents !== undefined) + apptUpdate.priceCents = updateFields.priceCents; + if (startDeltaMs !== 0) + apptUpdate.startTime = new Date( + appt.startTime.getTime() + startDeltaMs + ); + if (endDeltaMs !== 0) + apptUpdate.endTime = new Date( + appt.endTime.getTime() + endDeltaMs + ); + + const [updated] = await tx + .update(appointments) + .set(apptUpdate) + .where(eq(appointments.id, appt.id)) + .returning(); + if (appt.id === id) firstUpdated = updated; + } + + return firstUpdated; + }); + } catch (err: unknown) { + const statusCode = (err as Error & { statusCode?: number }).statusCode; + if (statusCode === 404) return c.json({ error: "Not found" }, 404); + if (statusCode === 422) + return c.json({ error: "endTime must be after startTime" }, 422); + if (statusCode === 409) + return c.json( + { + error: "Staff member has a conflicting appointment at this time", + }, + 409 + ); + throw err; + } + + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } + + // ── this_only (original logic) ──────────────────────────────────────────── + const needsConflictCheck = + updateFields.startTime !== undefined || + updateFields.endTime !== undefined || + updateFields.staffId !== undefined || + updateFields.batherStaffId !== undefined; + + const update: Record = { + ...updateFields, + updatedAt: new Date(), + }; + if (updateFields.startTime) update.startTime = new Date(updateFields.startTime); + if (updateFields.endTime) update.endTime = new Date(updateFields.endTime); + + if (needsConflictCheck) { + // Wrap conflict check + update in a transaction to prevent race conditions + // (fixes #18). Also falls back to the existing staffId when staffId is + // omitted from the request, so rescheduling always checks conflicts (fixes #19). + let row: typeof appointments.$inferSelect | undefined; + try { + row = await db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) { + throw Object.assign(new Error("not found"), { statusCode: 404 }); + } + + const start = updateFields.startTime + ? new Date(updateFields.startTime) + : current.startTime; + const end = updateFields.endTime + ? new Date(updateFields.endTime) + : current.endTime; + // Use provided staffId (may be null to unassign); fall back to existing + const staffId = + updateFields.staffId !== undefined + ? updateFields.staffId + : current.staffId; + // Use provided batherStaffId (may be null to unassign); fall back to existing + const batherStaffId = + updateFields.batherStaffId !== undefined + ? updateFields.batherStaffId + : current.batherStaffId; + + if (end <= start) { + throw Object.assign(new Error("end before start"), { + statusCode: 422, + }); + } + + if (staffId) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, staffId), + lt(appointments.startTime, end), + gte(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, id), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + if (batherStaffId) { + const bathConflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, batherStaffId), + eq(appointments.batherStaffId, batherStaffId) + ), + lt(appointments.startTime, end), + gte(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, id), + ) + ) + .limit(1); + if (bathConflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + const [updated] = await tx + .update(appointments) + .set(update) + .where(eq(appointments.id, id)) + .returning(); + return updated; + }); + } catch (err: unknown) { + const statusCode = (err as Error & { statusCode?: number }).statusCode; + if (statusCode === 404) return c.json({ error: "Not found" }, 404); + if (statusCode === 422) + return c.json({ error: "endTime must be after startTime" }, 422); + if (statusCode === 409) + return c.json( + { + error: "Staff member has a conflicting appointment at this time", + }, + 409 + ); + throw err; + } + + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } + + const [row] = await db + .update(appointments) + .set(update) + .where(eq(appointments.id, id)) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +// Soft-delete: cancel the appointment instead of removing the row, +// preserving audit trail and financial records (fixes #20). +// Optional ?cascade=this_only|this_and_future|all for series appointments. +appointmentsRouter.delete("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const cascade = c.req.query("cascade") ?? "this_only"; + + if (cascade === "this_and_future" || cascade === "all") { + const [current] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) return c.json({ error: "Not found" }, 404); + + if (current.seriesId && current.seriesIndex !== null) { + const whereClause = + cascade === "this_and_future" + ? and( + eq(appointments.seriesId, current.seriesId), + gte(appointments.seriesIndex, current.seriesIndex), + ) + : eq(appointments.seriesId, current.seriesId); + await db + .update(appointments) + .set({ status: "cancelled", updatedAt: new Date() }) + .where(whereClause); + } else { + // Not in a series — cancel only this one + await db + .update(appointments) + .set({ status: "cancelled", updatedAt: new Date() }) + .where(eq(appointments.id, id)); + } + + const apptDate = current.startTime.toISOString().slice(0, 10); + const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); + withRetry( + () => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), + 2, + 1000, + `Failed to notify waitlist for appointment ${id}` + ); + + return c.json({ ok: true }); + } + + // Single cancel (default) + const [current] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) return c.json({ error: "Not found" }, 404); + + const apptDate = current.startTime.toISOString().slice(0, 10); + const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); + + const [row] = await db + .update(appointments) + .set({ status: "cancelled", updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + + withRetry( + () => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), + 2, + 1000, + `Failed to notify waitlist for appointment ${id}` + ); + + return c.json({ ok: true }); +}); + +// ─── POST /api/appointments/:id/confirm ─────────────────────────────────────── +// Staff/portal: confirm a specific appointment by ID. Idempotent. + +appointmentsRouter.post("/:id/confirm", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) return c.json({ error: "Not found" }, 404); + + if (appt.confirmationStatus === "cancelled") { + return c.json({ error: "Cannot confirm a cancelled appointment" }, 409); + } + + if (appt.confirmationStatus === "confirmed") { + return c.json(appt); // idempotent + } + + const [updated] = await db + .update(appointments) + .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json(updated); +}); + +// ─── POST /api/appointments/:id/cancel ─────────────────────────────────────── +// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified. + +appointmentsRouter.post("/:id/cancel", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) return c.json({ error: "Not found" }, 404); + + if (appt.confirmationStatus === "cancelled") { + return c.json({ error: "Appointment is already cancelled" }, 409); + } + + const [updated] = await db + .update(appointments) + .set({ + confirmationStatus: "cancelled", + cancelledAt: new Date(), + confirmationToken: null, + updatedAt: new Date(), + }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json(updated); +}); + +// ─── Token generation helper ────────────────────────────────────────────────── + +export function generateConfirmationToken(): string { + return randomBytes(32).toString("hex"); +} diff --git a/src/routes/authProvider.ts b/src/routes/authProvider.ts new file mode 100644 index 0000000..e53e909 --- /dev/null +++ b/src/routes/authProvider.ts @@ -0,0 +1,179 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db"; +import { requireSuperUser } from "../middleware/rbac.js"; +import { reinitAuth } from "../lib/auth.js"; + +export const authProviderRouter = new Hono(); + +const REDACTED = "••••••••"; + +const putAuthProviderSchema = z.object({ + providerId: z.string().min(1).max(100), + displayName: z.string().min(1).max(200), + issuerUrl: z.string().url(), + internalBaseUrl: z.string().url().nullable().optional(), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + scopes: z.string().default("openid profile email"), +}); + +/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */ +const authProviderTestSchema = z.object({ + issuerUrl: z.string().url(), + internalBaseUrl: z.string().url().nullable().optional(), +}); + +/** + * GET /api/admin/auth-provider + * Returns the current provider config with clientSecret redacted. + * Returns 404 if no provider is configured. + */ +authProviderRouter.get( + "/", + requireSuperUser(), + async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + if (!row) { + return c.json({ error: "No auth provider configured" }, 404); + } + + // Return with secret redacted + return c.json({ + id: row.id, + providerId: row.providerId, + displayName: row.displayName, + issuerUrl: row.issuerUrl, + internalBaseUrl: row.internalBaseUrl, + clientId: row.clientId, + clientSecret: REDACTED, + scopes: row.scopes, + enabled: row.enabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + } +); + +/** + * PUT /api/admin/auth-provider + * Creates or replaces the auth provider config. + * The clientSecret is encrypted before storage. + */ +authProviderRouter.put( + "/", + requireSuperUser(), + zValidator("json", putAuthProviderSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + +let encryptedSecret: string; + try { + encryptedSecret = encryptSecret(body.clientSecret); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: `Failed to encrypt client secret: ${message}` }, 500); + } + + // Upsert: delete existing rows then insert atomically + let row: typeof authProviderConfig.$inferSelect | undefined; + try { + [row] = await db.transaction(async (tx) => { + await tx.delete(authProviderConfig); + return tx.insert(authProviderConfig).values({ + providerId: body.providerId, + displayName: body.displayName, + issuerUrl: body.issuerUrl, + internalBaseUrl: body.internalBaseUrl ?? null, + clientId: body.clientId, + clientSecret: encryptedSecret, + scopes: body.scopes, + enabled: true, + }).returning(); + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: `Failed to persist auth provider config: ${message}` }, 500); + } + + if (!row) return c.json({ error: "Failed to create auth provider config" }, 500); + + try { + await reinitAuth(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500); + } + + return c.json({ + id: row.id, + providerId: row.providerId, + displayName: row.displayName, + issuerUrl: row.issuerUrl, + internalBaseUrl: row.internalBaseUrl, + clientId: row.clientId, + clientSecret: REDACTED, + scopes: row.scopes, + enabled: row.enabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + } +); + +/** + * POST /api/admin/auth-provider/test + * Validates the provider config by hitting the OIDC discovery endpoint. + * Returns {ok: true, metadata} on success or {ok: false, error: string} on failure. + */ +authProviderRouter.post( + "/test", + requireSuperUser(), + zValidator("json", authProviderTestSchema), + async (c) => { + const body = c.req.valid("json"); + + const discoveryUrl = `${body.issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`; + + try { + const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) { + return c.json({ ok: false, error: `Discovery endpoint returned ${res.status}` }); + } + const metadata = await res.json() as Record; + return c.json({ ok: true, metadata }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return c.json({ ok: false, error: message }); + } + } +); + +/** + * DELETE /api/admin/auth-provider + * Removes the auth provider config from the DB. + * After this, auth falls back to OIDC_* env vars. + */ +authProviderRouter.delete( + "/", + requireSuperUser(), + async (c) => { + const db = getDb(); + await db.delete(authProviderConfig); + try { + await reinitAuth(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500); + } + return c.json({ ok: true }); + } +); diff --git a/src/routes/book.ts b/src/routes/book.ts new file mode 100644 index 0000000..828c11c --- /dev/null +++ b/src/routes/book.ts @@ -0,0 +1,355 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + gt, + gte, + lt, + ne, + getDb, + services, + staff, + appointments, + clients, + pets, +} from "@groombook/db"; +import { + generateAvailableSlots, + BUSINESS_START_HOUR, + BUSINESS_END_HOUR, +} from "../lib/slots.js"; + +export const bookRouter = new Hono(); + +// ─── GET /api/book/services ───────────────────────────────────────────────── +// Public: list active services for the booking flow + +bookRouter.get("/services", async (c) => { + const db = getDb(); + const rows = await db + .select() + .from(services) + .where(eq(services.active, true)) + .orderBy(services.name); + return c.json(rows); +}); + +// ─── GET /api/book/availability ───────────────────────────────────────────── +// Public: return ISO startTime strings for slots where ≥1 groomer is free +// Query params: serviceId (uuid), date (YYYY-MM-DD) + +bookRouter.get("/availability", async (c) => { + const serviceId = c.req.query("serviceId"); + const dateStr = c.req.query("date"); + + if (!serviceId || !dateStr) { + return c.json({ error: "serviceId and date are required" }, 400); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return c.json({ error: "date must be YYYY-MM-DD" }, 400); + } + + const db = getDb(); + const [service] = await db + .select() + .from(services) + .where(and(eq(services.id, serviceId), eq(services.active, true))); + if (!service) return c.json({ error: "Service not found" }, 404); + + const groomers = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.active, true), eq(staff.role, "groomer"))); + + if (groomers.length === 0) return c.json([]); + + const dayStart = new Date(`${dateStr}T00:00:00Z`); + dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0); + const dayEnd = new Date(`${dateStr}T00:00:00Z`); + dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0); + + // Fetch all active appointments for the day (any groomer) + const booked = await db + .select({ + staffId: appointments.staffId, + startTime: appointments.startTime, + endTime: appointments.endTime, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, dayStart), + lt(appointments.startTime, dayEnd), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ); + + const slots = generateAvailableSlots({ + dateStr, + durationMinutes: service.durationMinutes, + groomerIds: groomers.map((g) => g.id), + booked, + }); + + return c.json(slots); +}); + +// ─── POST /api/book/appointments ───────────────────────────────────────────── +// Public: create a booking. Finds or creates client by email, always creates pet. + +const bookingSchema = z.object({ + serviceId: z.string().uuid(), + startTime: z.string().datetime().refine( + (dt) => new Date(dt) > new Date(), + { message: "Appointment must be in the future" } + ), + clientName: z.string().min(1).max(200), + clientEmail: z.string().email(), + clientPhone: z.string().max(50).optional(), + petName: z.string().min(1).max(200), + petSpecies: z.string().min(1).max(100), + petBreed: z.string().max(100).optional(), + petSizeCategory: z.string().max(50).optional(), + petCoatType: z.string().max(50).optional(), + notes: z.string().max(2000).optional(), +}); + +bookRouter.post( + "/appointments", + zValidator("json", bookingSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const start = new Date(body.startTime); + + const [service] = await db + .select() + .from(services) + .where(and(eq(services.id, body.serviceId), eq(services.active, true))); + if (!service) return c.json({ error: "Service not found" }, 404); + + const end = new Date(start.getTime() + service.durationMinutes * 60_000); + + // Find all active groomers + const groomers = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.active, true), eq(staff.role, "groomer"))); + + if (groomers.length === 0) { + return c.json({ error: "No groomers available" }, 409); + } + + // Find conflicting appointments for this time window + const booked = await db + .select({ staffId: appointments.staffId }) + .from(appointments) + .where( + and( + lt(appointments.startTime, end), + gt(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ); + + const busyIds = new Set(booked.map((a) => a.staffId)); + const freeGroomer = groomers.find(({ id }) => !busyIds.has(id)); + if (!freeGroomer) { + return c.json( + { error: "No groomers available at this time. Please choose another slot." }, + 409 + ); + } + + // Find or create client by email (skip disabled clients) + let [client] = await db + .select() + .from(clients) + .where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active"))); + + if (!client) { + const inserted = await db + .insert(clients) + .values({ + name: body.clientName, + email: body.clientEmail, + phone: body.clientPhone ?? null, + }) + .returning(); + client = inserted[0]; + } + + if (!client) return c.json({ error: "Failed to create client" }, 500); + + // Create pet + const petInserted = await db + .insert(pets) + .values({ + clientId: client.id, + name: body.petName, + species: body.petSpecies, + breed: body.petBreed ?? null, + coatType: (body.petCoatType ?? null) as "smooth" | "double" | "wire" | "curly" | "long" | "hairless" | null, + petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "xlarge" | null, + }) + .returning(); + const pet = petInserted[0]; + if (!pet) return c.json({ error: "Failed to create pet" }, 500); + + // Insert appointment in a transaction to guard against race conditions + let appointment; + try { + appointment = await db.transaction(async (tx) => { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, freeGroomer.id), + lt(appointments.startTime, end), + gt(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + + const apptInserted = await tx + .insert(appointments) + .values({ + clientId: client.id, + petId: pet.id, + serviceId: body.serviceId, + staffId: freeGroomer.id, + startTime: start, + endTime: end, + notes: body.notes ?? null, + }) + .returning(); + return apptInserted[0]; + }); + } catch (err: unknown) { + const code = (err as Error & { statusCode?: number }).statusCode; + if (code === 409) { + return c.json( + { error: "This slot was just taken. Please choose another time." }, + 409 + ); + } + throw err; + } + + if (!appointment) return c.json({ error: "Failed to create appointment" }, 500); + + return c.json({ appointment, client, pet }, 201); + } +); + +// ─── GET /api/book/confirm/:token ────────────────────────────────────────── +// Public: confirm appointment via tokenized email link. Redirects to success/error page. + +const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173"; + +bookRouter.get("/confirm/:token", async (c) => { + const token = c.req.param("token"); + const db = getDb(); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.confirmationToken, token)) + .limit(1); + + if (!appt) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + if (appt.startTime < new Date()) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + if (appt.confirmationStatus === "confirmed") { + return c.redirect(`${BASE_URL()}/booking/confirmed`); + } + + if (appt.confirmationStatus === "cancelled") { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + const updated = await db + .update(appointments) + .set({ + confirmationStatus: "confirmed", + confirmedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(appointments.confirmationToken, token), + eq(appointments.confirmationStatus, "pending") + ) + ) + .returning(); + + if (updated.length === 0) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + return c.redirect(`${BASE_URL()}/booking/confirmed`); +}); + +// ─── GET /api/book/cancel/:token ─────────────────────────────────────────── +// Public: cancel appointment via tokenized email link. Redirects to success/error page. + +bookRouter.get("/cancel/:token", async (c) => { + const token = c.req.param("token"); + const db = getDb(); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.confirmationToken, token)) + .limit(1); + + if (!appt) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + if (appt.startTime < new Date()) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + if (appt.confirmationStatus === "cancelled") { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + const updated = await db + .update(appointments) + .set({ + confirmationStatus: "cancelled", + cancelledAt: new Date(), + confirmationToken: null, + updatedAt: new Date(), + }) + .where( + and( + eq(appointments.confirmationToken, token), + eq(appointments.confirmationStatus, "pending") + ) + ) + .returning(); + + if (updated.length === 0) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + return c.redirect(`${BASE_URL()}/booking/cancelled`); +}); diff --git a/src/routes/buffer-rules.ts b/src/routes/buffer-rules.ts new file mode 100644 index 0000000..fb3ff2b --- /dev/null +++ b/src/routes/buffer-rules.ts @@ -0,0 +1,128 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + isNull, + getDb, + bufferRules, + services, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; +import { requireRole } from "../middleware/rbac.js"; + +export const bufferRulesRouter = new Hono(); + +// Apply manager role guard to all routes +bufferRulesRouter.use("*", requireRole("manager")); + +const createBufferRuleSchema = z.object({ + serviceId: z.string().uuid(), + sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(), + coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(), + bufferMinutes: z.number().int().positive(), +}); + +const updateBufferRuleSchema = z.object({ + bufferMinutes: z.number().int().positive(), +}); + +bufferRulesRouter.get("/", async (c) => { + const db = getDb(); + const serviceId = c.req.query("serviceId"); + + const conditions = []; + if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId)); + + const rows = await db + .select({ + id: bufferRules.id, + serviceId: bufferRules.serviceId, + serviceName: services.name, + sizeCategory: bufferRules.sizeCategory, + coatType: bufferRules.coatType, + bufferMinutes: bufferRules.bufferMinutes, + createdAt: bufferRules.createdAt, + updatedAt: bufferRules.updatedAt, + }) + .from(bufferRules) + .leftJoin(services, eq(bufferRules.serviceId, services.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined); + + return c.json(rows); +}); + +bufferRulesRouter.post( + "/", + zValidator("json", createBufferRuleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // Validate serviceId exists + const [svc] = await db + .select({ id: services.id }) + .from(services) + .where(eq(services.id, body.serviceId)) + .limit(1); + if (!svc) return c.json({ error: "Service not found" }, 404); + + // Check for duplicate — sizeCategory/coatType are nullable, use isNull for null check + const conditions = [eq(bufferRules.serviceId, body.serviceId)]; + if (body.sizeCategory) { + conditions.push(eq(bufferRules.sizeCategory, body.sizeCategory)); + } else { + conditions.push(isNull(bufferRules.sizeCategory)); + } + if (body.coatType) { + conditions.push(eq(bufferRules.coatType, body.coatType)); + } else { + conditions.push(isNull(bufferRules.coatType)); + } + const [existing] = await db + .select({ id: bufferRules.id }) + .from(bufferRules) + .where(and(...conditions)) + .limit(1); + if (existing) return c.json({ error: "Duplicate rule for this service and attributes" }, 409); + + const [row] = await db + .insert(bufferRules) + .values({ + serviceId: body.serviceId, + sizeCategory: body.sizeCategory, + coatType: body.coatType, + bufferMinutes: body.bufferMinutes, + }) + .returning(); + + return c.json(row, 201); + } +); + +bufferRulesRouter.patch( + "/:id", + zValidator("json", updateBufferRuleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db + .update(bufferRules) + .set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() }) + .where(eq(bufferRules.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +bufferRulesRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(bufferRules) + .where(eq(bufferRules.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); \ No newline at end of file diff --git a/src/routes/calendar.ts b/src/routes/calendar.ts new file mode 100644 index 0000000..ff45842 --- /dev/null +++ b/src/routes/calendar.ts @@ -0,0 +1,137 @@ +import { Hono } from "hono"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + and, + eq, + gte, + getDb, + appointments, + clients, + pets, + services, + staff, +} from "@groombook/db"; + +export const calendarRouter = new Hono(); + +function formatIcalDate(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); +} + +function escapeIcalText(text: string | null): string { + if (!text) return ""; + return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +} + +function buildIcalFeed( + appointments: Array<{ + id: string; + startTime: Date; + endTime: Date; + status: string; + clientName: string | null; + petName: string | null; + serviceName: string | null; + }>, + staffName: string, + dtstamp: string +): string { + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//GroomBook//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${escapeIcalText(staffName)} - GroomBook`, + ]; + + for (const appt of appointments) { + const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED"; + const sequence = appt.status === "cancelled" ? "1" : "0"; + const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`; + const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`; + + lines.push( + "BEGIN:VEVENT", + `UID:${appt.id}@groombook`, + `DTSTAMP:${dtstamp}`, + `DTSTART:${formatIcalDate(new Date(appt.startTime))}`, + `DTEND:${formatIcalDate(new Date(appt.endTime))}`, + `SUMMARY:${escapeIcalText(summary)}`, + `DESCRIPTION:${escapeIcalText(description)}`, + `STATUS:${status}`, + `SEQUENCE:${sequence}`, + "END:VEVENT" + ); + } + + lines.push("END:VCALENDAR"); + return lines.join("\r\n"); +} + +calendarRouter.get("/:staffId.ics", async (c) => { + const db = getDb(); + const staffId = c.req.param("staffId") as string; + const token = c.req.query("token") as string; + + if (!token) { + return c.text("Unauthorized", 401); + } + + const [staffMember] = await db + .select() + .from(staff) + .where(eq(staff.id, staffId)) + .limit(1); + + if (!staffMember || !staffMember.icalToken) { + return c.text("Unauthorized", 401); + } + + const storedToken = staffMember.icalToken; + const incomingToken = token; + const storedBuf = Buffer.from(storedToken, "utf8"); + const incomingBuf = Buffer.from(incomingToken, "utf8"); + if ( + storedBuf.length !== incomingBuf.length || + !timingSafeEqual(storedBuf, incomingBuf) + ) { + return c.text("Unauthorized", 401); + } + + const now = new Date(); + const rows = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + clientId: appointments.clientId, + petId: appointments.petId, + serviceId: appointments.serviceId, + clientName: clients.name, + petName: pets.name, + serviceName: services.name, + }) + .from(appointments) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .innerJoin(pets, eq(appointments.petId, pets.id)) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .where( + and( + eq(appointments.staffId, staffId), + gte(appointments.startTime, now) + ) + ) + .orderBy(appointments.startTime); + + const ical = buildIcalFeed(rows, staffMember.name, formatIcalDate(new Date())); + return c.text(ical, 200, { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`, + }); +}); + +export function generateIcalToken(): string { + return randomBytes(32).toString("hex"); +} diff --git a/src/routes/clients.ts b/src/routes/clients.ts new file mode 100644 index 0000000..38104ec --- /dev/null +++ b/src/routes/clients.ts @@ -0,0 +1,168 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const clientsRouter = new Hono(); + +const createClientSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().email(), + phone: z.string().max(50).optional(), + address: z.string().max(500).optional(), + notes: z.string().max(2000).optional(), + smsOptIn: z.boolean().optional(), + smsConsentText: z.string().max(1000).optional(), +}); + + +// List clients — defaults to active only, ?includeDisabled=true shows all. +// Groomers see only clients with ≥1 appointment assigned to them. +clientsRouter.get("/", async (c) => { + const db = getDb(); + const includeDisabled = c.req.query("includeDisabled") === "true"; + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + // Groomer: subquery for clients with an appointment for this groomer + const groomerApptFilter = isGroomer + ? exists( + db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, clients.id), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + ) + : undefined; + + const conditions = []; + if (!includeDisabled) conditions.push(eq(clients.status, "active")); + if (groomerApptFilter) conditions.push(groomerApptFilter); + + const rows = await db + .select() + .from(clients) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(clients.name); + return c.json(rows); +}); + +// Get a single client +clientsRouter.get("/:id", async (c) => { + const db = getDb(); + const clientId = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + const [row] = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)); + if (!row) return c.json({ error: "Not found" }, 404); + // Groomer: 403 if no appointment linkage to this client + if (isGroomer) { + 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); + if (!linkage) return c.json({ error: "Forbidden" }, 403); + } + return c.json(row); +}); + +// Create a client +clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db.insert(clients).values(body).returning(); + return c.json(row, 201); +}); + +// Update a client (including status changes) +const patchClientSchema = createClientSchema.partial().extend({ + status: z.enum(["active", "disabled"]).optional(), + smsOptOut: z.boolean().optional(), +}); + +clientsRouter.patch( + "/:id", + zValidator("json", patchClientSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const now = new Date(); + + const setValues: Record = { ...body, updatedAt: now }; + + if (body.status === "disabled") { + setValues.disabledAt = now; + } else if (body.status === "active") { + setValues.disabledAt = null; + } + + if (body.smsOptOut === true) { + setValues.smsOptIn = false; + setValues.smsOptOutDate = now; + delete setValues.smsOptOut; + } + delete setValues.smsOptOut; + + const [row] = await db + .update(clients) + .set(setValues) + .where(eq(clients.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +// Delete a client — requires ?confirm=true query param +clientsRouter.delete("/:id", async (c) => { + const confirm = c.req.query("confirm"); + if (confirm !== "true") { + return c.json( + { error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." }, + 400 + ); + } + + const db = getDb(); + const clientId = c.req.param("id"); + + const [existingAppt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where(eq(appointments.clientId, clientId)) + .limit(1); + + if (existingAppt) { + return c.json( + { error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." }, + 409 + ); + } + + const [row] = await db + .delete(clients) + .where(eq(clients.id, clientId)) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/src/routes/dev.ts b/src/routes/dev.ts new file mode 100644 index 0000000..363da85 --- /dev/null +++ b/src/routes/dev.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; +import { getDb, staff, clients, eq, sql } from "@groombook/db"; + +const devRouter = new Hono(); + +// GET /api/dev/config — tells the frontend whether auth is disabled +devRouter.get("/config", (c) => { + return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" }); +}); + +// GET /api/dev/users — list staff and clients for the login selector +// Only available when AUTH_DISABLED=true +devRouter.get("/users", async (c) => { + if (process.env.AUTH_DISABLED !== "true") { + return c.json({ error: "Not available when auth is enabled" }, 403); + } + + const db = getDb(); + + const staffList = await db + .select({ + id: staff.id, + userId: staff.userId, + name: staff.name, + email: staff.email, + role: staff.role, + }) + .from(staff) + .where(eq(staff.active, true)) + .orderBy(staff.name); + + const clientList = await db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + petCount: sql`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"), + }) + .from(clients) + .orderBy(clients.name) + .limit(20); + + return c.json({ staff: staffList, clients: clientList }); +}); + +export { devRouter }; diff --git a/src/routes/groomingLogs.ts b/src/routes/groomingLogs.ts new file mode 100644 index 0000000..1f7f85a --- /dev/null +++ b/src/routes/groomingLogs.ts @@ -0,0 +1,143 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const groomingLogsRouter = new Hono(); + +const createLogSchema = z.object({ + petId: z.string().uuid(), + appointmentId: z.string().uuid().optional(), + staffId: z.string().uuid().optional(), + cutStyle: z.string().max(500).optional(), + productsUsed: z.string().max(1000).optional(), + notes: z.string().max(2000).optional(), + groomedAt: z.string().datetime().optional(), +}); + +// GET /api/grooming-logs?petId= +groomingLogsRouter.get("/", async (c) => { + const db = getDb(); + const petId = c.req.query("petId"); + if (!petId) return c.json({ error: "petId is required" }, 400); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + if (isGroomer) { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } + + const rows = await db + .select() + .from(groomingVisitLogs) + .where(eq(groomingVisitLogs.petId, petId)) + .orderBy(desc(groomingVisitLogs.groomedAt)); + return c.json(rows); +}); + +groomingLogsRouter.post( + "/", + zValidator("json", createLogSchema), + async (c) => { + const db = getDb(); + const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + if (isGroomer) { + if (appointmentId) { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.id, appointmentId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } else { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } + } + + const [row] = await db + .insert(groomingVisitLogs) + .values({ + ...rest, + petId, + appointmentId: appointmentId ?? null, + groomedAt: groomedAt ? new Date(groomedAt) : new Date(), + }) + .returning(); + return c.json(row, 201); + } +); + +groomingLogsRouter.delete("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [log] = await db + .select() + .from(groomingVisitLogs) + .where(eq(groomingVisitLogs.id, id)) + .limit(1); + if (!log) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const [appt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, log.petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!appt) return c.json({ error: "Forbidden" }, 403); + } + + await db + .delete(groomingVisitLogs) + .where(eq(groomingVisitLogs.id, id)) + .returning(); + return c.json({ ok: true }); +}); diff --git a/src/routes/impersonation.ts b/src/routes/impersonation.ts new file mode 100644 index 0000000..350f086 --- /dev/null +++ b/src/routes/impersonation.ts @@ -0,0 +1,300 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + getDb, + impersonationSessions, + impersonationAuditLogs, + clients, + desc, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const impersonationRouter = new Hono(); + +const SESSION_TIMEOUT_MINUTES = 30; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) { + return new Date(Date.now() + minutes * 60_000); +} + +/** Expire any timed-out active sessions for a given staff member. */ +async function expireTimedOutSessions(staffId: string) { + const db = getDb(); + const now = new Date(); + const active = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.staffId, staffId), + eq(impersonationSessions.status, "active") + ) + ); + for (const s of active) { + if (s.expiresAt <= now) { + await db + .update(impersonationSessions) + .set({ status: "expired", endedAt: now }) + .where(eq(impersonationSessions.id, s.id)); + } + } +} + +/** + * Check if an active session has expired by time. If so, mark it expired in DB + * and return true. Returns false if the session is still valid. + */ +async function checkAndExpireSession( + session: typeof impersonationSessions.$inferSelect +): Promise { + if (session.status !== "active") return false; + if (session.expiresAt > new Date()) return false; + const db = getDb(); + const now = new Date(); + await db + .update(impersonationSessions) + .set({ status: "expired", endedAt: now }) + .where(eq(impersonationSessions.id, session.id)); + return true; +} + +// ─── POST /sessions — Start a new impersonation session ───────────────────── +// requireRole("manager") is enforced by index.ts middleware on /impersonation/* + +const startSessionSchema = z.object({ + clientId: z.string().uuid(), + reason: z.string().max(500).optional(), +}); + +impersonationRouter.post( + "/sessions", + zValidator("json", startSessionSchema), + async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + const body = c.req.valid("json"); + + // Verify client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, body.clientId)); + if (!client) return c.json({ error: "Client not found" }, 404); + + // Expire timed-out sessions first + await expireTimedOutSessions(staffRow.id); + + // Enforce one active session per staff member + const [existing] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.staffId, staffRow.id), + eq(impersonationSessions.status, "active") + ) + ); + if (existing) { + return c.json( + { error: "You already have an active impersonation session", sessionId: existing.id }, + 409 + ); + } + + const [session] = await db + .insert(impersonationSessions) + .values({ + staffId: staffRow.id, + clientId: body.clientId, + reason: body.reason ?? null, + expiresAt: expiresAt(), + }) + .returning(); + + // Log session start + await db.insert(impersonationAuditLogs).values({ + sessionId: session!.id, + action: "session_started", + metadata: { reason: body.reason ?? null }, + }); + + return c.json(session!, 201); + } +); + +// ─── GET /sessions/:id — Get session details ──────────────────────────────── + +impersonationRouter.get("/sessions/:id", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + + // Auto-expire if timed out + if (await checkAndExpireSession(session)) { + session.status = "expired"; + session.endedAt = new Date(); + } + + return c.json(session); +}); + +// ─── POST /sessions/:id/extend — Extend session timeout ───────────────────── + +impersonationRouter.post("/sessions/:id/extend", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + if (session.status !== "active") { + return c.json({ error: "Session is not active" }, 400); + } + + // Check time-based expiry + if (await checkAndExpireSession(session)) { + return c.json({ error: "Session has expired" }, 400); + } + + const newExpiry = expiresAt(); + const [updated] = await db + .update(impersonationSessions) + .set({ expiresAt: newExpiry }) + .where(eq(impersonationSessions.id, session.id)) + .returning(); + + await db.insert(impersonationAuditLogs).values({ + sessionId: session.id, + action: "session_extended", + metadata: { newExpiresAt: newExpiry.toISOString() }, + }); + + return c.json(updated); +}); + +// ─── POST /sessions/:id/end — End session ──────────────────────────────────── + +impersonationRouter.post("/sessions/:id/end", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + if (session.status !== "active") { + return c.json({ error: "Session is not active" }, 400); + } + + // Check time-based expiry + if (await checkAndExpireSession(session)) { + return c.json({ error: "Session has expired" }, 400); + } + + const now = new Date(); + const [updated] = await db + .update(impersonationSessions) + .set({ status: "ended", endedAt: now }) + .where(eq(impersonationSessions.id, session.id)) + .returning(); + + await db.insert(impersonationAuditLogs).values({ + sessionId: session.id, + action: "session_ended", + }); + + return c.json(updated); +}); + +// ─── POST /sessions/:id/log — Log an audit entry ──────────────────────────── + +const logEntrySchema = z.object({ + action: z.string().min(1).max(200), + pageVisited: z.string().max(500).optional(), + metadata: z.record(z.unknown()).optional(), +}); + +impersonationRouter.post( + "/sessions/:id/log", + zValidator("json", logEntrySchema), + async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + const body = c.req.valid("json"); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + if (session.status !== "active") { + return c.json({ error: "Session is not active" }, 400); + } + + // Check time-based expiry + if (await checkAndExpireSession(session)) { + return c.json({ error: "Session has expired" }, 400); + } + + const [entry] = await db + .insert(impersonationAuditLogs) + .values({ + sessionId: session.id, + action: body.action, + pageVisited: body.pageVisited ?? null, + metadata: body.metadata ?? null, + }) + .returning(); + + return c.json(entry, 201); + } +); + +// ─── GET /sessions/:id/audit-log — Get audit trail ────────────────────────── + +impersonationRouter.get("/sessions/:id/audit-log", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + + const logs = await db + .select() + .from(impersonationAuditLogs) + .where(eq(impersonationAuditLogs.sessionId, session.id)) + .orderBy(desc(impersonationAuditLogs.createdAt)); + + return c.json(logs); +}); diff --git a/src/routes/invoices.ts b/src/routes/invoices.ts new file mode 100644 index 0000000..91ac4ee --- /dev/null +++ b/src/routes/invoices.ts @@ -0,0 +1,571 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + getDb, + invoices, + invoiceLineItems, + invoiceTipSplits, + refunds, + appointments, + services, + clients, + sql, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const invoicesRouter = new Hono(); + +// Convert Zod validation errors from 422 to 400 +invoicesRouter.onError((err, c) => { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation failed", issues: err.issues }, 400); + } + throw err; +}); + +const createInvoiceSchema = z.object({ + appointmentId: z.string().uuid().optional(), + clientId: z.string().uuid(), + lineItems: z + .array( + z.object({ + description: z.string().min(1).max(500), + quantity: z.number().int().positive().default(1), + unitPriceCents: z.number().int().nonnegative(), + }) + ) + .min(1), + taxCents: z.number().int().nonnegative().default(0), + tipCents: z.number().int().nonnegative().default(0), + notes: z.string().max(2000).optional(), +}); + +const updateInvoiceSchema = z.object({ + status: z.enum(["draft", "pending", "paid", "void"]).optional(), + paymentMethod: z.enum(["cash", "card", "check", "other"]).nullable().optional(), + paidAt: z.string().datetime().nullable().optional(), + taxCents: z.number().int().nonnegative().optional(), + tipCents: z.number().int().nonnegative().optional(), + notes: z.string().max(2000).nullable().optional(), + tipSplits: z.array( + z.object({ + staffId: z.string().uuid().nullable(), + staffName: z.string().min(1).max(200), + sharePct: z.number().min(0).max(100), + }) + ).optional(), +}); + +// List invoices +const listInvoicesQuerySchema = z.object({ + clientId: z.string().uuid().optional(), + appointmentId: z.string().uuid().optional(), + status: z.enum(["draft", "pending", "paid", "void"]).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +invoicesRouter.get( + "/", + zValidator("query", listInvoicesQuerySchema), + async (c) => { + const db = getDb(); + const { clientId, appointmentId, status, limit, offset } = c.req.valid("query"); + + const conditions = []; + if (clientId) conditions.push(eq(invoices.clientId, clientId)); + if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); + if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ count: sql`count(*)` }) + .from(invoices) + .where(whereClause); + + const rows = await db + .select({ + id: invoices.id, + appointmentId: invoices.appointmentId, + clientId: invoices.clientId, + clientName: clients.name, + subtotalCents: invoices.subtotalCents, + taxCents: invoices.taxCents, + tipCents: invoices.tipCents, + totalCents: invoices.totalCents, + status: invoices.status, + paymentMethod: invoices.paymentMethod, + paidAt: invoices.paidAt, + notes: invoices.notes, + stripePaymentIntentId: invoices.stripePaymentIntentId, + stripeRefundId: invoices.stripeRefundId, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt, + }) + .from(invoices) + .leftJoin(clients, eq(invoices.clientId, clients.id)) + .where(whereClause) + .orderBy(invoices.createdAt) + .limit(limit) + .offset(offset); + + return c.json({ data: rows, total: totalResult?.count ?? 0 }); + } +); + +// Get single invoice with line items and tip splits +invoicesRouter.get("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + + const [lineItems, tipSplits] = await Promise.all([ + db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), + db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), + ]); + + let cardLast4: string | null = null; + let paymentStatus: string | null = null; + if (invoice.stripePaymentIntentId) { + const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId); + if (details) { + cardLast4 = details.cardLast4; + paymentStatus = details.paymentStatus; + } + } + + return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus }); +}); + +// Save tip splits for an invoice (replaces existing splits) +const tipSplitSchema = z.object({ + splits: z.array( + z.object({ + staffId: z.string().uuid().nullable(), + staffName: z.string().min(1).max(200), + sharePct: z.number().min(0).max(100), + }) + ).min(1).refine( + (splits) => { + const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); + return totalBps === 10000; + }, + { message: "Split percentages must sum to 100" } + ), +}); + +invoicesRouter.post( + "/:id/tip-splits", + zValidator("json", tipSplitSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + if (invoice.status === "void") return c.json({ error: "Cannot modify a voided invoice" }, 422); + + const tipCents = invoice.tipCents; + + await db.transaction(async (tx) => { + // Remove existing splits + await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + + // Insert new splits, distributing tipCents proportionally + let remaining = tipCents; + const rows = body.splits.map((s, i) => { + const isLast = i === body.splits.length - 1; + const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents); + if (!isLast) remaining -= shareCents; + return { + invoiceId: id, + staffId: s.staffId, + staffName: s.staffName, + sharePct: s.sharePct.toFixed(2), + shareCents, + }; + }); + + if (rows.length > 0) { + await tx.insert(invoiceTipSplits).values(rows); + } + }); + + const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + const [lineItems, tipSplits] = await Promise.all([ + db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), + db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), + ]); + + return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201); + } +); + +// Create invoice (optionally pre-populated from an appointment) +invoicesRouter.post( + "/", + zValidator("json", createInvoiceSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // If appointmentId provided, verify it exists + if (body.appointmentId) { + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, body.appointmentId)); + if (!appt) return c.json({ error: "Appointment not found" }, 404); + } + + const subtotalCents = body.lineItems.reduce( + (sum, item) => sum + item.quantity * item.unitPriceCents, + 0 + ); + const totalCents = subtotalCents + body.taxCents + body.tipCents; + + const [invoice] = await db + .insert(invoices) + .values({ + appointmentId: body.appointmentId ?? null, + clientId: body.clientId, + subtotalCents, + taxCents: body.taxCents, + tipCents: body.tipCents, + totalCents, + notes: body.notes ?? null, + }) + .returning(); + + if (!invoice) return c.json({ error: "Failed to create invoice" }, 500); + + const items = await db + .insert(invoiceLineItems) + .values( + body.lineItems.map((item) => ({ + invoiceId: invoice.id, + description: item.description, + quantity: item.quantity, + unitPriceCents: item.unitPriceCents, + totalCents: item.quantity * item.unitPriceCents, + })) + ) + .returning(); + + return c.json({ ...invoice, lineItems: items }, 201); + } +); + +// Create invoice from appointment (convenience endpoint) +invoicesRouter.post("/from-appointment/:appointmentId", async (c) => { + const db = getDb(); + const appointmentId = c.req.param("appointmentId"); + + const [appt] = await db + .select({ + id: appointments.id, + clientId: appointments.clientId, + serviceId: appointments.serviceId, + priceCents: appointments.priceCents, + serviceName: services.name, + serviceBasePriceCents: services.basePriceCents, + }) + .from(appointments) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .where(eq(appointments.id, appointmentId)); + + if (!appt) return c.json({ error: "Appointment not found" }, 404); + + // Check if invoice already exists for this appointment + const [existing] = await db + .select({ id: invoices.id }) + .from(invoices) + .where(eq(invoices.appointmentId, appointmentId)) + .limit(1); + + if (existing) { + return c.json( + { error: "Invoice already exists for this appointment", invoiceId: existing.id }, + 409 + ); + } + + const unitPriceCents = appt.priceCents ?? appt.serviceBasePriceCents; + const subtotalCents = unitPriceCents; + const totalCents = subtotalCents; + + const [invoice] = await db + .insert(invoices) + .values({ + appointmentId, + clientId: appt.clientId, + subtotalCents, + taxCents: 0, + tipCents: 0, + totalCents, + }) + .returning(); + + if (!invoice) return c.json({ error: "Failed to create invoice" }, 500); + + const [lineItem] = await db + .insert(invoiceLineItems) + .values({ + invoiceId: invoice.id, + description: appt.serviceName, + quantity: 1, + unitPriceCents, + totalCents: unitPriceCents, + }) + .returning(); + + return c.json({ ...invoice, lineItems: [lineItem] }, 201); +}); + +const ALLOWED_TRANSITIONS: Record = { + draft: ["pending", "void"], + pending: ["draft", "paid", "void"], + paid: ["void"], + void: [], +}; + +// Update invoice +invoicesRouter.patch( + "/:id", + zValidator("json", updateInvoiceSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const [current] = await db + .select() + .from(invoices) + .where(eq(invoices.id, id)); + if (!current) return c.json({ error: "Not found" }, 404); + + if (body.status !== undefined) { + const allowed = ALLOWED_TRANSITIONS[current.status] ?? []; + if (!allowed.includes(body.status)) { + return c.json( + { error: `Invalid status transition from ${current.status} to ${body.status}` }, + 422 + ); + } + } + + const tipCents = body.tipCents ?? current.tipCents; + + // Validate tip splits when marking invoice as paid + if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { + if (body.tipSplits.length === 0) { + return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400); + } + const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0); + if (Math.abs(totalPct - 100) > 0.01) { + return c.json({ error: "Tip split percentages must sum to 100%" }, 400); + } + } + + // Destructure tipSplits out — it belongs to a separate table, not the invoices column + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { tipSplits: _tipSplits, ...updateBody } = body as Record; + const update: Record = { ...updateBody, updatedAt: new Date() }; + + // Auto-set paidAt when marking as paid + if (body.status === "paid" && !body.paidAt && !current.paidAt) { + update.paidAt = new Date(); + } + + // Recalculate total if tax or tip changed + const newTaxCents = body.taxCents ?? current.taxCents; + const newTipCents = body.tipCents ?? current.tipCents; + if (body.taxCents !== undefined || body.tipCents !== undefined) { + update.totalCents = current.subtotalCents + newTaxCents + newTipCents; + } + + // Wrap tip split persistence and invoice update in a single atomic transaction + const [updated, lineItems] = await db.transaction(async (tx) => { + if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { + await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + const splits = body.tipSplits; + if (splits.length > 0) { + let remaining = tipCents; + const rows = splits.map((s, i) => { + const isLast = i === splits.length - 1; + const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents); + if (!isLast) remaining -= shareCents; + return { + invoiceId: id, + staffId: s.staffId, + staffName: s.staffName, + sharePct: s.sharePct.toFixed(2), + shareCents, + }; + }); + await tx.insert(invoiceTipSplits).values(rows); + } + } + + const [updatedInvoice] = await tx + .update(invoices) + .set(update) + .where(eq(invoices.id, id)) + .returning(); + + const lineItems = await tx + .select() + .from(invoiceLineItems) + .where(eq(invoiceLineItems.invoiceId, id)); + + return [updatedInvoice, lineItems]; + }); + + return c.json({ ...updated, lineItems }); + } +); + +// ─── Refund ─────────────────────────────────────────────────────────────────── + +import { processRefund, getPaymentIntentDetails } from "../services/payment.js"; + +const refundSchema = z.object({ + amountCents: z.number().int().nonnegative().optional(), + idempotencyKey: z.string().max(255).optional(), +}); + +invoicesRouter.post( + "/:id/refund", + zValidator("json", refundSchema), + async (c) => { + const db = getDb(); + const staff = c.get("staff"); + if (!staff) return c.json({ error: "Forbidden" }, 403); + if (staff.role !== "manager" && !staff.isSuperUser) { + return c.json({ error: "Manager role required" }, 403); + } + + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + if (invoice.status !== "paid") { + return c.json({ error: "Refund only allowed on paid invoices" }, 422); + } + + return await db.transaction(async (tx) => { + if (body.idempotencyKey) { + const [existing] = await tx + .select() + .from(refunds) + .where(eq(refunds.idempotencyKey, body.idempotencyKey)); + if (existing) { + return c.json({ refundId: existing.stripeRefundId }); + } + } + + let refundId: string; + + if (invoice.stripePaymentIntentId) { + const result = await processRefund(id, body.amountCents); + if (!result) return c.json({ error: "Refund failed" }, 500); + refundId = result.refundId; + } else { + // Manual refund — no Stripe call needed + refundId = `manual_${id}_${Date.now()}`; + } + + await tx.insert(refunds).values({ + invoiceId: id, + stripeRefundId: refundId, + idempotencyKey: body.idempotencyKey ?? null, + amountCents: body.amountCents ?? null, + }); + + return c.json({ refundId }); + }); + } +); + +// Payment stats for admin dashboard +invoicesRouter.get("/stats/summary", async (c) => { + try { + const db = getDb(); + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const [revenueResult] = await db + .select({ total: sql`coalesce(sum(total_cents), 0)` }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); + + const [outstandingResult] = await db + .select({ total: sql`coalesce(sum(total_cents), 0)` }) + .from(invoices) + .where(eq(invoices.status, "pending")); + + const [refundsResult] = await db + .select({ total: sql`coalesce(sum(amount_cents), 0)` }) + .from(refunds) + .where(sql`${refunds.createdAt} >= ${startOfMonth}`); + + const methodBreakdown = await db + .select({ + method: invoices.paymentMethod, + total: sql`count(*)`, + }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) + .groupBy(invoices.paymentMethod); + + return c.json({ + revenueThisMonth: revenueResult?.total ?? 0, + outstanding: outstandingResult?.total ?? 0, + refundsThisMonth: refundsResult?.total ?? 0, + methodBreakdown, + }); + } catch (err) { + console.error("stats/summary error:", err); + return c.json({ + revenueThisMonth: 0, + outstanding: 0, + refundsThisMonth: 0, + methodBreakdown: [], + }); + } +}); + +// Get Stripe payment details for an invoice (card last4, payment status, refund status) +invoicesRouter.get("/:id/stripe-details", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + + let cardLast4: string | null = null; + let paymentStatus: string | null = null; + + if (invoice.stripePaymentIntentId) { + const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId); + if (details) { + cardLast4 = details.cardLast4; + paymentStatus = details.paymentStatus; + } + } + + return c.json({ + stripePaymentIntentId: invoice.stripePaymentIntentId, + stripeRefundId: invoice.stripeRefundId, + cardLast4, + paymentStatus, + }); +}); diff --git a/src/routes/pets.ts b/src/routes/pets.ts new file mode 100644 index 0000000..9642450 --- /dev/null +++ b/src/routes/pets.ts @@ -0,0 +1,277 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; +import { + getPresignedUploadUrl, + getPresignedGetUrl, + deleteObject, +} from "../lib/s3.js"; + +export const petsRouter = new Hono(); + +const createPetSchema = z.object({ + clientId: z.string().uuid(), + name: z.string().min(1).max(200), + species: z.string().min(1).max(100), + breed: z.string().max(200).optional(), + weightKg: z.number().positive().optional(), + dateOfBirth: z.string().datetime().optional(), + healthAlerts: z.string().max(2000).optional(), + groomingNotes: z.string().max(2000).optional(), + cutStyle: z.string().max(500).optional(), + shampooPreference: z.string().max(500).optional(), + specialCareNotes: z.string().max(2000).optional(), + customFields: z.record(z.string(), z.string()).optional(), + sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(), + coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(), +}); + +const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); + +// List pets, optionally filtered by clientId. +// Groomers see only pets owned by clients with ≥1 appointment for this groomer. +petsRouter.get("/", async (c) => { + const db = getDb(); + const clientId = c.req.query("clientId"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + // Groomer: filter to pets whose client has an appointment for this groomer + const groomerClientFilter = isGroomer + ? exists( + db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, pets.clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + ) + : undefined; + + const conditions = []; + if (clientId) conditions.push(eq(pets.clientId, clientId)); + if (groomerClientFilter) conditions.push(groomerClientFilter); + + const rows = await db + .select() + .from(pets) + .where(conditions.length > 0 ? and(...conditions) : undefined); + return c.json(rows); +}); + +petsRouter.get("/:id", 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); + // Groomer: 403 if no appointment linkage to this pet's client + if (isGroomer) { + const [linkage] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, row.clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!linkage) return c.json({ error: "Forbidden" }, 403); + } + return c.json(row); +}); + +petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { + const db = getDb(); + const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const [row] = await db + .insert(pets) + .values({ + ...rest, + weightKg: weightKg?.toString(), + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + customFields: customFields ?? {}, + }) + .returning(); + return c.json(row, 201); +}); + +petsRouter.patch( + "/:id", + zValidator("json", updatePetSchema), + async (c) => { + const db = getDb(); + const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const [row] = await db + .update(pets) + .set({ + ...rest, + weightKg: weightKg?.toString(), + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + ...(customFields !== undefined ? { customFields } : {}), + updatedAt: new Date(), + }) + .where(eq(pets.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +petsRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(pets) + .where(eq(pets.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); + +// ─── Photo routes ────────────────────────────────────────────────────────────── + +const ALLOWED_CONTENT_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); + +const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB + +const uploadUrlSchema = z.object({ + contentType: z.string().refine((v) => ALLOWED_CONTENT_TYPES.has(v), { + message: "contentType must be one of: image/jpeg, image/png, image/webp, image/gif", + }), + fileSizeBytes: z.number().int().positive().max(MAX_PHOTO_SIZE, { + message: "File must not exceed 5 MB", + }), +}); + +const confirmSchema = z.object({ + key: z.string().min(1), +}); + +/** + * POST /:petId/photo/upload-url + * Returns a presigned S3 PUT URL and the object key for the upload. + * All staff roles (manager, receptionist, groomer) may call this. + */ +petsRouter.post( + "/:petId/photo/upload-url", + zValidator("json", uploadUrlSchema), + async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + const { contentType, fileSizeBytes } = c.req.valid("json"); + + const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!pet) return c.json({ error: "Pet not found" }, 404); + + const ext = contentType.split("/")[1] ?? "jpg"; + const key = `pets/${petId}/${Date.now()}.${ext}`; + const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes); + + return c.json({ uploadUrl, key }); + } +); + +/** + * POST /:petId/photo/confirm + * Called after the client has successfully uploaded to the presigned URL. + * Records the object key in the DB. + */ +petsRouter.post( + "/:petId/photo/confirm", + zValidator("json", confirmSchema), + async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + const { key } = c.req.valid("json"); + + // Validate that the key belongs to this pet to prevent key hijacking + if (!key.startsWith(`pets/${petId}/`)) { + return c.json({ error: "Invalid key" }, 400); + } + + const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!pet) return c.json({ error: "Pet not found" }, 404); + + // Delete the previous photo from storage to avoid orphaned objects + if (pet.photoKey) { + try { + await deleteObject(pet.photoKey); + } catch (err) { + console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err); + } + } + + const [row] = await db + .update(pets) + .set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() }) + .where(eq(pets.id, petId)) + .returning(); + if (!row) return c.json({ error: "Pet not found" }, 404); + + return c.json({ ok: true, photoKey: row.photoKey }); + } +); + +/** + * DELETE /:petId/photo + * Removes the photo from object storage and clears the DB record. + * All staff roles (manager, receptionist, groomer) may call this. + */ +petsRouter.delete("/:petId/photo", async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + + const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!pet) return c.json({ error: "Pet not found" }, 404); + if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); + + try { + await deleteObject(pet.photoKey); + } catch (err) { + console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err); + } + await db + .update(pets) + .set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() }) + .where(eq(pets.id, petId)); + + return c.json({ ok: true }); +}); + +/** + * GET /:petId/photo + * Returns a presigned GET URL for the pet's photo. + * All authenticated staff may access (read). + */ +petsRouter.get("/:petId/photo", async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + + const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!pet) return c.json({ error: "Pet not found" }, 404); + if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); + + const url = await getPresignedGetUrl(pet.photoKey); + return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts new file mode 100644 index 0000000..a4c2b87 --- /dev/null +++ b/src/routes/portal.ts @@ -0,0 +1,521 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, inArray } from "@groombook/db"; +import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; +import { validatePortalSession } from "../middleware/portalSession.js"; +import { portalAudit } from "../middleware/portalAudit.js"; +import type { PortalEnv } from "../middleware/portalSession.js"; + +export const portalRouter = new Hono(); + +// Dev-mode session creation — must be registered BEFORE the /* middleware so it is +// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates +// the impersonation session and has no X-Impersonation-Session-Id header yet. +const devSessionSchema = z.object({ + clientId: z.string().uuid(), +}); + +portalRouter.post( + "/dev-session", + zValidator("json", devSessionSchema), + async (c) => { + if (process.env.AUTH_DISABLED !== "true") { + return c.json({ error: "Not available when auth is enabled" }, 403); + } + + const db = getDb(); + const body = c.req.valid("json"); + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, body.clientId)) + .limit(1); + if (!client) { + return c.json({ error: "Client not found" }, 404); + } + + const 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. Run the database seed." }, 500); + } + staffId = firstStaff.id; + } + + const [session] = await db + .insert(impersonationSessions) + .values({ + staffId, + clientId: body.clientId, + reason: "dev-mode-client-portal", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }) + .returning(); + + return c.json(session, 201); + } +); + +// Apply middleware to all portal routes +portalRouter.use("/*", validatePortalSession, portalAudit); + +// ─── GET routes ────────────────────────────────────────────────────────────── + +portalRouter.get("/me", async (c) => { + const db = getDb(); + const clientId = c.get("portalClientId"); + + const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); + if (!client) return c.json({ error: "Not found" }, 404); + + return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone }); +}); + +portalRouter.get("/config", async (c) => { + return c.json({ + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "", + }); +}); + +portalRouter.get("/services", async (c) => { + const db = getDb(); + const allServices = await db.select().from(services).where(eq(services.active, true)); + return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes }))); +}); + +portalRouter.get("/appointments", async (c) => { + const db = getDb(); + const clientId = c.get("portalClientId"); + + const allAppts = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + confirmationStatus: appointments.confirmationStatus, + customerNotes: appointments.customerNotes, + notes: appointments.notes, + petId: appointments.petId, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + }) + .from(appointments) + .where(eq(appointments.clientId, clientId)) + .orderBy(appointments.startTime); + + const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); + const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); + + const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; + const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; + + const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); + const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); + + const appts = allAppts.map(a => ({ + id: a.id, + startTime: a.startTime, + endTime: a.endTime, + status: a.status, + confirmationStatus: a.confirmationStatus, + customerNotes: a.customerNotes, + notes: a.notes, + pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, + service: a.serviceId ? { id: a.serviceId } : null, + staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, + })); + + return c.json({ appointments: appts }); +}); + +portalRouter.get("/pets", async (c) => { + const db = getDb(); + const clientId = c.get("portalClientId"); + + const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); + return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes }))); +}); + +portalRouter.get("/invoices", async (c) => { + const db = getDb(); + const clientId = c.get("portalClientId"); + + const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); + const invoiceIds = clientInvoices.map(i => i.id); + const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : []; + + const itemsByInvoice: Record = {}; + for (const li of lineItems) { + if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = []; + itemsByInvoice[li.invoiceId]!.push(li); + } + + return c.json(clientInvoices.map(inv => ({ + id: inv.id, + status: inv.status, + totalCents: inv.totalCents, + date: inv.createdAt, + lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })), + }))); +}); + +// ─── Appointment action routes ──────────────────────────────────────────────── + +const customerNotesSchema = z.object({ + // .min(1) prevents empty strings — clearing notes is not a supported use case + customerNotes: z.string().min(1).max(500), +}); + +portalRouter.patch( + "/appointments/:id/notes", + zValidator("json", customerNotesSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ customerNotes: body.customerNotes, updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + customerNotes: updated.customerNotes, + updatedAt: updated.updatedAt, + }); + } +); + +// ─── Appointment confirm/cancel ────────────────────────────────────────────── + +portalRouter.post("/appointments/:id/confirm", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const clientId = c.get("portalClientId"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422); + } + + if (appt.confirmationStatus !== "pending") { + return c.json({ error: "Appointment is not pending confirmation" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated!.id, + confirmationStatus: updated!.confirmationStatus, + confirmedAt: updated!.confirmedAt, + updatedAt: updated!.updatedAt, + }); +}); + +portalRouter.post("/appointments/:id/cancel", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const clientId = c.get("portalClientId"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Appointment is already cancelled or completed" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated!.id, + status: updated!.status, + confirmationStatus: updated!.confirmationStatus, + cancelledAt: updated!.cancelledAt, + updatedAt: updated!.updatedAt, + }); +}); + +// ─── Client-facing waitlist routes ──────────────────────────────────────────── + +const createWaitlistEntrySchema = z.object({ + petId: z.string().uuid(), + serviceId: z.string().uuid(), + preferredDate: z.string(), + preferredTime: z.string(), +}); + +const updateWaitlistEntrySchema = z.object({ + status: z.literal("cancelled").optional(), + preferredDate: z.string().optional(), + preferredTime: z.string().optional(), +}); + +portalRouter.post( + "/waitlist", + zValidator("json", createWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: body.preferredTime, + }) + .returning(); + + return c.json(entry, 201); + } +); + +portalRouter.patch( + "/waitlist/:id", + zValidator("json", updateWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const [existing] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!existing) return c.json({ error: "Not found" }, 404); + if (existing.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + const updateData: Record = { updatedAt: new Date() }; + if (body.status !== undefined) updateData.status = body.status; + if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; + if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; + + const [updated] = await db + .update(waitlistEntries) + .set(updateData) + .where(eq(waitlistEntries.id, id)) + .returning(); + + return c.json(updated); + } +); + +portalRouter.delete("/waitlist/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const clientId = c.get("portalClientId"); + + const [entry] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!entry) return c.json({ error: "Not found" }, 404); + if (entry.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + await db + .delete(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .returning(); + + return c.json({ ok: true }); +}); + +// ─── Payment routes ─────────────────────────────────────────────────────────── + +import { + createPaymentIntent, + listPaymentMethods, + detachPaymentMethod, + createSetupIntent, + getOrCreateStripeCustomer, + getStripeClient, +} from "../services/payment.js"; + +const payMultipleSchema = z.object({ + invoiceIds: z.array(z.string().uuid()).min(1), +}); + +portalRouter.post( + "/invoices/pay-multiple", + zValidator("json", payMultipleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const invoiceRows = await db + .select() + .from(invoices) + .where(inArray(invoices.id, body.invoiceIds)); + + if (invoiceRows.length !== body.invoiceIds.length) { + return c.json({ error: "One or more invoices not found" }, 404); + } + + for (const inv of invoiceRows) { + if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403); + if (inv.status === "draft" || inv.status === "void") { + return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422); + } + if (inv.status === "paid") { + return c.json({ error: `Invoice ${inv.id} is already paid` }, 422); + } + } + + const firstInvoice = invoiceRows[0]; + if (!firstInvoice) return c.json({ error: "No invoices found" }, 400); + const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId); + if (!allSameClient) { + return c.json({ error: "All invoices must belong to the same client" }, 422); + } + + const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + const result = await createPaymentIntent(body.invoiceIds, clientId); + if (!result) return c.json({ error: "Payment service unavailable" }, 503); + + return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); + } +); + +portalRouter.get("/payment-methods", async (c) => { + const clientId = c.get("portalClientId"); + + const methods = await listPaymentMethods(clientId); + if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); + return c.json(methods); +}); + +portalRouter.post("/payment-methods", async (c) => { + const clientId = c.get("portalClientId"); + + const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + const customerId = await getOrCreateStripeCustomer(clientId); + if (!customerId) return c.json({ error: "Could not create customer" }, 500); + + const result = await createSetupIntent(customerId); + if (!result) return c.json({ error: "Payment service unavailable" }, 503); + + return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); +}); + +portalRouter.delete("/payment-methods/:id", async (c) => { + const clientId = c.get("portalClientId"); + + const paymentMethodId = c.req.param("id"); + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404); + + const stripe = getStripeClient(); + if (!stripe) return c.json({ error: "Payment service unavailable" }, 503); + + const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); + if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) { + return c.json({ error: "Payment method not found" }, 404); + } + + const ok = await detachPaymentMethod(paymentMethodId); + if (!ok) return c.json({ error: "Failed to detach payment method" }, 500); + return c.json({ ok: true }); +}); \ No newline at end of file diff --git a/src/routes/reports.ts b/src/routes/reports.ts new file mode 100644 index 0000000..c249862 --- /dev/null +++ b/src/routes/reports.ts @@ -0,0 +1,487 @@ +import { Hono } from "hono"; +import { + and, + eq, + gte, + lt, + sql, + getDb, + appointments, + clients, + invoices, + invoiceTipSplits, + services, + staff, +} from "@groombook/db"; + +export const reportsRouter = new Hono(); + +reportsRouter.onError((err, c) => { + console.error("[reports] unhandled error:", err); + return c.json({ error: "Internal server error", message: err.message }, 500); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function parseDate(value: string | undefined, fallback: Date): Date { + if (!value) return fallback; + const d = new Date(value); + return isNaN(d.getTime()) ? fallback : d; +} + +function defaultFrom(): Date { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 30); + d.setUTCHours(0, 0, 0, 0); + return d; +} + +function defaultTo(): Date { + const d = new Date(); + d.setUTCHours(23, 59, 59, 999); + return d; +} + +// ─── Summary ────────────────────────────────────────────────────────────────── +// GET /api/reports/summary?from=&to= +// High-level KPIs for a date range + +reportsRouter.get("/summary", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + const [revenueRow] = await db + .select({ + totalRevenueCents: sql`COALESCE(SUM(${invoices.totalCents}), 0)::int`, + paidCount: sql`COUNT(*)::int`, + }) + .from(invoices) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ); + + const [apptRow] = await db + .select({ + total: sql`COUNT(*)::int`, + completed: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + cancelled: sql`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`, + noShow: sql`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ); + + const [clientRow] = await db + .select({ + totalClients: sql`COUNT(*)::int`, + }) + .from(clients); + + // New clients in the period + const [newClientRow] = await db + .select({ + newClients: sql`COUNT(*)::int`, + }) + .from(clients) + .where( + and( + gte(clients.createdAt, from), + lt(clients.createdAt, to) + ) + ); + + return c.json({ + from: from.toISOString(), + to: to.toISOString(), + revenue: { + totalCents: revenueRow?.totalRevenueCents ?? 0, + paidInvoices: revenueRow?.paidCount ?? 0, + }, + appointments: { + total: apptRow?.total ?? 0, + completed: apptRow?.completed ?? 0, + cancelled: apptRow?.cancelled ?? 0, + noShow: apptRow?.noShow ?? 0, + }, + clients: { + total: clientRow?.totalClients ?? 0, + new: newClientRow?.newClients ?? 0, + }, + }); +}); + +// ─── Revenue by period ──────────────────────────────────────────────────────── +// GET /api/reports/revenue?from=&to=&groupBy=day|week|month + +reportsRouter.get("/revenue", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + const groupBy = c.req.query("groupBy") ?? "day"; + + const truncUnit = + groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day"; + + const byPeriod = await db + .select({ + period: sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})::text`, + totalCents: sql`SUM(${invoices.totalCents})::int`, + invoiceCount: sql`COUNT(*)::int`, + }) + .from(invoices) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .groupBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})` + ) + .orderBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})` + ); + + // Revenue by groomer (via appointment -> staff join) + const byGroomer = await db + .select({ + staffId: staff.id, + staffName: staff.name, + totalCents: sql`SUM(${invoices.totalCents})::int`, + invoiceCount: sql`COUNT(${invoices.id})::int`, + }) + .from(invoices) + .innerJoin(appointments, eq(invoices.appointmentId, appointments.id)) + .innerJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .groupBy(staff.id, staff.name) + .orderBy(sql`SUM(${invoices.totalCents}) DESC`); + + return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod, byGroomer }); +}); + +// ─── Appointment analytics ──────────────────────────────────────────────────── +// GET /api/reports/appointments?from=&to=&groupBy=day|week|month + +reportsRouter.get("/appointments", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + const groupBy = c.req.query("groupBy") ?? "day"; + + const truncUnit = + groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day"; + + const byPeriod = await db + .select({ + period: sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})::text`, + total: sql`COUNT(*)::int`, + completed: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + cancelled: sql`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`, + noShow: sql`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .groupBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})` + ) + .orderBy( + sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})` + ); + + return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod }); +}); + +// ─── Service popularity ─────────────────────────────────────────────────────── +// GET /api/reports/services?from=&to= + +reportsRouter.get("/services", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + const rows = await db + .select({ + serviceId: services.id, + serviceName: services.name, + appointmentCount: sql`COUNT(${appointments.id})::int`, + completedCount: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + revenueCents: sql`COALESCE(SUM(CASE WHEN ${invoices.status} = 'paid' THEN ${invoices.totalCents} ELSE 0 END), 0)::int`, + }) + .from(services) + .leftJoin( + appointments, + and( + eq(appointments.serviceId, services.id), + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .leftJoin(invoices, eq(invoices.appointmentId, appointments.id)) + .groupBy(services.id, services.name) + .orderBy(sql`COUNT(${appointments.id}) DESC`); + + return c.json({ from: from.toISOString(), to: to.toISOString(), rows }); +}); + +// ─── Client retention ───────────────────────────────────────────────────────── +// GET /api/reports/clients?from=&to= +// Returns: new clients, returning clients, clients with no recent activity (churn risk) + +reportsRouter.get("/clients", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + // New clients in period + const newClients = await db + .select({ + clientId: clients.id, + clientName: clients.name, + createdAt: clients.createdAt, + }) + .from(clients) + .where(and(gte(clients.createdAt, from), lt(clients.createdAt, to))) + .orderBy(clients.createdAt); + + // Active clients in period (had at least 1 appointment) + const activeInPeriod = await db + .select({ + clientId: appointments.clientId, + appointmentCount: sql`COUNT(*)::int`, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to), + eq(appointments.status, "completed") + ) + ) + .groupBy(appointments.clientId); + + // Clients with no appointment in last 90 days (churn risk) + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); + const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); + + const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20)); + const offset = (page - 1) * limit; + + const churnRisk = await db + .select({ + clientId: clients.id, + clientName: clients.name, + lastAppointmentAt: sql`MAX(${appointments.startTime})::text`, + }) + .from(clients) + .leftJoin(appointments, eq(appointments.clientId, clients.id)) + .groupBy(clients.id, clients.name) + .having( + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` + ) + .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`) + .limit(limit) + .offset(offset); + + const [churnCountRow] = await db + .select({ total: sql`count(*)::int` }) + .from( + db + .select({ id: clients.id }) + .from(clients) + .leftJoin(appointments, eq(appointments.clientId, clients.id)) + .groupBy(clients.id) + .having( + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` + ) + .as("churn_count") + ); + const churnRiskTotal = churnCountRow?.total ?? 0; + + return c.json({ + from: from.toISOString(), + to: to.toISOString(), + newClients, + activeInPeriodCount: activeInPeriod.length, + churnRisk, + churnRiskTotal, + page, + limit, + }); +}); + +// ─── Tip splits payroll report ──────────────────────────────────────────────── +// GET /api/reports/tip-splits?from=&to= +// Aggregates tip earnings per staff member for the period + +reportsRouter.get("/tip-splits", async (c) => { + const db = getDb(); + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + const rows = await db + .select({ + staffId: invoiceTipSplits.staffId, + staffName: invoiceTipSplits.staffName, + totalTipCents: sql`SUM(${invoiceTipSplits.shareCents})::int`, + invoiceCount: sql`COUNT(DISTINCT ${invoiceTipSplits.invoiceId})::int`, + }) + .from(invoiceTipSplits) + .innerJoin(invoices, eq(invoiceTipSplits.invoiceId, invoices.id)) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .groupBy(invoiceTipSplits.staffId, invoiceTipSplits.staffName) + .orderBy(sql`SUM(${invoiceTipSplits.shareCents}) DESC`); + + return c.json({ from: from.toISOString(), to: to.toISOString(), rows }); +}); + +// ─── CSV export ─────────────────────────────────────────────────────────────── +// GET /api/reports/export.csv?type=revenue|appointments|services&from=&to= + +reportsRouter.get("/export.csv", async (c) => { + const db = getDb(); + const type = c.req.query("type") ?? "revenue"; + const from = parseDate(c.req.query("from"), defaultFrom()); + const to = parseDate(c.req.query("to"), defaultTo()); + + let csv = ""; + + if (type === "revenue") { + const rows = await db + .select({ + paidAt: invoices.paidAt, + clientId: invoices.clientId, + totalCents: invoices.totalCents, + subtotalCents: invoices.subtotalCents, + taxCents: invoices.taxCents, + tipCents: invoices.tipCents, + paymentMethod: invoices.paymentMethod, + staffName: staff.name, + }) + .from(invoices) + .leftJoin(appointments, eq(invoices.appointmentId, appointments.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + eq(invoices.status, "paid"), + gte(invoices.paidAt, from), + lt(invoices.paidAt, to) + ) + ) + .orderBy(invoices.paidAt); + + csv = "Date,Groomer,Total,Subtotal,Tax,Tip,Payment Method\n"; + csv += rows + .map((r) => + [ + r.paidAt ? new Date(r.paidAt).toLocaleDateString() : "", + r.staffName ?? "", + (r.totalCents / 100).toFixed(2), + (r.subtotalCents / 100).toFixed(2), + (r.taxCents / 100).toFixed(2), + (r.tipCents / 100).toFixed(2), + r.paymentMethod ?? "", + ].join(",") + ) + .join("\n"); + } else if (type === "appointments") { + const rows = await db + .select({ + startTime: appointments.startTime, + status: appointments.status, + clientId: appointments.clientId, + clientName: clients.name, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .leftJoin(clients, eq(appointments.clientId, clients.id)) + .leftJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .orderBy(appointments.startTime); + + csv = "Date,Client,Service,Groomer,Status\n"; + csv += rows + .map((r) => + [ + new Date(r.startTime).toLocaleDateString(), + `"${(r.clientName ?? "").replace(/"/g, '""')}"`, + `"${(r.serviceName ?? "").replace(/"/g, '""')}"`, + r.staffName ?? "", + r.status, + ].join(",") + ) + .join("\n"); + } else if (type === "services") { + const rows = await db + .select({ + serviceName: services.name, + appointmentCount: sql`COUNT(${appointments.id})::int`, + completedCount: sql`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`, + }) + .from(services) + .leftJoin( + appointments, + and( + eq(appointments.serviceId, services.id), + gte(appointments.startTime, from), + lt(appointments.startTime, to) + ) + ) + .groupBy(services.id, services.name) + .orderBy(sql`COUNT(${appointments.id}) DESC`); + + csv = "Service,Total Appointments,Completed\n"; + csv += rows + .map((r) => + [ + `"${r.serviceName.replace(/"/g, '""')}"`, + r.appointmentCount, + r.completedCount, + ].join(",") + ) + .join("\n"); + } else { + return c.json({ error: "Invalid type. Use revenue, appointments, or services." }, 400); + } + + const filename = `groombook-${type}-report.csv`; + c.header("Content-Type", "text/csv"); + c.header("Content-Disposition", `attachment; filename="${filename}"`); + return c.text(csv); +}); diff --git a/src/routes/search.ts b/src/routes/search.ts new file mode 100644 index 0000000..14e4ec3 --- /dev/null +++ b/src/routes/search.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono"; +import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db"; + +export const searchRouter = new Hono(); + +const LIMIT = 10; + +/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */ +function escapeLike(s: string): string { + return `%${s.replace(/[%_\\]/g, "\\$&")}%`; +} + +/** + * GET /api/search?q={query} + * + * Returns up to 10 matching active clients and up to 10 matching pets. + * Clients are matched on name, email, or phone. + * Pets are matched on name or breed; includes owner name. + */ +searchRouter.get("/", async (c) => { + const q = c.req.query("q"); + if (!q || q.trim().length === 0) { + return c.json({ error: "Query parameter q is required" }, 400); + } + + const pattern = escapeLike(q.trim()); + const db = getDb(); + + const [matchingClients, matchingPets] = await Promise.all([ + db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + phone: clients.phone, + }) + .from(clients) + .where( + and( + eq(clients.status, "active"), + or( + ilike(clients.name, pattern), + ilike(clients.email, pattern), + ilike(clients.phone, pattern) + ) + ) + ) + .limit(LIMIT), + + db + .select({ + id: pets.id, + name: pets.name, + breed: pets.breed, + clientId: pets.clientId, + ownerName: clients.name, + }) + .from(pets) + .innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active"))) + .where( + or( + ilike(pets.name, pattern), + ilike(pets.breed, pattern) + ) + ) + .limit(LIMIT), + ]); + + return c.json({ clients: matchingClients, pets: matchingPets }); +}); diff --git a/src/routes/services.ts b/src/routes/services.ts new file mode 100644 index 0000000..7cf8112 --- /dev/null +++ b/src/routes/services.ts @@ -0,0 +1,74 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, getDb, services } from "@groombook/db"; + +export const servicesRouter = new Hono(); + +const createServiceSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + basePriceCents: z.number().int().positive(), + durationMinutes: z.number().int().positive().max(480), + defaultBufferMinutes: z.number().int().min(0).optional(), + active: z.boolean().default(true), +}); + +const updateServiceSchema = createServiceSchema.partial(); + +servicesRouter.get("/", async (c) => { + const db = getDb(); + const includeInactive = c.req.query("includeInactive") === "true"; + const query = db.select().from(services).orderBy(services.name); + const rows = includeInactive + ? await query + : await query.where(eq(services.active, true)); + return c.json(rows); +}); + +servicesRouter.get("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(services) + .where(eq(services.id, c.req.param("id"))); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +servicesRouter.post( + "/", + zValidator("json", createServiceSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db.insert(services).values(body).returning(); + return c.json(row, 201); + } +); + +servicesRouter.patch( + "/:id", + zValidator("json", updateServiceSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db + .update(services) + .set({ ...body, updatedAt: new Date() }) + .where(eq(services.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +servicesRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(services) + .where(eq(services.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts new file mode 100644 index 0000000..3b931db --- /dev/null +++ b/src/routes/settings.ts @@ -0,0 +1,256 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, getDb, businessSettings } from "@groombook/db"; +import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js"; +import { requireSuperUser } from "../middleware/rbac.js"; + +export const settingsRouter = new Hono(); + +// GET /api/admin/settings — return current business settings +settingsRouter.get("/", async (c) => { + const db = getDb(); + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) { + // Auto-create default settings if none exist + const [created] = await db.insert(businessSettings).values({}).returning(); + return c.json(created); + } + return c.json(row); +}); + +const hexColorRegex = /^#[0-9a-fA-F]{6}$/; + +const updateSettingsSchema = z.object({ + businessName: z.string().min(1).max(200).optional(), + primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(), + accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(), +}); + +// PATCH /api/admin/settings — update business settings +settingsRouter.patch( + "/", + requireSuperUser(), + zValidator("json", updateSettingsSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // Get or create the settings row + const rows = await db.select().from(businessSettings).limit(1); + let settingsId: string; + if (rows[0]) { + settingsId = rows[0].id; + } else { + const [inserted] = await db.insert(businessSettings).values({}).returning(); + if (!inserted) throw new Error("Failed to create default settings"); + settingsId = inserted.id; + } + + const [updated] = await db + .update(businessSettings) + .set({ ...body, updatedAt: new Date() }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + return c.json(updated); + } +); + +// ─── Logo routes ────────────────────────────────────────────────────────────── + +const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]); +const MAX_LOGO_SIZE = 512 * 1024; // 512 KB + +const logoUploadUrlSchema = z.object({ + contentType: z.string().refine((v) => ALLOWED_LOGO_TYPES.has(v), { + message: "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }), + fileSizeBytes: z.number().int().positive().max(MAX_LOGO_SIZE, { + message: "File must not exceed 512 KB", + }), +}); + +const logoConfirmSchema = z.object({ + key: z.string().min(1), +}); + +/** + * POST /api/admin/settings/logo/upload-url + * Returns a presigned S3 PUT URL and the object key for logo upload. + */ +settingsRouter.post( + "/logo/upload-url", + zValidator("json", logoUploadUrlSchema), + async (c) => { + const db = getDb(); + const { contentType, fileSizeBytes } = c.req.valid("json"); + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes); + + return c.json({ uploadUrl, key }); + } +); + +/** + * POST /api/admin/settings/logo/upload + * Proxy upload through the API server to avoid mixed-content issues with + * pre-signed URLs that use the internal HTTP endpoint. The file is uploaded + * directly to S3 from the server using the internal endpoint. + */ +settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => { + const db = getDb(); + + // Parse multipart form data (file field) + const body = await c.req.parseBody({ all: true }); + const file = body["file"]; + + if (!file || !(file instanceof File)) { + return c.json({ error: "No file provided" }, 400); + } + + const contentType = file.type; + if (!ALLOWED_LOGO_TYPES.has(contentType)) { + return c.json( + { + error: + "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }, + 400 + ); + } + + const fileSizeBytes = file.size; + if (fileSizeBytes > MAX_LOGO_SIZE) { + return c.json({ error: "File must not exceed 512 KB" }, 400); + } + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + + // Read file into buffer and upload directly to S3 (bypasses pre-signed URL) + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await putObject(key, buffer, contentType, fileSizeBytes); + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + // Update database with new logo key + const [updated] = await db + .update(businessSettings) + .set({ + logoKey: key, + logoBase64: null, + logoMimeType: null, + updatedAt: new Date(), + }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + if (!updated) { + return c.json({ error: "Settings not found" }, 404); + } + + return c.json({ ok: true, logoKey: updated.logoKey }); +}); + +/** + * POST /api/admin/settings/logo/confirm + * Called after the client has successfully uploaded to the presigned URL. + * Records the object key in the DB and clears legacy base64 fields. + */ +settingsRouter.post( + "/logo/confirm", + zValidator("json", logoConfirmSchema), + async (c) => { + const db = getDb(); + const { key } = c.req.valid("json"); + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + // Validate key prefix + if (!key.startsWith(`logos/${settingsId}/`)) { + return c.json({ error: "Invalid key" }, 400); + } + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + const [updated] = await db + .update(businessSettings) + .set({ logoKey: key, logoBase64: null, logoMimeType: null, updatedAt: new Date() }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + if (!updated) { + return c.json({ error: "Settings not found" }, 404); + } + + return c.json({ ok: true, logoKey: updated.logoKey }); + } +); + +/** + * GET /api/admin/settings/logo + * Proxies the logo from S3 so the browser never sees an S3 URL. + * Returns the image bytes with proper Content-Type. + */ +settingsRouter.get("/logo", async (c) => { + const db = getDb(); + + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + const { body, contentType } = await getObject(row.logoKey); + return new Response(Buffer.from(body), { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + }, + }); +}); + +/** + * DELETE /api/admin/settings/logo + * Removes the logo from S3 and clears the DB record. + */ +settingsRouter.delete("/logo", async (c) => { + const db = getDb(); + + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + await deleteObject(row.logoKey); + await db + .update(businessSettings) + .set({ logoKey: null, updatedAt: new Date() }) + .where(eq(businessSettings.id, row.id)); + + return c.json({ ok: true }); +}); diff --git a/src/routes/setup.ts b/src/routes/setup.ts new file mode 100644 index 0000000..495fd66 --- /dev/null +++ b/src/routes/setup.ts @@ -0,0 +1,339 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX = 10; +const rateLimitMap = new Map(); + +function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } { + const entry = rateLimitMap.get(ip); + const now = Date.now(); + if (!entry || now > entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return { allowed: true, remaining: RATE_LIMIT_MAX - 1 }; + } + if (entry.count >= RATE_LIMIT_MAX) { + return { allowed: false, remaining: 0 }; + } + entry.count++; + return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count }; +} + +export const setupRouter = new Hono(); + +// GET /api/setup/status — public (no auth), returns whether setup is needed +// and whether the auth provider bootstrap step should be shown +setupRouter.get("/status", async (c) => { + const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase()); + if (skipOobe) { + return c.json({ + needsSetup: false, + showAuthProviderStep: false, + authConfigExists: false, + authEnvVarsSet: false, + skipped: true, + }); + } + + const db = getDb(); + + // Check if any super user exists + const [superUser] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + // Check if DB already has an auth provider config + const [dbAuthConfig] = await db + .select({ id: authProviderConfig.id }) + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + // Check if OIDC env vars are set (bootstrap mode) + const oidcIssuer = process.env.OIDC_ISSUER; + const oidcClientId = process.env.OIDC_CLIENT_ID; + const oidcClientSecret = process.env.OIDC_CLIENT_SECRET; + const authEnvVarsSet = !!(oidcIssuer && oidcClientId && oidcClientSecret); + + return c.json({ + needsSetup: !superUser, + // Show auth provider bootstrap step when: fresh install (no super user) AND no DB config AND no env vars + showAuthProviderStep: !superUser && !dbAuthConfig && !authEnvVarsSet, + authConfigExists: !!dbAuthConfig, + authEnvVarsSet, + }); +}); + +const setupSchema = z.object({ + businessName: z.string().min(1).max(200), +}); + +// POST /api/setup — authenticated (Better-Auth JWT), creates staff record if needed and sets business name +// This endpoint is exempt from resolveStaffMiddleware so that OOBE users (with no staff record yet) can complete setup +setupRouter.post("/", zValidator("json", setupSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const jwt = c.get("jwtPayload"); + const currentStaff = c.get("staff"); // may be undefined during OOBE + + // Use a transaction with row-level locking to prevent race conditions + const result = await db.transaction(async (tx) => { + // Lock super user rows to prevent concurrent claims + // FOR UPDATE serializes concurrent claims: second transaction blocks until first commits + const [existingSuperUser] = await tx + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .for("update") + .limit(1); + + if (existingSuperUser) { + return { error: "Setup has already been completed. A super user already exists.", code: 409 }; + } + + // Lock the business_settings row for update to prevent concurrent setup + const [existingSettings] = await tx + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + + // Update or create business settings with the business name + if (existingSettings) { + await tx + .update(businessSettings) + .set({ businessName: body.businessName, updatedAt: new Date() }) + .where(eq(businessSettings.id, existingSettings.id)); + } else { + await tx.insert(businessSettings).values({ businessName: body.businessName }); + } + + // Find or create staff record for the authenticated user + let resolvedStaff = currentStaff; + + if (!resolvedStaff) { + // Try to find by userId + const [byUserId] = await tx + .select() + .from(staff) + .where(eq(staff.userId, jwt.sub)); + if (byUserId) { + resolvedStaff = byUserId; + } + } + + if (!resolvedStaff && jwt.email) { + // Try auto-link by email: staff record exists with matching email but no userId + const [byEmail] = await tx + .select() + .from(staff) + .where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`)); + if (byEmail) { + await tx + .update(staff) + .set({ userId: jwt.sub }) + .where(eq(staff.id, byEmail.id)); + resolvedStaff = { ...byEmail, userId: jwt.sub }; + } + } + + if (!resolvedStaff) { + // Brand new user during OOBE — create staff record + if (!jwt.email) { + return { error: "Cannot complete setup: authenticated user has no email claim", code: 400 }; + } + const [newStaff] = await tx + .insert(staff) + .values({ + name: jwt.name || jwt.email, + email: jwt.email, + userId: jwt.sub, + role: "manager", + isSuperUser: false, // will be set below + }) + .returning(); + resolvedStaff = newStaff!; + } + + // Mark as super user + const [updatedStaff] = await tx + .update(staff) + .set({ isSuperUser: true, updatedAt: new Date() }) + .where(eq(staff.id, resolvedStaff.id)) + .returning(); + + return { staff: updatedStaff }; + }); + + if ("error" in result) { + const status = (result as { code?: number }).code || 409; + return c.json({ error: result.error }, status as any); + } + + return c.json({ ok: true, staff: result.staff }, 201); +}); + +// ─── Auth Provider Bootstrap ────────────────────────────────────────────────── + +const authProviderBootstrapSchema = z.object({ + providerId: z.string().min(1).max(100), + displayName: z.string().min(1).max(200), + issuerUrl: z.string().url(), + internalBaseUrl: z.string().url().nullable().optional(), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + scopes: z.string().default("openid profile email"), +}); + +// Minimal schema for test endpoint — OIDC discovery only needs issuer/internal URLs +const authProviderTestSchema = z.object({ + issuerUrl: z.string().url(), + internalBaseUrl: z.string().url().nullable().optional(), +}); + +/** + * POST /api/setup/auth-provider + * Unauthenticated endpoint for first-time auth provider setup during OOBE. + * Only available when needsSetup is true (no super user = fresh install). + * Rate-limited by the API gateway; additionally restricted to first-time setup only. + * After setup completes, this endpoint permanently returns 403. + */ +setupRouter.post("/auth-provider", async (c) => { + const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const { allowed, remaining } = rateLimitByIp(ip); + c.res.headers.set("x-rate-limit-remaining", String(remaining)); + if (!allowed) { + return c.json({ error: "Too many requests. Please try again later." }, 429); + } + + const db = getDb(); + + let row: typeof authProviderConfig.$inferSelect; + try { + row = await db.transaction(async (tx) => { + const [superUser] = await tx + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + if (superUser) { + throw Object.assign(new Error("setup-complete"), { code: 403 }); + } + + const [existingConfig] = await tx + .select({ id: authProviderConfig.id }) + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + if (existingConfig) { + throw Object.assign(new Error("config-exists"), { code: 409 }); + } + + const body = authProviderBootstrapSchema.parse(await c.req.json()); + + const encryptedSecret = encryptSecret(body.clientSecret); + + const [configRow] = await tx + .insert(authProviderConfig) + .values({ + providerId: body.providerId, + displayName: body.displayName, + issuerUrl: body.issuerUrl, + internalBaseUrl: body.internalBaseUrl ?? null, + clientId: body.clientId, + clientSecret: encryptedSecret, + scopes: body.scopes, + enabled: true, + }) + .returning(); + + if (!configRow) { + throw Object.assign(new Error("insert-failed"), { code: 500 }); + } + + return configRow; + }); + } catch (err: unknown) { + const e = err as Error & { code?: number }; + if (e.message === "setup-complete") { + return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403); + } + if (e.message === "config-exists") { + return c.json({ error: "Auth provider is already configured." }, e.code as 409); + } + if (e.message === "insert-failed") { + return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500); + } + throw err; + } + + return c.json({ + id: row.id, + providerId: row.providerId, + displayName: row.displayName, + issuerUrl: row.issuerUrl, + internalBaseUrl: row.internalBaseUrl, + clientId: row.clientId, + scopes: row.scopes, + enabled: row.enabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, 201); +}); + +/** + * POST /api/setup/auth-provider/test + * Unauthenticated endpoint to validate an OIDC provider configuration during OOBE. + * Fetches the OIDC discovery document to confirm the issuer is reachable. + * Only available when needsSetup is true (no super user = fresh install). + */ +setupRouter.post("/auth-provider/test", async (c) => { + const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const { allowed, remaining } = rateLimitByIp(ip); + c.res.headers.set("x-rate-limit-remaining", String(remaining)); + if (!allowed) { + return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429); + } + + const db = getDb(); + + // Guard: only allow during fresh install (no super user yet) + const [superUser] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + if (superUser) { + return c.json({ ok: false, error: "Setup has already been completed." }, 403); + } + + const body = authProviderTestSchema.parse(await c.req.json()); + + // Determine the discovery URL + const discoveryUrl = body.internalBaseUrl + ? `${body.internalBaseUrl.replace(/\/$/, "")}/application/o/.well-known/openid-configuration` + : `${body.issuerUrl}/.well-known/openid-configuration`; + + try { + const res = await fetch(discoveryUrl, { method: "GET", signal: AbortSignal.timeout(10_000) }); + if (!res.ok) { + return c.json({ + ok: false, + error: `OIDC discovery failed (HTTP ${res.status}). Check your Issuer URL and Internal Base URL.`, + }); + } + return c.json({ ok: true }); + } catch { + return c.json({ + ok: false, + error: "Could not reach the OIDC provider. Check your Issuer URL and network connectivity.", + }); + } +}); diff --git a/src/routes/staff.ts b/src/routes/staff.ts new file mode 100644 index 0000000..813bd62 --- /dev/null +++ b/src/routes/staff.ts @@ -0,0 +1,244 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { randomBytes } from "node:crypto"; +import { and, eq, getDb, ne, staff, appointments } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const staffRouter = new Hono(); + +const createStaffSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().email(), + role: z.enum(["groomer", "receptionist", "manager"]).default("groomer"), + oidcSub: z.string().optional(), + active: z.boolean().default(true), + isSuperUser: z.boolean().optional(), +}); + +const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); + +const linkUserSchema = z.object({ + userId: z.string().min(1), +}); + +staffRouter.get("/me", async (c) => { + const staffRow = c.get("staff"); + return c.json(staffRow); +}); + +staffRouter.get("/", async (c) => { + const db = getDb(); + const includeInactive = c.req.query("includeInactive") === "true"; + const rows = includeInactive + ? await db.select().from(staff).orderBy(staff.name) + : await db.select().from(staff).where(eq(staff.active, true)).orderBy(staff.name); + return c.json(rows); +}); + +staffRouter.get("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(staff) + .where(eq(staff.id, c.req.param("id"))); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +staffRouter.post("/", zValidator("json", createStaffSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db.insert(staff).values(body).returning(); + return c.json(row, 201); +}); + +staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const currentStaff = c.get("staff"); + const targetId = c.req.param("id"); + + // Super user check: only super users can change isSuperUser + if (body.isSuperUser !== undefined && !currentStaff.isSuperUser) { + return c.json({ error: "Forbidden: only super users can grant or revoke super user status" }, 403); + } + + // If revoking super user status, check last-super-user guardrail + if (body.isSuperUser === false) { + const superUserCount = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) + .limit(2); // just need count; fetch 2 to know if > 1 + if (superUserCount.length <= 1) { + return c.json( + { error: "Cannot revoke the last super user. Assign another super user first." }, + 400 + ); + } + } + + // If deactivating a super user, check last-super-user guardrail + if (body.active === false) { + const [target] = await db + .select({ isSuperUser: staff.isSuperUser }) + .from(staff) + .where(eq(staff.id, targetId)) + .limit(1); + if (target?.isSuperUser) { + const superUserCount = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) + .limit(2); + if (superUserCount.length <= 1) { + return c.json( + { error: "Cannot deactivate the last super user. Assign another super user first." }, + 400 + ); + } + } + } + + const [row] = await db + .update(staff) + .set({ ...body, updatedAt: new Date() }) + .where(eq(staff.id, targetId)) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => { + const db = getDb(); + const targetId = c.req.param("id"); + const body = c.req.valid("json"); + const currentStaff = c.get("staff"); + + if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) { + return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403); + } + + const [existing] = await db + .select() + .from(staff) + .where(eq(staff.id, targetId)) + .limit(1); + if (!existing) return c.json({ error: "Not found" }, 404); + + const [updated] = await db + .update(staff) + .set({ userId: body.userId, updatedAt: new Date() }) + .where(eq(staff.id, targetId)) + .returning(); + + return c.json(updated); +}); + +staffRouter.delete("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + // Prevent deleting staff who have existing non-cancelled appointments (fixes #21). + const activeAppointments = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, id), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (activeAppointments.length > 0) { + return c.json( + { + error: + "Cannot delete staff member with existing appointments. Reassign or cancel their appointments first.", + }, + 409 + ); + } + + // Prevent deleting the last super user + const [target] = await db + .select({ isSuperUser: staff.isSuperUser }) + .from(staff) + .where(eq(staff.id, id)) + .limit(1); + if (target?.isSuperUser) { + const superUserCount = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) + .limit(2); + if (superUserCount.length <= 1) { + return c.json( + { error: "Cannot delete the last super user. Assign another super user first." }, + 400 + ); + } + } + + const [row] = await db + .delete(staff) + .where(eq(staff.id, id)) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); + +staffRouter.post("/:id/ical-token", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + + if (staffRow.role !== "manager" && staffRow.id !== id) { + return c.json({ error: "Forbidden" }, 403); + } + + const [member] = await db + .select() + .from(staff) + .where(eq(staff.id, id)) + .limit(1); + + if (!member) return c.json({ error: "Not found" }, 404); + + const token = randomBytes(32).toString("hex"); + const [updated] = await db + .update(staff) + .set({ icalToken: token, updatedAt: new Date() }) + .where(eq(staff.id, id)) + .returning(); + + if (!updated) return c.json({ error: "Not found" }, 404); + return c.json({ icalToken: updated.icalToken }); +}); + +staffRouter.delete("/:id/ical-token", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + + if (staffRow.role !== "manager" && staffRow.id !== id) { + return c.json({ error: "Forbidden" }, 403); + } + + const [member] = await db + .select() + .from(staff) + .where(eq(staff.id, id)) + .limit(1); + + if (!member) return c.json({ error: "Not found" }, 404); + + await db + .update(staff) + .set({ icalToken: null, updatedAt: new Date() }) + .where(eq(staff.id, id)); + + return c.json({ ok: true }); +}); diff --git a/src/routes/stripe-webhooks.ts b/src/routes/stripe-webhooks.ts new file mode 100644 index 0000000..fa7c8ef --- /dev/null +++ b/src/routes/stripe-webhooks.ts @@ -0,0 +1,119 @@ +import { Hono } from "hono"; +import Stripe from "stripe"; +import { z } from "zod/v3"; +import { eq, getDb, invoices } from "@groombook/db"; +import { getStripeClient } from "../services/payment.js"; + +export const webhooksRouter = new Hono(); + +webhooksRouter.post("/stripe", async (c) => { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + return c.json({ error: "Webhook secret not configured" }, 503); + } + + const signature = c.req.header("stripe-signature"); + if (!signature) { + return c.json({ error: "Missing signature" }, 401); + } + + let rawBody: string; + try { + rawBody = await c.req.text(); + } catch { + return c.json({ error: "Could not read body" }, 400); + } + + const stripe = getStripeClient(); + if (!stripe) { + return c.json({ error: "Stripe not configured" }, 503); + } + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); + } catch (err) { + const message = err instanceof Error ? err.message : "Invalid signature"; + return c.json({ error: message }, 401); + } + + const db = getDb(); + + if (event.type === "payment_intent.succeeded") { + const pi = event.data.object as Stripe.PaymentIntent; + if (pi.metadata?.groombook_invoice_ids) { + const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); + for (const invoiceId of invoiceIds) { + if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); + const [inv] = await db + .select() + .from(invoices) + .where(eq(invoices.id, invoiceIdTrimmed)) + .limit(1); + if (!inv) continue; + if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue; + await db + .update(invoices) + .set({ + status: "paid", + paymentMethod: "card", + paidAt: new Date(), + stripePaymentIntentId: pi.id, + updatedAt: new Date(), + }) + .where(eq(invoices.id, invoiceIdTrimmed)); + } + } + } else if (event.type === "payment_intent.payment_failed") { + const pi = event.data.object as Stripe.PaymentIntent; + if (pi.metadata?.groombook_invoice_ids) { + const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); + for (const invoiceId of invoiceIds) { + if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); + await db + .update(invoices) + .set({ + paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed", + updatedAt: new Date(), + }) + .where(eq(invoices.id, invoiceIdTrimmed)); + } + } + } else if (event.type === "charge.refunded") { + const charge = event.data.object as Stripe.Charge; + if (typeof charge.payment_intent === "string" && charge.payment_intent) { + const [inv] = await db + .select({ id: invoices.id }) + .from(invoices) + .where(eq(invoices.stripePaymentIntentId, charge.payment_intent)) + .limit(1); + if (inv) { + const refundId = + typeof charge.refunded === "boolean" && charge.refunded + ? `ch_${charge.id}_refund` + : null; + await db + .update(invoices) + .set({ + status: "void", + stripeRefundId: refundId, + updatedAt: new Date(), + }) + .where(eq(invoices.id, inv.id)); + } + } + } else if (event.type === "charge.dispute.created") { + const dispute = event.data.object as Stripe.Dispute; + console.error( + `[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}` + ); + } + + return c.json({ received: true }); +}); diff --git a/src/routes/waitlist.ts b/src/routes/waitlist.ts new file mode 100644 index 0000000..dd2adec --- /dev/null +++ b/src/routes/waitlist.ts @@ -0,0 +1,88 @@ +import { Hono } from "hono"; +import { + and, + eq, + lt, + getDb, + waitlistEntries, + clients, + pets, + services, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const waitlistRouter = new Hono(); + +async function markExpiredEntries(db: ReturnType, rows: { status: string; preferredDate: string }[]) { + const today = new Date().toISOString().slice(0, 10); + const hasExpired = rows.some((r) => r.status === "active" && r.preferredDate < today); + if (hasExpired) { + await db + .update(waitlistEntries) + .set({ status: "expired", updatedAt: new Date() }) + .where(and(eq(waitlistEntries.status, "active"), lt(waitlistEntries.preferredDate, today))); + } +} + +waitlistRouter.get("/", async (c) => { + const db = getDb(); + const date = c.req.query("date"); + + const conditions = []; + if (date) { + conditions.push(eq(waitlistEntries.preferredDate, date)); + } + + const rows = await db + .select({ + id: waitlistEntries.id, + clientId: waitlistEntries.clientId, + petId: waitlistEntries.petId, + serviceId: waitlistEntries.serviceId, + preferredDate: waitlistEntries.preferredDate, + preferredTime: waitlistEntries.preferredTime, + status: waitlistEntries.status, + notifiedAt: waitlistEntries.notifiedAt, + expiresAt: waitlistEntries.expiresAt, + createdAt: waitlistEntries.createdAt, + updatedAt: waitlistEntries.updatedAt, + clientName: clients.name, + clientEmail: clients.email, + petName: pets.name, + serviceName: services.name, + }) + .from(waitlistEntries) + .leftJoin(clients, eq(waitlistEntries.clientId, clients.id)) + .leftJoin(pets, eq(waitlistEntries.petId, pets.id)) + .leftJoin(services, eq(waitlistEntries.serviceId, services.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(waitlistEntries.createdAt); + + await markExpiredEntries(db, rows); + + const today = new Date().toISOString().slice(0, 10); + const enriched = rows.map((row) => ({ + ...row, + status: row.status === "active" && row.preferredDate < today ? "expired" : row.status, + })); + + return c.json(enriched); +}); + +waitlistRouter.get("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, c.req.param("id"))) + .limit(1); + if (!row) return c.json({ error: "Not found" }, 404); + + await markExpiredEntries(db, [row]); + const today = new Date().toISOString().slice(0, 10); + const isExpired = row.status === "active" && row.preferredDate < today; + return c.json({ + ...row, + status: isExpired ? "expired" : row.status, + }); +}); diff --git a/src/services/email.ts b/src/services/email.ts new file mode 100644 index 0000000..4cd4be9 --- /dev/null +++ b/src/services/email.ts @@ -0,0 +1,203 @@ +import nodemailer from "nodemailer"; +import type Mail from "nodemailer/lib/mailer/index.js"; + +// Returns null when SMTP is not configured — callers skip sending silently. +function createTransport(): nodemailer.Transporter | null { + const host = process.env.SMTP_HOST; + if (!host) return null; + + return nodemailer.createTransport({ + host, + port: Number(process.env.SMTP_PORT ?? 587), + secure: process.env.SMTP_SECURE === "true", + auth: + process.env.SMTP_USER + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } + : undefined, + }); +} + +let _transport: nodemailer.Transporter | null | undefined; + +function getTransport(): nodemailer.Transporter | null { + if (_transport === undefined) _transport = createTransport(); + return _transport; +} + +const FROM = process.env.SMTP_FROM ?? "Groom Book "; + +export async function sendEmail(opts: Mail.Options): Promise { + const transport = getTransport(); + if (!transport) return false; // SMTP not configured — skip silently + + await transport.sendMail({ from: FROM, ...opts }); + return true; +} + +// ─── Email templates ────────────────────────────────────────────────────────── + +interface AppointmentEmailData { + clientName: string; + petName: string; + serviceName: string; + groomerName: string | null; + startTime: Date; +} + +function formatDateTime(d: Date): string { + return d.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function buildConfirmationEmail( + to: string, + data: AppointmentEmailData +): Mail.Options { + const time = formatDateTime(data.startTime); + const groomer = data.groomerName ? ` with ${data.groomerName}` : ""; + return { + to, + subject: `Appointment Confirmed — ${data.petName} on ${data.startTime.toLocaleDateString()}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Your appointment has been confirmed!`, + ``, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` When: ${time}${groomer}`, + ``, + `We look forward to seeing you. If you need to reschedule, please contact us.`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Your appointment has been confirmed!

+ + + + +
Pet${data.petName}
Service${data.serviceName}
When${time}${groomer}
+

We look forward to seeing you. If you need to reschedule, please contact us.

+

— Groom Book

`, + }; +} + +export function buildReminderEmail( + to: string, + data: AppointmentEmailData, + hoursAhead: number, + confirmationToken?: string | null +): Mail.Options { + const time = formatDateTime(data.startTime); + const groomer = data.groomerName ? ` with ${data.groomerName}` : ""; + const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`; + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + + const confirmUrl = confirmationToken ? `${apiUrl}/api/book/confirm/${confirmationToken}` : null; + const cancelUrl = confirmationToken ? `${apiUrl}/api/book/cancel/${confirmationToken}` : null; + + const actionText = confirmationToken + ? [ + ``, + `Confirm your appointment: ${confirmUrl}`, + `Cancel your appointment: ${cancelUrl}`, + ].join("\n") + : ""; + + const actionHtml = confirmationToken + ? ` +` + : ""; + + return { + to, + subject: `Reminder: ${data.petName}'s appointment is ${when}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Just a reminder that ${data.petName}'s grooming appointment is ${when}.`, + ``, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` When: ${time}${groomer}`, + actionText, + `See you soon!`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Just a reminder that ${data.petName}'s grooming appointment is ${when}.

+ + + + +
Pet${data.petName}
Service${data.serviceName}
When${time}${groomer}
+${actionHtml} +

See you soon!

+

— Groom Book

`, + }; +} + +interface WaitlistNotificationData { + clientName: string; + petName: string; + serviceName: string; + preferredDate: string; + preferredTime: string; +} + +export function buildWaitlistNotificationEmail( + to: string, + data: WaitlistNotificationData +): Mail.Options { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const bookUrl = `${apiUrl}/book`; + return { + to, + subject: `Appointment Cancelled — A slot has opened up for ${data.petName}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Great news! An appointment slot has become available.`, + ``, + `We had a cancellation for:`, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` Date: ${data.preferredDate}`, + ` Time: ${data.preferredTime}`, + ``, + `If you're still interested, book now before this slot is taken!`, + ``, + `Book your appointment: ${bookUrl}`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Great news! An appointment slot has become available.

+

We had a cancellation for:

+ + + + + +
Pet${data.petName}
Service${data.serviceName}
Date${data.preferredDate}
Time${data.preferredTime}
+ +

If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.

+

— Groom Book

`, + }; +} diff --git a/src/services/payment.ts b/src/services/payment.ts new file mode 100644 index 0000000..eb97597 --- /dev/null +++ b/src/services/payment.ts @@ -0,0 +1,180 @@ +import Stripe from "stripe"; +import { getDb, clients, eq, inArray, invoices } from "@groombook/db"; + +let _stripe: Stripe | null | undefined; + +export function getStripeClient(): Stripe | null { + if (_stripe === undefined) { + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) return null; + _stripe = new Stripe(secretKey); + } + return _stripe; +} + +export async function getOrCreateStripeCustomer(clientId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return null; + + const db = getDb(); + const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); + if (!client) return null; + + if (client.stripeCustomerId) return client.stripeCustomerId; + + const customer = await stripe.customers.create({ + metadata: { groombook_client_id: clientId }, + }); + + await db + .update(clients) + .set({ stripeCustomerId: customer.id, updatedAt: new Date() }) + .where(eq(clients.id, clientId)); + + return customer.id; +} + +export async function createPaymentIntent( + invoiceIdOrIds: string | string[], + clientId: string +): Promise<{ clientSecret: string; paymentIntentId: string } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const db = getDb(); + const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds]; + const firstInvoiceId = invoiceIds[0]; + if (!firstInvoiceId) return null; + + const invoiceRows = await db + .select() + .from(invoices) + .where(eq(invoices.id, firstInvoiceId)); + + const [invoice] = invoiceRows; + if (!invoice) return null; + + let totalCents = invoice.totalCents; + if (invoiceIds.length > 1) { + const allInvoices = await db + .select({ totalCents: invoices.totalCents }) + .from(invoices) + .where(inArray(invoices.id, invoiceIds)); + totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0); + } + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return null; + + const paymentIntent = await stripe.paymentIntents.create({ + amount: totalCents, + currency: "usd", + customer: stripeCustomerId, + metadata: { + groombook_invoice_ids: invoiceIds.join(","), + groombook_client_id: clientId, + }, + automatic_payment_methods: { enabled: true }, + }); + + for (const invId of invoiceIds) { + await db + .update(invoices) + .set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() }) + .where(eq(invoices.id, invId)); + } + + const clientSecret = paymentIntent.client_secret; + if (!clientSecret) return null; + + return { clientSecret, paymentIntentId: paymentIntent.id }; +} + +export async function processRefund( + invoiceId: string, + amountCents?: number +): Promise<{ refundId: string } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const db = getDb(); + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1); + if (!invoice?.stripePaymentIntentId) return null; + + const refund = await stripe.refunds.create({ + payment_intent: invoice.stripePaymentIntentId, + amount: amountCents, + }); + + await db + .update(invoices) + .set({ stripeRefundId: refund.id, updatedAt: new Date() }) + .where(eq(invoices.id, invoiceId)); + + return { refundId: refund.id }; +} + +export async function listPaymentMethods(clientId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return null; + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return null; + + const methods = await stripe.paymentMethods.list({ + customer: stripeCustomerId, + type: "card", + }); + + return methods.data; +} + +export async function attachPaymentMethod( + clientId: string, + paymentMethodId: string +): Promise { + const stripe = getStripeClient(); + if (!stripe) return false; + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return false; + + await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId }); + return true; +} + +export async function detachPaymentMethod(paymentMethodId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return false; + + await stripe.paymentMethods.detach(paymentMethodId); + return true; +} + +export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const setupIntent = await stripe.setupIntents.create({ + customer: customerId, + payment_method_types: ["card"], + }); + + return { clientSecret: setupIntent.client_secret! }; +} + +export async function getPaymentIntentDetails( + paymentIntentId: string +): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] }); + const cardLast4 = pi.payment_method + ? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null + : null; + return { + cardLast4, + paymentStatus: pi.status ?? null, + }; +} diff --git a/src/services/reminders.ts b/src/services/reminders.ts new file mode 100644 index 0000000..1981258 --- /dev/null +++ b/src/services/reminders.ts @@ -0,0 +1,214 @@ +import cron from "node-cron"; +import { randomBytes } from "node:crypto"; +import { + and, + eq, + getDb, + gte, + inArray, + lt, + appointments, + clients, + pets, + services, + staff, + reminderLogs, + session, +} from "@groombook/db"; +import { + buildReminderEmail, + sendEmail, +} from "./email.js"; +import { smsSend } from "./sms.js"; + +const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply."; + +function getReminderWindows(): { label: string; hours: number }[] { + const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); + const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); + return [ + { label: `${early}h`, hours: early }, + { label: `${late}h`, hours: late }, + ]; +} + +export async function runReminderCheck(): Promise { + const db = getDb(); + const now = new Date(); + + for (const window of getReminderWindows()) { + const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000); + const windowEnd = new Date(now.getTime() + window.hours * 3600_000); + + const upcoming = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + clientId: appointments.clientId, + petId: appointments.petId, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + status: appointments.status, + confirmationToken: appointments.confirmationToken, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, windowStart), + lt(appointments.startTime, windowEnd), + eq(appointments.status, "scheduled") + ) + ); + + const appointmentIds: string[] = upcoming.map((a) => a.id as string); + if (appointmentIds.length === 0) continue; + + // Bulk check: which appointments already have email and SMS reminders sent? + const sentRows = await db + .select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.reminderType, window.label), + appointmentIds.length === 1 + ? eq(reminderLogs.appointmentId, appointmentIds[0]!) + : inArray(reminderLogs.appointmentId, appointmentIds) + ) + ); + + const sentEmail = new Set( + sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId) + ); + const sentSms = new Set( + sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId) + ); + + // Bulk JOIN: fetch all client/pet/service/staff data in one query + const joinedRows = await db + .select({ + appointmentId: appointments.id, + startTime: appointments.startTime, + clientId: appointments.clientId, + petId: appointments.petId, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + confirmationToken: appointments.confirmationToken, + clientName: clients.name, + clientEmail: clients.email, + clientEmailOptOut: clients.emailOptOut, + clientSmsOptIn: clients.smsOptIn, + clientPhone: clients.phone, + petName: pets.name, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .innerJoin(pets, eq(appointments.petId, pets.id)) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + gte(appointments.startTime, windowStart), + lt(appointments.startTime, windowEnd), + eq(appointments.status, "scheduled") + ) + ); + + const appointmentMap = new Map(); + for (const row of joinedRows) { + appointmentMap.set(row.appointmentId, row); + } + + for (const appt of upcoming) { + const joined = appointmentMap.get(appt.id as string); + if (!joined) continue; + + const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined; + + if (!clientEmail || clientEmailOptOut) continue; + if (!petName || !serviceName) continue; + + const emailSent = sentEmail.has(appt.id as string); + const smsSent = sentSms.has(appt.id as string); + + let confirmationToken = appt.confirmationToken; + if (!confirmationToken) { + confirmationToken = randomBytes(32).toString("hex"); + await db + .update(appointments) + .set({ confirmationToken, updatedAt: new Date() }) + .where(eq(appointments.id, appt.id)); + } + + if (!emailSent) { + const sent = await sendEmail( + buildReminderEmail( + clientEmail, + { + clientName, + petName, + serviceName, + groomerName: staffName, + startTime: appt.startTime, + }, + window.hours, + confirmationToken + ) + ); + + if (sent) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" }) + .onConflictDoNothing(); + } + } + + if (!smsSent && clientSmsOptIn && clientPhone) { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; + const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; + const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; + const smsBody = [ + `Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`, + `Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`, + `Confirm: ${confirmUrl}`, + `Cancel: ${cancelUrl}`, + TCPA_OPT_OUT, + ].join(". "); + try { + const smsOk = await smsSend(clientPhone, smsBody); + if (smsOk) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" }) + .onConflictDoNothing(); + } + } catch (err) { + console.error("[reminders] SMS send failed:", err); + } + } + } + } +} + +export function startReminderScheduler(): void { + cron.schedule("* * * * *", () => { + runReminderCheck().catch((err) => { + console.error("[reminders] Error during reminder check:", err); + }); + runSessionCleanup().catch((err) => { + console.error("[reminders] Error during session cleanup:", err); + }); + }); + console.log("[reminders] Reminder scheduler started"); +} + +export async function runSessionCleanup(): Promise { + const db = getDb(); + const now = new Date(); + await db + .delete(session) + .where(lt(session.expiresAt, now)); +} diff --git a/src/services/sms.ts b/src/services/sms.ts new file mode 100644 index 0000000..5be4009 --- /dev/null +++ b/src/services/sms.ts @@ -0,0 +1,142 @@ +import { Telnyx } from "telnyx"; +import { createHmac } from "crypto"; + +export interface SmsProvider { + sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>; + validateWebhookSignature(req: Request): boolean; +} + +interface TelnyxSmsResult { + message_id: string; + status: string; +} + +function createTelnyxClient(): Telnyx | null { + const apiKey = process.env.TELNYX_API_KEY; + if (!apiKey) return null; + return new Telnyx(apiKey); +} + +let _client: Telnyx | null | undefined; + +function getClient(): Telnyx | null { + if (_client === undefined) _client = createTelnyxClient(); + return _client; +} + +function getFromNumber(): string | null { + return process.env.TELNYX_FROM_NUMBER ?? null; +} + +function isE164(phone: string): boolean { + return /^\+[1-9]\d{7,14}$/.test(phone); +} + +export async function sendSms( + to: string, + body: string, + mediaUrls?: string[] +): Promise<{ messageId: string; status: string }> { + const client = getClient(); + if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY."); + + const from = getFromNumber(); + if (!from) throw new Error("TELNYX_FROM_NUMBER is not set"); + + if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`); + if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`); + + const payload: Record = { + from, + to, + body, + }; + + if (mediaUrls && mediaUrls.length > 0) { + payload.media_urls = mediaUrls; + } + + const result = await client.messages.create(payload as Record); + const smsResult = result.data as unknown as TelnyxSmsResult; + return { + messageId: smsResult.message_id, + status: smsResult.status, + }; +} + +export class TelnyxProvider implements SmsProvider { + async sendSms( + to: string, + body: string, + mediaUrls?: string[] + ): Promise<{ messageId: string; status: string }> { + return sendSms(to, body, mediaUrls); + } + + validateWebhookSignature(req: Request): boolean { + const secret = process.env.TELNYX_WEBHOOK_SECRET; + if (!secret) return false; + + const signature = req.headers.get("telnyx-signature"); + if (!signature) return false; + + const payload = JSON.stringify(req.body); + + try { + const hmac = createHmac("sha256", secret); + const expected = `sha256=${hmac.update(payload).digest("hex")}`; + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + + if (sigBuf.length !== expBuf.length) return false; + + let diff = 0; + for (let i = 0; i < sigBuf.length; i++) { + const sigByte = sigBuf[i] ?? 0; + const expByte = expBuf[i] ?? 0; + diff |= sigByte ^ expByte; + } + return diff === 0; + } catch { + return false; + } + } +} + +let _provider: SmsProvider | null | undefined; + +export function createSmsProvider(): SmsProvider | null { + if (_provider === undefined) { + if (process.env.SMS_ENABLED !== "true") { + _provider = null; + return null; + } + switch (process.env.SMS_PROVIDER) { + case "telnyx": { + const client = getClient(); + if (!client) { + _provider = null; + return null; + } + _provider = new TelnyxProvider(); + break; + } + default: + _provider = null; + } + } + return _provider; +} + +export async function smsSend( + to: string, + body: string, + mediaUrls?: string[] +): Promise { + const provider = createSmsProvider(); + if (!provider) return false; + + await provider.sendSms(to, body, mediaUrls); + return true; +} diff --git a/src/services/waitlistNotify.ts b/src/services/waitlistNotify.ts new file mode 100644 index 0000000..2338515 --- /dev/null +++ b/src/services/waitlistNotify.ts @@ -0,0 +1,63 @@ +import { and, eq, getDb, waitlistEntries, clients, pets, services } from "@groombook/db"; +import { buildWaitlistNotificationEmail, sendEmail } from "./email.js"; + +export async function notifyWaitlistForAppointment( + appointmentId: string, + appointmentDate: string, + appointmentTime: string, + serviceId: string +): Promise { + const db = getDb(); + + const matchingEntries = await db + .select() + .from(waitlistEntries) + .where( + and( + eq(waitlistEntries.preferredDate, appointmentDate), + eq(waitlistEntries.preferredTime, appointmentTime), + eq(waitlistEntries.serviceId, serviceId), + eq(waitlistEntries.status, "active") + ) + ); + + for (const entry of matchingEntries) { + const [client] = await db + .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .from(clients) + .where(eq(clients.id, entry.clientId)) + .limit(1); + + if (!client?.email || client.emailOptOut) continue; + + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, entry.petId)) + .limit(1); + + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, entry.serviceId)) + .limit(1); + + if (!pet || !service) continue; + + const email = buildWaitlistNotificationEmail(client.email, { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + preferredDate: appointmentDate, + preferredTime: appointmentTime, + }); + + const sent = await sendEmail(email); + if (sent) { + await db + .update(waitlistEntries) + .set({ status: "notified", notifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(waitlistEntries.id, entry.id)); + } + } +} diff --git a/src/types/telnyx.d.ts b/src/types/telnyx.d.ts new file mode 100644 index 0000000..097916e --- /dev/null +++ b/src/types/telnyx.d.ts @@ -0,0 +1,19 @@ +declare module "telnyx" { + export interface MessageResult { + data: unknown; + } + + export interface MessagesCreateParams { + from: string; + to: string; + body: string; + media_urls?: string[]; + } + + export class Telnyx { + constructor(apiKey: string); + messages: { + create(params: Record): Promise; + }; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3b421a7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..763633d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@groombook/db/factories": path.resolve(__dirname, "./packages/db/src/factories.ts"), + "@groombook/db": path.resolve(__dirname, "./packages/db/src/index.ts"), + }, + }, + test: { + coverage: { + provider: "v8", + include: ["src/lib/**"], + thresholds: { + lines: 80, + functions: 80, + }, + }, + }, +});