Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1d28635ba | |||
| 51b45b529d | |||
| 4204bea2b3 | |||
| f36a3626a8 | |||
| 90b3811577 | |||
| 467b85abc7 | |||
| e417d8f6a7 | |||
| fc82e24ead | |||
| c3c99ad6c4 | |||
| a205fe1138 | |||
| ff024ab375 | |||
| 01069f8c6c | |||
| 43f17dc612 | |||
| d9bfed4424 | |||
| 1403517067 | |||
| 9c5e470737 | |||
| f1258023ac | |||
| faf7def77d | |||
| c19e19c709 | |||
| f9a3ebc0f3 | |||
| d3122ad701 | |||
| 539ef21d89 | |||
| 9ccbc7a171 | |||
| 9ba5da5e75 | |||
| 575789f7f5 | |||
| 4f981bbebd | |||
| d8f2135506 | |||
| a0a75d7e25 | |||
| 22457ac361 | |||
| f12ec4f8d3 | |||
| 566d5f4b55 | |||
| 434c7b94e2 | |||
| 70af9da338 | |||
| d9ee14b17e | |||
| 9ed28f8bab | |||
| abac9dfe6c | |||
| 4d7baec939 |
@@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch or ref to run CI against"
|
||||
required: false
|
||||
default: "main"
|
||||
|
||||
jobs:
|
||||
lint-typecheck:
|
||||
name: Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate image tag
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
||||
else
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Image tag: $TAG"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.DS_Store
|
||||
*.log
|
||||
.turbo/
|
||||
coverage/
|
||||
minimax-output/
|
||||
|
||||
+25
-11
@@ -2,37 +2,51 @@ FROM node:20-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/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
pnpm --filter @groombook/db build && \
|
||||
pnpm --filter @groombook/api build
|
||||
|
||||
# Runtime
|
||||
FROM node:20-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"]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+6
-1
@@ -28,7 +28,12 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
|
||||
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
|
||||
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
|
||||
| TC-API-1.4 | 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.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
|
||||
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
||||
|
||||
### 4.2 Client Management
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration: 0030_extended_pet_profile
|
||||
-- Adds extended profile fields to the pets table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE pets ADD COLUMN coat_type text;
|
||||
ALTER TABLE pets ADD COLUMN temperament_score integer;
|
||||
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "0030_extended_pet_profile",
|
||||
"prevId": "0028_sms_reminders",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"species": { "name": "species", "type": "text", "isNullable": false },
|
||||
"breed": { "name": "breed", "type": "text", "isNullable": true },
|
||||
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true },
|
||||
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true },
|
||||
"health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true },
|
||||
"grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "isNullable": true },
|
||||
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true },
|
||||
"special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true },
|
||||
"custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" },
|
||||
"photo_key": { "name": "photo_key", "type": "text", "isNullable": true },
|
||||
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true },
|
||||
"image": { "name": "image", "type": "text", "isNullable": true },
|
||||
"coat_type": { "name": "coat_type", "type": "text", "isNullable": true },
|
||||
"temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true },
|
||||
"temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } },
|
||||
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||
}
|
||||
@@ -204,6 +204,20 @@
|
||||
"when": 1775741667192,
|
||||
"tag": "0028_sms_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1775828067192,
|
||||
"tag": "0029_db_indexes_constraints",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1775914467192,
|
||||
"tag": "0030_extended_pet_profile",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { petsRouter } from "../routes/pets.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||
|
||||
const CLIENT_ID = "a0000000-0000-4000-8000-000000000001";
|
||||
const PET_ID = "b0000000-0000-4000-8000-000000000002";
|
||||
|
||||
let petRows: Record<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
let deletedId: string | null = null;
|
||||
|
||||
function resetMock() {
|
||||
petRows = [{
|
||||
id: PET_ID,
|
||||
clientId: CLIENT_ID,
|
||||
name: "Biscuit",
|
||||
species: "dog",
|
||||
breed: "Golden Retriever",
|
||||
weightKg: "30.00",
|
||||
dateOfBirth: null,
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}];
|
||||
appointmentRows = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
deletedId = null;
|
||||
}
|
||||
|
||||
function makeSelectChainable(rows: unknown[]): unknown {
|
||||
const chain = new Proxy([...rows], {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChainable(): unknown {
|
||||
let vals: Record<string, unknown> = {};
|
||||
const chain = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
if (prop === "values") {
|
||||
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
||||
}
|
||||
if (prop === "returning") {
|
||||
return () => {
|
||||
insertedValues.push(vals);
|
||||
return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }];
|
||||
};
|
||||
}
|
||||
return chain;
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeUpdateChainable(): unknown {
|
||||
let vals: Record<string, unknown> = {};
|
||||
let whereId: string | null = null;
|
||||
const chain = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
if (prop === "set") {
|
||||
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
||||
}
|
||||
if (prop === "where") {
|
||||
return (cond: unknown) => {
|
||||
// Extract id from condition if it's an eq call
|
||||
if (whereId) vals = { ...vals };
|
||||
return chain;
|
||||
};
|
||||
}
|
||||
if (prop === "returning") {
|
||||
return () => {
|
||||
const merged = { ...petRows[0], ...vals };
|
||||
updatedValues.push(vals);
|
||||
return [merged];
|
||||
};
|
||||
}
|
||||
return chain;
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeDeleteChainable(): unknown {
|
||||
let whereId: string | null = null;
|
||||
const chain = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
if (prop === "where") {
|
||||
return (cond: unknown) => {
|
||||
whereId = PET_ID;
|
||||
return chain;
|
||||
};
|
||||
}
|
||||
if (prop === "returning") {
|
||||
return () => {
|
||||
const row = petRows[0];
|
||||
deletedId = row.id as string;
|
||||
return [row];
|
||||
};
|
||||
}
|
||||
return chain;
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const name = (table as { _name?: string })._name;
|
||||
if (name === "appointments") return makeSelectChainable(appointmentRows);
|
||||
return makeSelectChainable(petRows);
|
||||
},
|
||||
}),
|
||||
insert: () => makeInsertChainable(),
|
||||
update: () => makeUpdateChainable(),
|
||||
delete: () => makeDeleteChainable(),
|
||||
}),
|
||||
pets,
|
||||
appointments,
|
||||
and: (...conds: unknown[]) => conds,
|
||||
eq: (col: unknown, val: unknown) => ({ col, val }),
|
||||
exists: (q: unknown) => q,
|
||||
or: (...conds: unknown[]) => conds,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeApp(staff: StaffRow = MANAGER) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("staff", staff);
|
||||
await next();
|
||||
});
|
||||
return app.route("/pets", petsRouter);
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = makeApp(MANAGER);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Extended pet profile fields — validation", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("rejects temperamentScore of 0 (below min)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects temperamentScore of 6 (above max)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer temperamentScore", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid medicalAlert severity", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: CLIENT_ID,
|
||||
name: "Test",
|
||||
species: "dog",
|
||||
medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts valid temperamentScore 1–5", async () => {
|
||||
const app = createApp();
|
||||
for (const score of [1, 2, 3, 4, 5]) {
|
||||
resetMock();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts all valid medicalAlert severity values", async () => {
|
||||
const app = createApp();
|
||||
for (const severity of ["low", "medium", "high"] as const) {
|
||||
resetMock();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: CLIENT_ID,
|
||||
name: "Test",
|
||||
species: "dog",
|
||||
medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Extended pet profile fields — create", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("accepts all extended fields on create", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: CLIENT_ID,
|
||||
name: "Biscuit",
|
||||
species: "dog",
|
||||
breed: "Golden Retriever",
|
||||
coatType: "double",
|
||||
temperamentScore: 4,
|
||||
temperamentFlags: ["anxious_with_dryers", "gentle"],
|
||||
medicalAlerts: [
|
||||
{ type: "seizure", description: "Occasional episodes", severity: "medium" },
|
||||
],
|
||||
preferredCuts: ["puppy cut", "teddy bear"],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.coatType).toBe("double");
|
||||
expect(body.temperamentScore).toBe(4);
|
||||
expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]);
|
||||
expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]);
|
||||
expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]);
|
||||
});
|
||||
|
||||
it("create without extended fields works (all optional)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Extended pet profile fields — update", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("updates coatType", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request(`/pets/${PET_ID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ coatType: "smooth" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.coatType).toBe("smooth");
|
||||
});
|
||||
|
||||
it("updates temperamentScore", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request(`/pets/${PET_ID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ temperamentScore: 2 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.temperamentScore).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects temperamentScore 0 on update", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request(`/pets/${PET_ID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ temperamentScore: 0 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid severity on update", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request(`/pets/${PET_ID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects too many temperamentFlags (>20)", async () => {
|
||||
const app = createApp();
|
||||
const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`);
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects too many preferredCuts (>20)", async () => {
|
||||
const app = createApp();
|
||||
const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`);
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects too many medicalAlerts (>50)", async () => {
|
||||
const app = createApp();
|
||||
const alerts = Array.from({ length: 51 }, (_, i) => ({
|
||||
type: `type_${i}`,
|
||||
description: `desc_${i}`,
|
||||
severity: "low" as const,
|
||||
}));
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns extended fields in GET response", async () => {
|
||||
petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }];
|
||||
const app = createApp();
|
||||
const res = await app.request(`/pets/${PET_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.coatType).toBe("wire");
|
||||
expect(body.temperamentScore).toBe(3);
|
||||
expect(body.temperamentFlags).toEqual(["gentle"]);
|
||||
expect(body.preferredCuts).toEqual(["scissor cut"]);
|
||||
});
|
||||
});
|
||||
@@ -45,72 +45,40 @@ 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 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) => ({
|
||||
limit: () => ({
|
||||
[Symbol.iterator]: function* () {
|
||||
if (result) yield result;
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return "staff";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "staff", column: prop };
|
||||
},
|
||||
0: result,
|
||||
length: result ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => ({
|
||||
where: () => buildQuery(
|
||||
table === staff ? staffLookupResult : userLookupResult,
|
||||
table === staff ? managerFallbackResult : null
|
||||
),
|
||||
}),
|
||||
}),
|
||||
insert: (table: unknown) => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
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];
|
||||
},
|
||||
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,
|
||||
user,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
sql: vi.fn((..._args: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -119,8 +87,6 @@ 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. */
|
||||
@@ -236,50 +202,6 @@ 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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,16 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── Shared types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: MedicalAlertSeverity;
|
||||
}
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
||||
@@ -146,6 +156,12 @@ export const pets = pgTable(
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
// Extended profile fields
|
||||
coatType: text("coat_type"),
|
||||
temperamentScore: integer("temperament_score"),
|
||||
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
||||
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
|
||||
+85
-1
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff, user } from "../db/index.js";
|
||||
import { and, eq, getDb, sql, staff } from "../db/index.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -110,30 +110,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = 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();
|
||||
c.set("staff", newStaff);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
|
||||
@@ -24,6 +24,15 @@ const createPetSchema = z.object({
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).optional(),
|
||||
customFields: z.record(z.string(), z.string()).optional(),
|
||||
coatType: z.string().max(100).optional(),
|
||||
temperamentScore: z.number().int().min(1).max(5).optional(),
|
||||
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
||||
medicalAlerts: z.array(z.object({
|
||||
type: z.string().max(100),
|
||||
description: z.string().max(1000),
|
||||
severity: z.enum(["low", "medium", "high"]),
|
||||
})).max(50).optional(),
|
||||
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
|
||||
});
|
||||
|
||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||
|
||||
@@ -42,10 +42,23 @@ export interface Pet {
|
||||
customFields: Record<string, string>;
|
||||
photoKey?: string;
|
||||
photoUploadedAt?: string;
|
||||
coatType?: string | null;
|
||||
temperamentScore?: number | null;
|
||||
temperamentFlags?: string[];
|
||||
medicalAlerts?: MedicalAlert[];
|
||||
preferredCuts?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: MedicalAlertSeverity;
|
||||
}
|
||||
|
||||
export interface GroomingVisitLog {
|
||||
id: string;
|
||||
petId: string;
|
||||
|
||||
@@ -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: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -3,5 +3,41 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE staff ADD COLUMN ical_token TEXT UNIQUE;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add image field to pets table for demo pet image support
|
||||
ALTER TABLE "pets" ADD COLUMN "image" text;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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": {} }
|
||||
}
|
||||
@@ -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": {} }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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<string, number> = {};
|
||||
|
||||
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> = {}): 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> = {}): 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<PetRow> & { 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,
|
||||
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> = {}): ServiceRow {
|
||||
const id = nextId("service");
|
||||
return {
|
||||
id,
|
||||
name: `Service ${id}`,
|
||||
description: "A grooming service",
|
||||
basePriceCents: 6500,
|
||||
durationMinutes: 60,
|
||||
active: true,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAppointment(
|
||||
overrides: Partial<AppointmentRow> & { 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 };
|
||||
}
|
||||
@@ -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<typeof drizzle> | 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<typeof getDb>;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,603 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
// ─── 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(),
|
||||
});
|
||||
|
||||
// ─── 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: text("coat_type"),
|
||||
petSizeCategory: text("pet_size_category"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().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(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
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<Record<string, unknown>>(),
|
||||
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(),
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<string, unknown> | 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<T> {
|
||||
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";
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+350
-323
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,2 +1,2 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] = [];
|
||||
let deletedRows: string[] = [];
|
||||
let insertedRows: Record<string, unknown>[] = [];
|
||||
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<string, unknown>) => {
|
||||
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: <T>(fn: (tx: {
|
||||
delete: () => Promise<unknown>;
|
||||
insert: () => { values: (v: Record<string, unknown>) => { returning: () => T[] } };
|
||||
}) => Promise<T>) => {
|
||||
const tx = {
|
||||
delete: () => { deletedRows.push("all"); return Promise.resolve([]); },
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
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<T extends Hono = Hono>(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<T extends Hono = Hono>(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<T extends Hono = Hono>(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<T extends Hono = Hono>(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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
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<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
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<string, unknown>) => ({
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<AppointmentRow> & { 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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown> }> = [];
|
||||
|
||||
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<string, unknown>)];
|
||||
}
|
||||
return [makeAuditLog(vals as Record<string, unknown>)];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: (table: { _name: string }) => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
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<AppEnv>();
|
||||
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<typeof requireRole>)) 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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<AppEnv>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | null = null;
|
||||
let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
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<string, unknown>) => ({
|
||||
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<string, string>) {
|
||||
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<string, string>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<AppEnv>,
|
||||
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||
await next();
|
||||
});
|
||||
app.use("*", middleware);
|
||||
const h = handler ?? ((c: Context<AppEnv>) => 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<AppEnv>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
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<AppEnv>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] = [];
|
||||
let insertedStaff: Record<string, unknown>[] = [];
|
||||
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<string, unknown>)) : 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<string, unknown>) => {
|
||||
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<string, unknown>) => ({
|
||||
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<string, unknown>)) : 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<string, unknown>) => {
|
||||
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<unknown>) => 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<string, unknown>): boolean {
|
||||
if (!cond || typeof cond !== "object") return true;
|
||||
const c = cond as Record<string, unknown>;
|
||||
if (c.__type === "eq") {
|
||||
const colObj = c.col as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] = [];
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
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<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
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<string, string>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
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 { 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<string, Uint8Array> = {
|
||||
"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);
|
||||
|
||||
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;
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
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: {
|
||||
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: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
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;
|
||||
}
|
||||
+107
@@ -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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<Uint8Array>) {
|
||||
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<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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<PortalEnv> = 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);
|
||||
}
|
||||
};
|
||||
@@ -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<PortalEnv> = 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();
|
||||
};
|
||||
@@ -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<AppEnv> = 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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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();
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user