Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ba6045ad | |||
| b83a793de4 | |||
| a610ef9d39 | |||
| cf3d30f19e | |||
| 0625961adf | |||
| b61d899f81 | |||
| 38047d5ea3 | |||
| fbcaedf155 | |||
| 7cfb24d542 | |||
| b0d9e5816f | |||
| 7a0662541d | |||
| 5e78df85f1 | |||
| 0a2259b67f | |||
| cc09a8e1e8 | |||
| 74da042d13 | |||
| ad1b210de1 | |||
| a03771f7e7 | |||
| 040ff4a253 | |||
| a1466b44c9 | |||
| b486c44a82 | |||
| b5a08a2c7e | |||
| 06d72b5baf | |||
| 33aa63b10f | |||
| e26d960046 | |||
| 4e8c66f3ca | |||
| ea28095434 | |||
| 3b9c72c2c4 | |||
| 49f70eb74b | |||
| 62dfc7776b | |||
| 68df697cf3 | |||
| 174d1c667b | |||
| 9fe6e15012 | |||
| 002e6575ba | |||
| f9c679b392 | |||
| ce0739b3ba | |||
| 3609087980 | |||
| 7b2b533c16 | |||
| 55894c6ff2 | |||
| f55c74983f | |||
| 8bdab69288 | |||
| 8f7104c3a0 | |||
| dab9bfab71 | |||
| 70bc946a0d | |||
| 40422a14f0 | |||
| 9462915a66 | |||
| 46f134a294 | |||
| 4086b6f5c0 | |||
| d7d98791c7 | |||
| 3bec5d095a | |||
| 3fddf80fac | |||
| 1ea319e122 | |||
| da913d600f | |||
| ce9fcfb362 | |||
| 59893908e2 | |||
| 2b78fcf731 | |||
| c9e176f08c | |||
| 0cab1522cf | |||
| 2a27e8bee2 | |||
| d6f7ade7bd | |||
| 00dadac0a1 | |||
| 9692476202 | |||
| 44da26820b | |||
| 21981fbdc4 | |||
| 85fc803548 | |||
| 6a3c1aa65e | |||
| 490ab06e8c | |||
| 609f86b927 | |||
| 9f2809e89b |
@@ -0,0 +1 @@
|
|||||||
|
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
|
||||||
+57
-7
@@ -25,17 +25,17 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm typecheck
|
run: pnpm --filter @groombook/api typecheck
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm --filter @groombook/api lint
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
@@ -49,17 +49,17 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm --filter @groombook/api test
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Build & Push Docker Image
|
name: Build & Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint-typecheck, test]
|
needs: [lint-typecheck, test]
|
||||||
steps:
|
steps:
|
||||||
@@ -78,6 +78,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -89,11 +91,59 @@ jobs:
|
|||||||
- name: Build and push API image
|
- name: Build and push API image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
provenance: false
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Migrate image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
provenance: false
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: migrate
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Seed image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
provenance: false
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: seed
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Reset image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
provenance: false
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: reset
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ COPY src/ src/
|
|||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
RUN pnpm --filter @groombook/types build && \
|
RUN pnpm --filter @groombook/types build && \
|
||||||
pnpm --filter @groombook/db build && \
|
pnpm --filter @groombook/db build && \
|
||||||
pnpm --filter @groombook/api build
|
pnpm build
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
FROM node:20-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -42,12 +42,12 @@ CMD ["node", "dist/index.js"]
|
|||||||
|
|
||||||
# Migrate stage — runs drizzle-kit migrate against the database
|
# Migrate stage — runs drizzle-kit migrate against the database
|
||||||
FROM builder AS migrate
|
FROM builder AS migrate
|
||||||
CMD ["pnpm", "db:migrate"]
|
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
|
||||||
|
|
||||||
# Seed stage — populates the database with test data
|
# Seed stage — populates the database with test data
|
||||||
FROM builder AS seed
|
FROM builder AS seed
|
||||||
CMD ["pnpm", "db:seed"]
|
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
|
||||||
|
|
||||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||||
FROM builder AS reset
|
FROM builder AS reset
|
||||||
CMD ["pnpm", "db:reset"]
|
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
|
|
||||||
## Test Cases
|
## Test Cases
|
||||||
|
|
||||||
|
### 4.0 Health Check
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
||||||
|
|
||||||
|
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
||||||
|
|
||||||
### 4.1 Authentication
|
### 4.1 Authentication
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
@@ -33,6 +41,32 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| 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.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
|
||||||
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
||||||
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
|
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
|
||||||
|
| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved |
|
||||||
|
| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true |
|
||||||
|
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
|
||||||
|
| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) |
|
||||||
|
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
|
||||||
|
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
|
||||||
|
|
||||||
|
#### SSO Login Journey (Authentik OIDC end-to-end)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||||
|
|---|----------|-------|---------------|---------------|
|
||||||
|
| TC-API-1.17 | SSO redirect to Authentik | Navigate to app → sign-in page shown → click "Sign in with SSO" | Redirected to Authentik at auth.farh.net | 403 error, redirect loop, no SSO button |
|
||||||
|
| TC-API-1.18 | Authenticate with valid OIDC credentials | At Authentik login page, enter valid credentials and authenticate | Redirected back to app with valid session | Redirect loop, 403, missing session cookie |
|
||||||
|
| TC-API-1.19 | SSO user auto-provisioned as groomer | Complete SSO login as a user with no pre-existing staff record | 200 response; groomer staff record auto-created; session active | 403 Forbidden, staff record not created |
|
||||||
|
| TC-API-1.20 | Existing staff record resolves correctly | Complete SSO login as uat-groomer (pre-existing staff) | 200 OK, correct staff identity resolved, no duplicate record created | 403, duplicate record, wrong staff data |
|
||||||
|
| TC-API-1.21 | SSO session grants dashboard access | After TC-API-1.18 SSO login, GET /api/staff/me | 200 OK, valid staff record returned, correct role displayed | 401/403, missing session, wrong identity |
|
||||||
|
|
||||||
|
#### OOBE Flow Post-Login
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||||
|
|---|----------|-------|---------------|---------------|
|
||||||
|
| TC-API-1.22 | Fresh DB reports needsSetup | On a fresh DB (no super user), GET /api/setup/status | needsSetup: true returned | needsSetup: false when it should be true |
|
||||||
|
| TC-API-1.23 | Configure OIDC via auth-provider endpoint | POST /api/setup/auth-provider with valid OIDC config | 200 OK, auth provider configured, no 403 | 403, setup blocked, invalid config rejected |
|
||||||
|
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
||||||
|
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
||||||
|
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||||
import { petsRouter } from "../routes/pets.js";
|
import { petsRouter } from "../routes/pets.js";
|
||||||
|
import { and, eq, exists, or } from "../db/index.js";
|
||||||
|
|
||||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ function makeDeleteChainable(): unknown {
|
|||||||
}
|
}
|
||||||
if (prop === "returning") {
|
if (prop === "returning") {
|
||||||
return () => {
|
return () => {
|
||||||
const row = petRows[0];
|
const row = petRows[0]!;
|
||||||
deletedId = row.id as string;
|
deletedId = row.id as string;
|
||||||
return [row];
|
return [row];
|
||||||
};
|
};
|
||||||
@@ -164,10 +165,10 @@ vi.mock("../db", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
pets,
|
pets,
|
||||||
appointments,
|
appointments,
|
||||||
and: (...conds: unknown[]) => conds,
|
and: vi.fn(),
|
||||||
eq: (col: unknown, val: unknown) => ({ col, val }),
|
eq: vi.fn(),
|
||||||
exists: (q: unknown) => q,
|
exists: vi.fn(),
|
||||||
or: (...conds: unknown[]) => conds,
|
or: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ vi.mock("../db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const impersonationAuditLogs = new Proxy(
|
||||||
|
{ _name: "impersonationAuditLogs" },
|
||||||
|
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||||
|
);
|
||||||
|
|
||||||
const appointments = new Proxy(
|
const appointments = new Proxy(
|
||||||
{ _name: "appointments" },
|
{ _name: "appointments" },
|
||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||||
@@ -99,8 +104,12 @@ vi.mock("../db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: () => ({ returning: () => [{}] }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
|
impersonationAuditLogs,
|
||||||
appointments,
|
appointments,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { getDb, businessSettings, eq, staff } from "./db/index.js";
|
|||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
|
import { bufferRulesRouter } from "./routes/buffer-rules.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
@@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
|||||||
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||||
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
|
api.use("/buffer-rules/*", requireRole("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||||
@@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter);
|
|||||||
api.route("/admin/settings", settingsRouter);
|
api.route("/admin/settings", settingsRouter);
|
||||||
api.route("/admin/auth-provider", authProviderRouter);
|
api.route("/admin/auth-provider", authProviderRouter);
|
||||||
api.route("/admin/seed", adminSeedRouter);
|
api.route("/admin/seed", adminSeedRouter);
|
||||||
|
api.route("/buffer-rules", bufferRulesRouter);
|
||||||
api.route("/search", searchRouter);
|
api.route("/search", searchRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ const DEMO_PET = {
|
|||||||
weightKg: "30.00",
|
weightKg: "30.00",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UAT_CLIENT = {
|
||||||
|
name: "UAT Customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
phone: "555-0100",
|
||||||
|
address: "1 UAT Lane, Test City, CA 90210",
|
||||||
|
status: "active" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UAT_PETS = [
|
||||||
|
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
|
||||||
|
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
|
||||||
|
];
|
||||||
|
|
||||||
const DEMO_SERVICES = [
|
const DEMO_SERVICES = [
|
||||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||||
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
|
|||||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||||
];
|
];
|
||||||
|
|
||||||
adminSeedRouter.post("/seed", async (c) => {
|
adminSeedRouter.post("/", async (c) => {
|
||||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
return c.json(
|
return c.json(
|
||||||
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
|
|||||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Client: UAT Customer ──────────────────────────────────────────────────
|
||||||
|
const [existingUatClient] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.email, UAT_CLIENT.email));
|
||||||
|
|
||||||
|
let uatClientId: string;
|
||||||
|
if (existingUatClient) {
|
||||||
|
uatClientId = existingUatClient.id;
|
||||||
|
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
|
||||||
|
} else {
|
||||||
|
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
|
||||||
|
uatClientId = created!.id;
|
||||||
|
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
|
||||||
|
const existingUatPets = await db
|
||||||
|
.select()
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.clientId, uatClientId));
|
||||||
|
|
||||||
|
for (const uatPet of UAT_PETS) {
|
||||||
|
const existingPet = existingUatPets.find(
|
||||||
|
(p) => p.name === uatPet.name && p.species === uatPet.species
|
||||||
|
);
|
||||||
|
if (existingPet) {
|
||||||
|
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(pets)
|
||||||
|
.values({
|
||||||
|
clientId: uatClientId,
|
||||||
|
name: uatPet.name,
|
||||||
|
species: uatPet.species,
|
||||||
|
breed: uatPet.breed,
|
||||||
|
coatType: uatPet.coatType,
|
||||||
|
weightKg: uatPet.weightKg,
|
||||||
|
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
message: "Seed complete",
|
message: "Seed complete",
|
||||||
details: results,
|
details: results,
|
||||||
@@ -136,4 +194,4 @@ adminSeedRouter.post("/seed", async (c) => {
|
|||||||
staffOidcSub: KNOWN_STAFF.oidcSub,
|
staffOidcSub: KNOWN_STAFF.oidcSub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { and, eq, getDb, isNull } from "../db/index.js";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
import { bufferRules, services } from "../db/index.js";
|
||||||
|
|
||||||
|
export const bufferRulesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
const createBufferRuleSchema = z.object({
|
||||||
|
serviceId: z.string().uuid(),
|
||||||
|
sizeCategory: z
|
||||||
|
.enum(["small", "medium", "large", "extra_large"])
|
||||||
|
.optional(),
|
||||||
|
coatType: z
|
||||||
|
.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"])
|
||||||
|
.optional(),
|
||||||
|
bufferMinutes: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBufferRuleSchema = z.object({
|
||||||
|
bufferMinutes: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET / — list all buffer rules, optionally filtered by serviceId
|
||||||
|
bufferRulesRouter.get("/", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const serviceId = c.req.query("serviceId");
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: bufferRules.id,
|
||||||
|
serviceId: bufferRules.serviceId,
|
||||||
|
sizeCategory: bufferRules.sizeCategory,
|
||||||
|
coatType: bufferRules.coatType,
|
||||||
|
bufferMinutes: bufferRules.bufferMinutes,
|
||||||
|
createdAt: bufferRules.createdAt,
|
||||||
|
updatedAt: bufferRules.updatedAt,
|
||||||
|
serviceName: services.name,
|
||||||
|
})
|
||||||
|
.from(bufferRules)
|
||||||
|
.innerJoin(services, eq(bufferRules.serviceId, services.id))
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(bufferRules.createdAt);
|
||||||
|
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST / — create a buffer rule
|
||||||
|
bufferRulesRouter.post(
|
||||||
|
"/",
|
||||||
|
zValidator("json", createBufferRuleSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
// Validate serviceId exists
|
||||||
|
const [svc] = await db
|
||||||
|
.select({ id: services.id })
|
||||||
|
.from(services)
|
||||||
|
.where(eq(services.id, body.serviceId));
|
||||||
|
if (!svc) return c.json({ error: "Service not found" }, 404);
|
||||||
|
|
||||||
|
// Check for duplicate (service + size + coat)
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: bufferRules.id })
|
||||||
|
.from(bufferRules)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(bufferRules.serviceId, body.serviceId),
|
||||||
|
body.sizeCategory !== undefined
|
||||||
|
? eq(bufferRules.sizeCategory, body.sizeCategory)
|
||||||
|
: isNull(bufferRules.sizeCategory),
|
||||||
|
body.coatType !== undefined
|
||||||
|
? eq(bufferRules.coatType, body.coatType)
|
||||||
|
: isNull(bufferRules.coatType)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(bufferRules)
|
||||||
|
.values({
|
||||||
|
serviceId: body.serviceId,
|
||||||
|
sizeCategory: body.sizeCategory ?? null,
|
||||||
|
coatType: body.coatType ?? null,
|
||||||
|
bufferMinutes: body.bufferMinutes,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(row, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /:id — update bufferMinutes only
|
||||||
|
bufferRulesRouter.patch(
|
||||||
|
"/:id",
|
||||||
|
zValidator("json", updateBufferRuleSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const [row] = await db
|
||||||
|
.update(bufferRules)
|
||||||
|
.set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() })
|
||||||
|
.where(eq(bufferRules.id, c.req.param("id")))
|
||||||
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
return c.json(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /:id — delete a buffer rule
|
||||||
|
bufferRulesRouter.delete("/:id", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [row] = await db
|
||||||
|
.delete(bufferRules)
|
||||||
|
.where(eq(bufferRules.id, c.req.param("id")))
|
||||||
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ const createPetSchema = z.object({
|
|||||||
shampooPreference: z.string().max(500).optional(),
|
shampooPreference: z.string().max(500).optional(),
|
||||||
specialCareNotes: z.string().max(2000).optional(),
|
specialCareNotes: z.string().max(2000).optional(),
|
||||||
customFields: z.record(z.string(), z.string()).optional(),
|
customFields: z.record(z.string(), z.string()).optional(),
|
||||||
|
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||||
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||||
temperamentScore: z.number().int().min(1).max(5).optional(),
|
temperamentScore: z.number().int().min(1).max(5).optional(),
|
||||||
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const createServiceSchema = z.object({
|
|||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateServiceSchema = createServiceSchema.partial();
|
const updateServiceSchema = createServiceSchema.partial().extend({
|
||||||
|
defaultBufferMinutes: z.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
servicesRouter.get("/", async (c) => {
|
servicesRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ export interface Client {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Medical Alerts ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AlertSeverity = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface MedicalAlert {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pet Profile Summary ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless";
|
||||||
export interface Pet {
|
export interface Pet {
|
||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.15.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc --project .",
|
"build": "tsc --project .",
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
|
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
|
||||||
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
|
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
|
||||||
|
|
||||||
-- ─── Alter pets columns to use new enums ─────────────────────────────────────
|
-- ─── Add columns to pets if missing, then cast to enums ──────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "coat_type" text;
|
||||||
|
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "pet_size_category" text;
|
||||||
ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type";
|
ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type";
|
||||||
ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category";
|
ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
-- no-op: journal entry exists but no schema change was needed
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Migration: 0033_add_services_default_buffer_minutes.sql
|
||||||
|
-- Adds missing default_buffer_minutes column to services table.
|
||||||
|
-- 0031_buffer_rules was applied to the DB but its journal entry was missing,
|
||||||
|
-- so this ensures idempotent column addition for fresh DB restores.
|
||||||
|
|
||||||
|
ALTER TABLE "services" ADD COLUMN IF NOT EXISTS "default_buffer_minutes" integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"id": "0033_add_services_default_buffer_minutes",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
@@ -218,6 +218,27 @@
|
|||||||
"when": 1775828067192,
|
"when": 1775828067192,
|
||||||
"tag": "0030_messaging",
|
"tag": "0030_messaging",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775860800000,
|
||||||
|
"tag": "0031_buffer_rules",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775894400000,
|
||||||
|
"tag": "0032_staff_read_at",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779500000000,
|
||||||
|
"tag": "0033_add_services_default_buffer_minutes",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -105,8 +105,10 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
|||||||
photoKey: null,
|
photoKey: null,
|
||||||
photoUploadedAt: null,
|
photoUploadedAt: null,
|
||||||
image: null,
|
image: null,
|
||||||
coatType: null,
|
temperamentScore: null,
|
||||||
petSizeCategory: null,
|
temperamentFlags: [],
|
||||||
|
medicalAlerts: [],
|
||||||
|
preferredCuts: [],
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
};
|
};
|
||||||
@@ -121,7 +123,7 @@ export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
|
|||||||
description: "A grooming service",
|
description: "A grooming service",
|
||||||
basePriceCents: 6500,
|
basePriceCents: 6500,
|
||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
defaultBufferMinutes: null,
|
defaultBufferMinutes: 0,
|
||||||
active: true,
|
active: true,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function getDb() {
|
|||||||
if (_db) return _db;
|
if (_db) return _db;
|
||||||
const url = process.env.DATABASE_URL;
|
const url = process.env.DATABASE_URL;
|
||||||
if (!url) throw new Error("DATABASE_URL is not set");
|
if (!url) throw new Error("DATABASE_URL is not set");
|
||||||
const client = postgres(url, { max: 10 });
|
const client = postgres(url, { max: 10, connect_timeout: 5 });
|
||||||
_db = drizzle(client, { schema });
|
_db = drizzle(client, { schema });
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-39
@@ -11,6 +11,7 @@ import {
|
|||||||
unique,
|
unique,
|
||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import type { MedicalAlert } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,22 +49,6 @@ export const clientStatusEnum = pgEnum("client_status", [
|
|||||||
"disabled",
|
"disabled",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
|
|
||||||
"small",
|
|
||||||
"medium",
|
|
||||||
"large",
|
|
||||||
"xlarge",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const coatTypeEnum = pgEnum("coat_type", [
|
|
||||||
"smooth",
|
|
||||||
"double",
|
|
||||||
"wire",
|
|
||||||
"curly",
|
|
||||||
"long",
|
|
||||||
"hairless",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
@@ -116,6 +101,26 @@ export const verification = pgTable("verification", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Pet enums ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
|
||||||
|
"small",
|
||||||
|
"medium",
|
||||||
|
"large",
|
||||||
|
"extra_large",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const coatTypeEnum = pgEnum("coat_type", [
|
||||||
|
"short",
|
||||||
|
"medium",
|
||||||
|
"long",
|
||||||
|
"double",
|
||||||
|
"wire",
|
||||||
|
"silky",
|
||||||
|
"curly",
|
||||||
|
"hairless",
|
||||||
|
]);
|
||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable(
|
export const clients = pgTable(
|
||||||
@@ -160,6 +165,10 @@ export const pets = pgTable(
|
|||||||
specialCareNotes: text("special_care_notes"),
|
specialCareNotes: text("special_care_notes"),
|
||||||
coatType: coatTypeEnum("coat_type"),
|
coatType: coatTypeEnum("coat_type"),
|
||||||
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
|
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
|
||||||
|
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([]),
|
||||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||||
photoKey: text("photo_key"),
|
photoKey: text("photo_key"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
@@ -176,34 +185,12 @@ export const services = pgTable("services", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
basePriceCents: integer("base_price_cents").notNull(),
|
basePriceCents: integer("base_price_cents").notNull(),
|
||||||
durationMinutes: integer("duration_minutes").notNull(),
|
durationMinutes: integer("duration_minutes").notNull(),
|
||||||
defaultBufferMinutes: integer("default_buffer_minutes"),
|
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
|
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bufferRules = pgTable(
|
|
||||||
"buffer_rules",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
serviceId: uuid("service_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => services.id, { onDelete: "cascade" }),
|
|
||||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
|
||||||
coatType: coatTypeEnum("coat_type"),
|
|
||||||
bufferMinutes: integer("buffer_minutes").notNull(),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
unique("uq_buffer_rules_service_size_coat").on(
|
|
||||||
t.serviceId,
|
|
||||||
t.sizeCategory,
|
|
||||||
t.coatType
|
|
||||||
),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const staff = pgTable("staff", {
|
export const staff = pgTable("staff", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
@@ -640,3 +627,34 @@ export const authProviderConfig = pgTable("auth_provider_config", {
|
|||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Buffer Rules ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Buffer time rules per service + pet size/coat combination.
|
||||||
|
// Covers service-level defaults and pet-specific overrides.
|
||||||
|
export const bufferRules = pgTable(
|
||||||
|
"buffer_rules",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
serviceId: uuid("service_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => services.id, { onDelete: "cascade" }),
|
||||||
|
// null sizeCategory means "any size" (wildcard)
|
||||||
|
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||||
|
// null coatType means "any coat type" (wildcard)
|
||||||
|
coatType: coatTypeEnum("coat_type"),
|
||||||
|
// minutes to add to the service duration for this size/coat combo
|
||||||
|
bufferMinutes: integer("buffer_minutes").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// One rule per unique (service, size, coat) combination
|
||||||
|
unique("uq_buffer_rules_service_size_coat").on(
|
||||||
|
t.serviceId,
|
||||||
|
t.sizeCategory,
|
||||||
|
t.coatType
|
||||||
|
),
|
||||||
|
index("idx_buffer_rules_service_id").on(t.serviceId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
Generated
+6
-2
@@ -28,7 +28,7 @@ importers:
|
|||||||
version: 0.7.6(hono@4.12.18)(zod@4.4.3)
|
version: 0.7.6(hono@4.12.18)(zod@4.4.3)
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
|
version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.4
|
specifier: ^0.38.4
|
||||||
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
||||||
@@ -84,6 +84,9 @@ importers:
|
|||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
better-auth:
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.4
|
specifier: ^0.38.4
|
||||||
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
||||||
@@ -3927,7 +3930,7 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
better-auth@1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)):
|
better-auth@1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
'@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||||
'@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))
|
'@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))
|
||||||
@@ -3947,6 +3950,7 @@ snapshots:
|
|||||||
nanostores: 1.3.0
|
nanostores: 1.3.0
|
||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
drizzle-kit: 0.30.6
|
||||||
drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
||||||
vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0)
|
vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
+10
-5
@@ -58,8 +58,11 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Health check (no auth required)
|
// Health check — no auth required, registered on app at full path before auth middleware
|
||||||
|
// /health: used by Dockerfile HEALTHCHECK and K8s readinessProbe/livenessProbe (port 3000 direct)
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
|
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
||||||
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
|
|
||||||
// Public booking routes — no auth required, must be registered before auth middleware
|
// Public booking routes — no auth required, must be registered before auth middleware
|
||||||
app.route("/api/book", bookRouter);
|
app.route("/api/book", bookRouter);
|
||||||
@@ -282,14 +285,16 @@ startReminderScheduler();
|
|||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log("Shutting down gracefully...");
|
console.log("Shutting down gracefully...");
|
||||||
|
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
||||||
|
// If graceful close takes >8s, force-exit to avoid being killed undrained
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error("Graceful close timeout — forcing exit");
|
||||||
|
process.exit(1);
|
||||||
|
}, 8_000);
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log("HTTP server closed");
|
console.log("HTTP server closed");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
|
||||||
console.error("Forced shutdown after timeout");
|
|
||||||
process.exit(1);
|
|
||||||
}, 10_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|||||||
+7
-1
@@ -186,7 +186,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||||
let oidcConfig: Record<string, string> = {};
|
let oidcConfig: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
const discoveryRes = await fetch(discoveryUrlStr);
|
const discoveryRes = await fetch(discoveryUrlStr, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
if (discoveryRes.ok) {
|
if (discoveryRes.ok) {
|
||||||
const discovery = await discoveryRes.json() as {
|
const discovery = await discoveryRes.json() as {
|
||||||
authorization_endpoint?: string;
|
authorization_endpoint?: string;
|
||||||
@@ -251,6 +253,10 @@ export async function initAuth(): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
enabled: true,
|
||||||
|
trustedProviders: ["authentik"],
|
||||||
|
},
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
},
|
},
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
if (c.req.path.startsWith("/api/auth/")) {
|
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -110,6 +110,48 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
|
||||||
|
// (e.g. authentik). If so, create a groomer staff record on the fly.
|
||||||
|
if (jwt.email) {
|
||||||
|
const [oidcAccount] = await db
|
||||||
|
.select({ id: account.id })
|
||||||
|
.from(account)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(account.userId, jwt.sub),
|
||||||
|
sql`${account.providerId} IN ('authentik', 'google', 'github')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (oidcAccount) {
|
||||||
|
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
|
||||||
|
const name =
|
||||||
|
jwt.name?.trim() ||
|
||||||
|
(jwt.email ? jwt.email.split("@")[0] : "Unknown");
|
||||||
|
|
||||||
|
const [newStaff] = await db
|
||||||
|
.insert(staff)
|
||||||
|
.values({
|
||||||
|
userId: jwt.sub,
|
||||||
|
email: jwt.email ?? "",
|
||||||
|
name,
|
||||||
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
|
||||||
|
);
|
||||||
|
c.set("staff", newStaff);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ const DEMO_PET = {
|
|||||||
weightKg: "30.00",
|
weightKg: "30.00",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UAT_CLIENT = {
|
||||||
|
name: "UAT Customer",
|
||||||
|
email: "uat-customer@groombook.dev",
|
||||||
|
phone: "555-0100",
|
||||||
|
address: "1 UAT Lane, Test City, CA 90210",
|
||||||
|
status: "active" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UAT_PETS = [
|
||||||
|
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
|
||||||
|
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth", weightKg: "30.00" },
|
||||||
|
];
|
||||||
|
|
||||||
const DEMO_SERVICES = [
|
const DEMO_SERVICES = [
|
||||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||||
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
|
|||||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||||
];
|
];
|
||||||
|
|
||||||
adminSeedRouter.post("/seed", async (c) => {
|
adminSeedRouter.post("/", async (c) => {
|
||||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
return c.json(
|
return c.json(
|
||||||
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
|
|||||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Client: UAT Customer ──────────────────────────────────────────────────
|
||||||
|
const [existingUatClient] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.email, UAT_CLIENT.email));
|
||||||
|
|
||||||
|
let uatClientId: string;
|
||||||
|
if (existingUatClient) {
|
||||||
|
uatClientId = existingUatClient.id;
|
||||||
|
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
|
||||||
|
} else {
|
||||||
|
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
|
||||||
|
uatClientId = created!.id;
|
||||||
|
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
|
||||||
|
const existingUatPets = await db
|
||||||
|
.select()
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.clientId, uatClientId));
|
||||||
|
|
||||||
|
for (const uatPet of UAT_PETS) {
|
||||||
|
const existing = existingUatPets.find(
|
||||||
|
(p) => p.name === uatPet.name && p.species === uatPet.species
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existing.id})`);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(pets)
|
||||||
|
.values({
|
||||||
|
clientId: uatClientId,
|
||||||
|
name: uatPet.name,
|
||||||
|
species: uatPet.species,
|
||||||
|
breed: uatPet.breed,
|
||||||
|
coatType: uatPet.coatType as any,
|
||||||
|
weightKg: uatPet.weightKg,
|
||||||
|
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
message: "Seed complete",
|
message: "Seed complete",
|
||||||
details: results,
|
details: results,
|
||||||
|
|||||||
+2
-2
@@ -193,8 +193,8 @@ bookRouter.post(
|
|||||||
name: body.petName,
|
name: body.petName,
|
||||||
species: body.petSpecies,
|
species: body.petSpecies,
|
||||||
breed: body.petBreed ?? null,
|
breed: body.petBreed ?? null,
|
||||||
coatType: (body.petCoatType ?? null) as "smooth" | "double" | "wire" | "curly" | "long" | "hairless" | null,
|
coatType: (body.petCoatType ?? null) as "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless" | null,
|
||||||
petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "xlarge" | null,
|
petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "extra_large" | null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
const pet = petInserted[0];
|
const pet = petInserted[0];
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ bufferRulesRouter.use("*", requireRole("manager"));
|
|||||||
|
|
||||||
const createBufferRuleSchema = z.object({
|
const createBufferRuleSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(),
|
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||||
coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(),
|
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||||
bufferMinutes: z.number().int().positive(),
|
bufferMinutes: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -24,8 +24,8 @@ const createPetSchema = z.object({
|
|||||||
shampooPreference: z.string().max(500).optional(),
|
shampooPreference: z.string().max(500).optional(),
|
||||||
specialCareNotes: z.string().max(2000).optional(),
|
specialCareNotes: z.string().max(2000).optional(),
|
||||||
customFields: z.record(z.string(), z.string()).optional(),
|
customFields: z.record(z.string(), z.string()).optional(),
|
||||||
sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(),
|
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||||
coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(),
|
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user