Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42a57121d3 | |||
| 5b97f2246d | |||
| b4cef1bef7 | |||
| bd8d97baa7 |
@@ -1,257 +0,0 @@
|
|||||||
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 --filter @groombook/api typecheck
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: pnpm --filter @groombook/api 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 --filter @groombook/api test
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [lint-typecheck, test]
|
|
||||||
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: Build
|
|
||||||
run: pnpm --filter @groombook/api build
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Build & Push Docker Images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build]
|
|
||||||
outputs:
|
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
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 GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push API image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile
|
|
||||||
target: runner
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
|
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build and push Migrate image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile
|
|
||||||
target: migrate
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
|
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build and push Seed image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile
|
|
||||||
target: seed
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
|
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build and push Reset image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile
|
|
||||||
target: reset
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
|
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
cd:
|
|
||||||
name: Update Infra Image Tags
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [docker]
|
|
||||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Generate infra repo token
|
|
||||||
id: infra-token
|
|
||||||
uses: tibdex/github-app-token@v2
|
|
||||||
with:
|
|
||||||
app_id: ${{ vars.GH_APP_ID }}
|
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Clone groombook/infra
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
|
||||||
|
|
||||||
- name: Install yq
|
|
||||||
run: |
|
|
||||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
|
||||||
sudo chmod +x /usr/local/bin/yq
|
|
||||||
|
|
||||||
- name: Update dev overlay image tags
|
|
||||||
env:
|
|
||||||
TAG: ${{ needs.docker.outputs.tag }}
|
|
||||||
SHA: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$TAG" ]; then
|
|
||||||
TAG="$(date -u +%Y.%m.%d)-${SHA::7}"
|
|
||||||
fi
|
|
||||||
export SHORT_SHA="${SHA::7}"
|
|
||||||
echo "Updating dev overlay image tags to: $TAG"
|
|
||||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
|
||||||
cd /tmp/infra
|
|
||||||
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
|
||||||
|
|
||||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
|
||||||
fi
|
|
||||||
|
|
||||||
SEED_JOB="apps/base/seed-job.yaml"
|
|
||||||
if [ -f "$SEED_JOB" ]; then
|
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git -C /tmp/infra diff --stat
|
|
||||||
|
|
||||||
- name: Create PR on groombook/infra
|
|
||||||
env:
|
|
||||||
TAG: ${{ needs.docker.outputs.tag }}
|
|
||||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$TAG" ]; then
|
|
||||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd /tmp/infra
|
|
||||||
git config user.name "groombook-engineer[bot]"
|
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
|
||||||
git checkout -b "chore/update-image-tags-${TAG}"
|
|
||||||
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
|
||||||
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
|
||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${TAG}"
|
|
||||||
|
|
||||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
|
||||||
if [ -n "$EXISTING_PR" ]; then
|
|
||||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
|
||||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
|
||||||
else
|
|
||||||
PR_URL=$(gh pr create \
|
|
||||||
--repo groombook/infra \
|
|
||||||
--base main \
|
|
||||||
--head "chore/update-image-tags-${TAG}" \
|
|
||||||
--title "chore: deploy ${TAG} to dev" \
|
|
||||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
|
||||||
gh pr merge "$PR_URL" --merge
|
|
||||||
fi
|
|
||||||
@@ -33,11 +33,6 @@ 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" |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
|
|||||||
@@ -1,416 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|
||||||
import { petsRouter } from "../routes/pets.js";
|
|
||||||
import { and, eq, exists, or } from "../db/index.js";
|
|
||||||
|
|
||||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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", async (importOriginal) => {
|
|
||||||
const db = await importOriginal<typeof import("../db/index.js")>();
|
|
||||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
|
||||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
|
||||||
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: db.and,
|
|
||||||
eq: db.eq,
|
|
||||||
exists: db.exists,
|
|
||||||
or: db.or,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 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: "double" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.coatType).toBe("double");
|
|
||||||
});
|
|
||||||
|
|
||||||
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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -26,7 +26,6 @@ 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";
|
||||||
@@ -212,7 +211,6 @@ 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"));
|
||||||
@@ -270,7 +268,6 @@ 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);
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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,7 +24,6 @@ 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,9 +13,7 @@ const createServiceSchema = z.object({
|
|||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateServiceSchema = createServiceSchema.partial().extend({
|
const updateServiceSchema = createServiceSchema.partial();
|
||||||
defaultBufferMinutes: z.number().int().min(0).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
servicesRouter.get("/", async (c) => {
|
servicesRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -26,19 +26,6 @@ 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;
|
||||||
|
|||||||
@@ -116,26 +116,6 @@ 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(
|
||||||
@@ -198,7 +178,6 @@ export const services = pgTable("services", {
|
|||||||
durationMinutes: integer("duration_minutes").notNull(),
|
durationMinutes: integer("duration_minutes").notNull(),
|
||||||
defaultBufferMinutes: integer("default_buffer_minutes"),
|
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(),
|
||||||
});
|
});
|
||||||
@@ -661,34 +640,3 @@ 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),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user