Compare commits
8 Commits
d9e7c36a09
..
pr-44
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b54bbae65 | |||
| ce9fcfb362 | |||
| 59893908e2 | |||
| 2b78fcf731 | |||
| f12ec4f8d3 | |||
| 2c928ca4d7 | |||
| af75fecb66 | |||
| 2d4df6fe1e |
@@ -0,0 +1,161 @@
|
||||
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: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm 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: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate image tag
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
||||
else
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Image tag: $TAG"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ gitea.token }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: runner
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||
|
||||
- name: Build and push Migrate image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: migrate
|
||||
push: true
|
||||
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:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: seed
|
||||
push: true
|
||||
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:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: reset
|
||||
push: true
|
||||
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
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -202,20 +202,20 @@ jobs:
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||
cd /tmp/infra
|
||||
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
||||
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
SEED_JOB="apps/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git checkout -b "chore/update-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
||||
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
FROM node:20-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -12,12 +12,12 @@ RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/api build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/apps/api/package.json apps/api/
|
||||
COPY --from=builder /app/apps/api/dist apps/api/dist
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
@@ -67,20 +67,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-4.9 | Cancel confirmation | POST /api/appointments/{id}/cancel | 200 OK, confirmation cancelled |
|
||||
| TC-API-4.10 | Conflict detection | POST /api/appointments with conflicting time | 409 Conflict, error message returned |
|
||||
|
||||
### 4.4b Buffer-Aware Availability & Booking
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|---|---|---|
|
||||
| TC-API-4b.1 | Buffer blocks subsequent slot | Create a large/long-coat appointment (30-min buffer), then check availability — next slot starts after 09:00 + duration + 30-min buffer | Available slot list correctly excludes times within buffer window |
|
||||
| TC-API-4b.2 | Buffer resolves by pet size | GET /availability with petSizeCategory=large&petCoatType=long → expect larger buffer than small/normal | Slots reflect larger buffer, fewer available times |
|
||||
| TC-API-4b.3 | Buffer resolves by pet size — small/short coat | GET /availability with petSizeCategory=small&petCoatType=short → expect 5-min buffer | Slots reflect smaller buffer, more available times |
|
||||
| TC-API-4b.4 | Buffer defaults when pet info missing | GET /availability without petSizeCategory/petCoatType → defaults to medium/normal (10-min buffer) | Slots use default 10-min buffer |
|
||||
| TC-API-4b.5 | Appointment stores bufferMinutes | POST /appointments with petSizeCategory=large&petCoatType=long → appointment record has bufferMinutes=30 | 201 Created, appointment.bufferMinutes = 30 |
|
||||
| TC-API-4b.6 | Buffer prevents double-booking at buffer boundary | Groomer has 09:00–10:00 appointment with 30-min buffer; POST appointment at 10:15 → should succeed (10:15 > 10:30 effective end) | 201 Created |
|
||||
| TC-API-4b.7 | Buffer prevents overlap booking | Groomer has 09:00–10:00 appointment with 30-min buffer; POST appointment at 10:00 → should be blocked (10:00 ≤ 10:30 effective end) | 409 Conflict |
|
||||
| TC-API-4b.8 | Backward compatibility — no buffer params | GET /availability without petSizeCategory/petCoatType and POST without them | Behaves as before with 0-min buffer or default 10-min |
|
||||
| TC-API-4b.9 | Admin booking also uses buffers | Create appointment via POST /api/appointments (admin) with pet info → bufferMinutes resolved and stored | 201 Created, bufferMinutes set |
|
||||
|
||||
### 4.5 Services
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Migration: 0031_buffer_and_pet_size
|
||||
-- Adds buffer_minutes to appointments and pet_size_category to pets
|
||||
-- (buffer_minutes was already in schema.ts but no migration created the column)
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE appointments ADD COLUMN IF NOT EXISTS buffer_minutes integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE pets ADD COLUMN IF NOT EXISTS pet_size_category text;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,512 +0,0 @@
|
||||
{
|
||||
"id": "0031_buffer_and_pet_size",
|
||||
"prevId": "0030_extended_pet_profile",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.appointments": {
|
||||
"name": "appointments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pet_id": {
|
||||
"name": "pet_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"service_id": {
|
||||
"name": "service_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"staff_id": {
|
||||
"name": "staff_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bather_staff_id": {
|
||||
"name": "bather_staff_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "appointment_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'scheduled'"
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"series_id": {
|
||||
"name": "series_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"series_index": {
|
||||
"name": "series_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"confirmation_status": {
|
||||
"name": "confirmation_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"confirmed_at": {
|
||||
"name": "confirmed_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cancelled_at": {
|
||||
"name": "cancelled_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"confirmation_token": {
|
||||
"name": "confirmation_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"customer_notes": {
|
||||
"name": "customer_notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"buffer_minutes": {
|
||||
"name": "buffer_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "0"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"appointments_client_id_clients_id_fk": {
|
||||
"name": "appointments_client_id_clients_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_pet_id_pets_id_fk": {
|
||||
"name": "appointments_pet_id_pets_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "pets",
|
||||
"columnsFrom": [
|
||||
"pet_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_service_id_services_id_fk": {
|
||||
"name": "appointments_service_id_services_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "services",
|
||||
"columnsFrom": [
|
||||
"service_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_staff_id_staff_id_fk": {
|
||||
"name": "appointments_staff_id_staff_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "staff",
|
||||
"columnsFrom": [
|
||||
"staff_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_bather_staff_id_staff_id_fk": {
|
||||
"name": "appointments_bather_staff_id_staff_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "staff",
|
||||
"columnsFrom": [
|
||||
"bather_staff_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_series_id_recurring_series_id_fk": {
|
||||
"name": "appointments_series_id_recurring_series_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "recurring_series",
|
||||
"columnsFrom": [
|
||||
"series_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_group_id_appointment_groups_id_fk": {
|
||||
"name": "appointments_group_id_appointment_groups_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "appointment_groups",
|
||||
"columnsFrom": [
|
||||
"group_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"appointments_confirmation_token_unique": {
|
||||
"name": "appointments_confirmation_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"confirmation_token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"species": {
|
||||
"name": "species",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"breed": {
|
||||
"name": "breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"weight_kg": {
|
||||
"name": "weight_kg",
|
||||
"type": "numeric(5, 2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"date_of_birth": {
|
||||
"name": "date_of_birth",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"health_alerts": {
|
||||
"name": "health_alerts",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grooming_notes": {
|
||||
"name": "grooming_notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cut_style": {
|
||||
"name": "cut_style",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"shampoo_preference": {
|
||||
"name": "shampoo_preference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"special_care_notes": {
|
||||
"name": "special_care_notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"custom_fields": {
|
||||
"name": "custom_fields",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"photo_key": {
|
||||
"name": "photo_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"photo_uploaded_at": {
|
||||
"name": "photo_uploaded_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"pet_size_category": {
|
||||
"name": "pet_size_category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"coat_type": {
|
||||
"name": "coat_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"temperament_score": {
|
||||
"name": "temperament_score",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"temperament_flags": {
|
||||
"name": "temperament_flags",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"medical_alerts": {
|
||||
"name": "medical_alerts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"preferred_cuts": {
|
||||
"name": "preferred_cuts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pets_client_id_clients_id_fk": {
|
||||
"name": "pets_client_id_clients_id_fk",
|
||||
"tableFrom": "pets",
|
||||
"tableTo": "clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.appointment_status": {
|
||||
"name": "appointment_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"scheduled",
|
||||
"confirmed",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"no_show"
|
||||
]
|
||||
},
|
||||
"public.client_status": {
|
||||
"name": "client_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"public.impersonation_session_status": {
|
||||
"name": "impersonation_session_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"ended",
|
||||
"expired"
|
||||
]
|
||||
},
|
||||
"public.invoice_status": {
|
||||
"name": "invoice_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"draft",
|
||||
"pending",
|
||||
"paid",
|
||||
"void"
|
||||
]
|
||||
},
|
||||
"public.payment_method": {
|
||||
"name": "payment_method",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"cash",
|
||||
"card",
|
||||
"check",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"public.staff_role": {
|
||||
"name": "staff_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"groomer",
|
||||
"receptionist",
|
||||
"manager"
|
||||
]
|
||||
},
|
||||
"public.waitlist_status": {
|
||||
"name": "waitlist_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"notified",
|
||||
"expired",
|
||||
"cancelled"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -218,13 +218,6 @@
|
||||
"when": 1775914467192,
|
||||
"tag": "0030_extended_pet_profile",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1776000867192,
|
||||
"tag": "0031_buffer_and_pet_size",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { and, eq, exists, or } from "drizzle-orm";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
@@ -22,8 +21,8 @@ const MANAGER: StaffRow = {
|
||||
|
||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||
|
||||
const CLIENT_ID = "12345678-1234-1234-1234-123456789abc";
|
||||
const PET_ID = "pet-uuid-extended";
|
||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const PET_ID = "660e8400-e29b-41d4-a716-446655440002";
|
||||
|
||||
let petRows: Record<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
@@ -135,7 +134,7 @@ function makeDeleteChainable(): unknown {
|
||||
}
|
||||
if (prop === "returning") {
|
||||
return () => {
|
||||
const row = petRows[0];
|
||||
const row = petRows[0]!;
|
||||
deletedId = row.id as string;
|
||||
return [row];
|
||||
};
|
||||
@@ -164,10 +163,10 @@ vi.mock("../db", () => {
|
||||
}),
|
||||
pets,
|
||||
appointments,
|
||||
and,
|
||||
eq,
|
||||
exists,
|
||||
or,
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ vi.mock("../db", () => {
|
||||
{ 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(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
@@ -99,12 +104,12 @@ vi.mock("../db", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({ returning: () => [{}] }),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs: new Proxy(
|
||||
{ _name: "impersonationAuditLogs" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||
),
|
||||
impersonationAuditLogs,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
resolveBufferMinutes,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
@@ -114,132 +113,4 @@ describe("generateAvailableSlots", () => {
|
||||
expect(new Date(last!).getUTCHours()).toBe(16);
|
||||
expect(new Date(last!).getUTCMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
it("blocks a slot whose new buffer would overlap an existing booking", () => {
|
||||
// G1 has a booking at 10:00–11:00 with 30-min buffer (effective until 11:30)
|
||||
// A 60-min appointment starting at 10:30 with 30-min new buffer
|
||||
// would end at 11:30, which overlaps the existing booking's buffer
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [
|
||||
{ staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 },
|
||||
],
|
||||
newBufferMinutes: 30,
|
||||
});
|
||||
// 09:00 slot should be blocked because 09:00–10:00 + 30-min buffer = 10:30
|
||||
// and existing booking ends at 11:00 with 30-min buffer = 11:30
|
||||
// Actually: new appointment 09:00–10:00, buffer to 10:30. Existing 10:00–11:00 starts at 10:00
|
||||
// which is NOT > 10:30, so 09:00 slot is OK.
|
||||
// Let's use 10:00 start: new appt 10:00–11:00, buffer to 11:30. Existing 10:00–11:00
|
||||
// New appt overlaps existing.
|
||||
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("blocks a slot when the new appointment's buffer reaches into an existing booking", () => {
|
||||
// Existing booking 10:00–11:00 with 30-min buffer (effective until 11:30)
|
||||
// New appointment at 09:00–10:00 with 60-min buffer → effective end 10:30
|
||||
// Existing booking start 10:00 < 11:00 (newEndWithBuffer) → blocks 09:00
|
||||
// New appointment at 09:30–10:30 with 60-min buffer → effective end 11:00
|
||||
// 10:00 (existing start) < 11:00 (newEndWithBuffer) → blocks 09:30
|
||||
// Both 09:00 and 09:30 are blocked, leaving only 12:00+
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [
|
||||
{ staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 },
|
||||
],
|
||||
newBufferMinutes: 60,
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:30:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("backward compatibility: existing bookings with bufferMinutes=0 work same as before", () => {
|
||||
// A 60-min appointment at 09:00 with no buffer should block 09:00 and 10:00 slots
|
||||
// for that groomer (same as original behavior)
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [
|
||||
{ staffId: G1, startTime: utc(9), endTime: utc(10), bufferMinutes: 0 },
|
||||
],
|
||||
newBufferMinutes: 0,
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("existing booking's buffer extends its blocking window", () => {
|
||||
// G1 has a booking 10:00–11:00 with 30-min buffer (effective until 11:30)
|
||||
// A new 60-min appointment at 09:00 with newBufferMinutes=0
|
||||
// ends at 10:00, which is NOT > 10:00 (in overlap check), so 09:00 slot is available
|
||||
// A new 60-min appointment at 10:00 ends at 11:00, which overlaps (starts at 10:00)
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [
|
||||
{ staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 },
|
||||
],
|
||||
newBufferMinutes: 0,
|
||||
});
|
||||
// 10:00 slot should be blocked (10:00 overlaps 10:00 start)
|
||||
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
// 09:00 slot is available since appointment ends at 10:00, existing starts at 10:00
|
||||
expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("new appointment's own buffer is accounted for in business hours check", () => {
|
||||
// With newBufferMinutes=60, a 60-min appointment at 16:00 would end at 17:00
|
||||
// plus 60-min buffer = 18:00, which exceeds business hours (17:00)
|
||||
// so the 16:00 slot should not be generated
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
newBufferMinutes: 60,
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T16:00:00.000Z`).toISOString());
|
||||
// But 15:00 should be fine: 15:00–16:00 + 60-min buffer = 17:00, within business hours
|
||||
expect(slots).toContain(new Date(`${DATE}T15:00:00.000Z`).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBufferMinutes", () => {
|
||||
it("returns 10-min buffer for unknown/mixed size/coat (medium/normal default)", () => {
|
||||
expect(resolveBufferMinutes({})).toBe(10);
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "normal" })).toBe(10);
|
||||
});
|
||||
|
||||
it("small pet with long coat = 10 min", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "small", petCoatType: "long" })).toBe(10);
|
||||
});
|
||||
|
||||
it("small pet with normal coat = 5 min", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "small", petCoatType: "normal" })).toBe(5);
|
||||
});
|
||||
|
||||
it("medium pet with long coat = 20 min", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "long" })).toBe(20);
|
||||
});
|
||||
|
||||
it("medium pet with normal coat = 10 min", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "normal" })).toBe(10);
|
||||
});
|
||||
|
||||
it("large pet with long coat = 30 min", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "large", petCoatType: "long" })).toBe(30);
|
||||
});
|
||||
|
||||
it("large pet with normal coat = 15 min", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "large", petCoatType: "normal" })).toBe(15);
|
||||
});
|
||||
|
||||
it("case insensitive", () => {
|
||||
expect(resolveBufferMinutes({ petSizeCategory: "LARGE", petCoatType: "LONG" })).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,11 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
@@ -158,7 +158,6 @@ export const pets = pgTable(
|
||||
image: text("image"),
|
||||
// Extended profile fields
|
||||
coatType: text("coat_type"),
|
||||
petSizeCategory: text("pet_size_category"), // "small" | "medium" | "large"
|
||||
temperamentScore: integer("temperament_score"),
|
||||
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
||||
@@ -241,8 +240,6 @@ export const appointments = pgTable(
|
||||
startTime: timestamp("start_time").notNull(),
|
||||
endTime: timestamp("end_time").notNull(),
|
||||
notes: text("notes"),
|
||||
// Buffer time (minutes) after appointment end — guards groomer transition/prep
|
||||
bufferMinutes: integer("buffer_minutes").notNull().default(0),
|
||||
// Override price at time of booking (null = use service base price)
|
||||
priceCents: integer("price_cents"),
|
||||
// Recurring series support
|
||||
|
||||
@@ -10,49 +10,22 @@ export interface BookedSlot {
|
||||
staffId: string | null;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
bufferMinutes?: number; // minutes of buffer after endTime; defaults to 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all available appointment start times for a given date,
|
||||
* returning only slots where at least one groomer is free.
|
||||
*/
|
||||
/**
|
||||
* Resolve buffer minutes based on pet size category and coat type.
|
||||
* Used when booking a new appointment to determine post-groom buffer time.
|
||||
*/
|
||||
export function resolveBufferMinutes({
|
||||
petSizeCategory,
|
||||
petCoatType,
|
||||
}: {
|
||||
petSizeCategory?: string;
|
||||
petCoatType?: string;
|
||||
}): number {
|
||||
const size = petSizeCategory?.toLowerCase() ?? "medium";
|
||||
const coat = petCoatType?.toLowerCase() ?? "normal";
|
||||
|
||||
if (size === "small") {
|
||||
return coat === "long" ? 10 : 5;
|
||||
}
|
||||
if (size === "large") {
|
||||
return coat === "long" ? 30 : 15;
|
||||
}
|
||||
// medium
|
||||
return coat === "long" ? 20 : 10;
|
||||
}
|
||||
|
||||
export function generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes,
|
||||
groomerIds,
|
||||
booked,
|
||||
newBufferMinutes = 0,
|
||||
}: {
|
||||
dateStr: string;
|
||||
durationMinutes: number;
|
||||
groomerIds: string[];
|
||||
booked: BookedSlot[];
|
||||
newBufferMinutes?: number;
|
||||
}): string[] {
|
||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
@@ -60,20 +33,18 @@ export function generateAvailableSlots({
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
const durationMs = durationMinutes * 60_000;
|
||||
const newBufferMs = newBufferMinutes * 60_000;
|
||||
const slots: string[] = [];
|
||||
let slotStart = dayStart.getTime();
|
||||
|
||||
while (slotStart + durationMs + newBufferMs <= dayEnd.getTime()) {
|
||||
while (slotStart + durationMs <= dayEnd.getTime()) {
|
||||
const slotEnd = slotStart + durationMs;
|
||||
const newEndWithBuffer = slotEnd + newBufferMs;
|
||||
const hasGroomer = groomerIds.some(
|
||||
(groomerId) =>
|
||||
!booked.some(
|
||||
(a) =>
|
||||
a.staffId === groomerId &&
|
||||
a.startTime.getTime() < newEndWithBuffer &&
|
||||
a.endTime.getTime() + (a.bufferMinutes ?? 0) * 60_000 > slotStart
|
||||
a.startTime.getTime() < slotEnd &&
|
||||
a.endTime.getTime() > slotStart
|
||||
)
|
||||
);
|
||||
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
lte,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
@@ -19,9 +18,8 @@ import {
|
||||
reminderLogs,
|
||||
services,
|
||||
staff,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { resolveBufferMinutes } from "../lib/slots.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
@@ -58,9 +56,6 @@ const createAppointmentSchema = z.object({
|
||||
endTime: z.string().datetime(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
// Optional pet info to resolve buffer time
|
||||
petSizeCategory: z.enum(["small", "medium", "large"]).optional(),
|
||||
petCoatType: z.string().max(50).optional(),
|
||||
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
||||
recurrence: z
|
||||
.object({
|
||||
@@ -164,14 +159,7 @@ appointmentsRouter.post(
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
}
|
||||
|
||||
const { recurrence, petSizeCategory, petCoatType, ...apptFields } = body;
|
||||
|
||||
// Resolve buffer for the new appointment
|
||||
const bufferMinutes = resolveBufferMinutes({
|
||||
petSizeCategory,
|
||||
petCoatType,
|
||||
});
|
||||
const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000);
|
||||
const { recurrence, ...apptFields } = body;
|
||||
|
||||
// Wrap conflict check + insert in a transaction to prevent double-booking
|
||||
// race conditions under concurrent load (fixes #18).
|
||||
@@ -188,8 +176,8 @@ appointmentsRouter.post(
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, endWithBuffer),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
@@ -210,8 +198,8 @@ appointmentsRouter.post(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, endWithBuffer),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
@@ -226,7 +214,7 @@ appointmentsRouter.post(
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({ ...apptFields, startTime: start, endTime: end, bufferMinutes })
|
||||
.values({ ...apptFields, startTime: start, endTime: end })
|
||||
.returning();
|
||||
if (!inserted) throw new Error("Insert failed");
|
||||
return inserted;
|
||||
@@ -251,9 +239,6 @@ appointmentsRouter.post(
|
||||
const instanceEnd = new Date(
|
||||
instanceStart.getTime() + durationMs
|
||||
);
|
||||
const instanceEndWithBuffer = new Date(
|
||||
instanceEnd.getTime() + bufferMinutes * 60_000
|
||||
);
|
||||
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
@@ -262,8 +247,8 @@ appointmentsRouter.post(
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, instanceEndWithBuffer),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
@@ -284,8 +269,8 @@ appointmentsRouter.post(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, instanceEndWithBuffer),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
@@ -304,7 +289,6 @@ appointmentsRouter.post(
|
||||
endTime: instanceEnd,
|
||||
seriesId: series.id,
|
||||
seriesIndex: i,
|
||||
bufferMinutes,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||
@@ -485,16 +469,14 @@ appointmentsRouter.patch(
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.staffId !== undefined)
|
||||
) {
|
||||
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
|
||||
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, newStaffId),
|
||||
lt(appointments.startTime, conflictEnd),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
@@ -512,8 +494,6 @@ appointmentsRouter.patch(
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.batherStaffId !== undefined)
|
||||
) {
|
||||
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
|
||||
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
@@ -523,8 +503,8 @@ appointmentsRouter.patch(
|
||||
eq(appointments.staffId, newBatherStaffId),
|
||||
eq(appointments.batherStaffId, newBatherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, conflictEnd),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
@@ -639,17 +619,14 @@ appointmentsRouter.patch(
|
||||
}
|
||||
|
||||
if (staffId) {
|
||||
const currentBuffer =
|
||||
(current.bufferMinutes ?? 0) * 60_000;
|
||||
const conflictEnd = new Date(end.getTime() + currentBuffer);
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
lt(appointments.startTime, conflictEnd),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
@@ -662,9 +639,6 @@ appointmentsRouter.patch(
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const currentBuffer =
|
||||
(current.bufferMinutes ?? 0) * 60_000;
|
||||
const conflictEnd = new Date(end.getTime() + currentBuffer);
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
@@ -674,8 +648,8 @@ appointmentsRouter.patch(
|
||||
eq(appointments.staffId, batherStaffId),
|
||||
eq(appointments.batherStaffId, batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, conflictEnd),
|
||||
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
resolveBufferMinutes,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
@@ -44,8 +43,6 @@ bookRouter.get("/services", async (c) => {
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
const petSizeCategory = c.req.query("petSizeCategory");
|
||||
const petCoatType = c.req.query("petCoatType");
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
@@ -73,16 +70,12 @@ bookRouter.get("/availability", async (c) => {
|
||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
// Resolve buffer for the new appointment
|
||||
const newBufferMinutes = resolveBufferMinutes({ petSizeCategory, petCoatType });
|
||||
|
||||
// Fetch all active appointments for the day (any groomer) with their buffer
|
||||
// Fetch all active appointments for the day (any groomer)
|
||||
const booked = await db
|
||||
.select({
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
bufferMinutes: appointments.bufferMinutes,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
@@ -99,7 +92,6 @@ bookRouter.get("/availability", async (c) => {
|
||||
durationMinutes: service.durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
newBufferMinutes,
|
||||
});
|
||||
|
||||
return c.json(slots);
|
||||
@@ -121,8 +113,6 @@ const bookingSchema = z.object({
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
petSizeCategory: z.enum(["small", "medium", "large"]).optional(),
|
||||
petCoatType: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
bookRouter.post(
|
||||
@@ -139,12 +129,6 @@ bookRouter.post(
|
||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
// Resolve buffer for the new appointment
|
||||
const bufferMinutes = resolveBufferMinutes({
|
||||
petSizeCategory: body.petSizeCategory,
|
||||
petCoatType: body.petCoatType,
|
||||
});
|
||||
|
||||
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
|
||||
// Find all active groomers
|
||||
@@ -157,37 +141,21 @@ bookRouter.post(
|
||||
return c.json({ error: "No groomers available" }, 409);
|
||||
}
|
||||
|
||||
// Find conflicting appointments for this time window (including existing buffers)
|
||||
const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000);
|
||||
// Find conflicting appointments for this time window
|
||||
const booked = await db
|
||||
.select({
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
bufferMinutes: appointments.bufferMinutes,
|
||||
})
|
||||
.select({ staffId: appointments.staffId })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
lt(appointments.startTime, endWithBuffer),
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
// Build busy groomer map: staffId -> effective end (endTime + buffer)
|
||||
const busyGroomers = new Map<string, number>();
|
||||
for (const b of booked) {
|
||||
const effectiveEnd = b.endTime.getTime() + (b.bufferMinutes ?? 0) * 60_000;
|
||||
const existing = busyGroomers.get(b.staffId ?? "") ?? 0;
|
||||
if (effectiveEnd > existing) busyGroomers.set(b.staffId ?? "", effectiveEnd);
|
||||
}
|
||||
|
||||
const freeGroomer = groomers.find(({ id }) => {
|
||||
const busyUntil = busyGroomers.get(id) ?? 0;
|
||||
return busyUntil <= start.getTime();
|
||||
});
|
||||
const busyIds = new Set(booked.map((a) => a.staffId));
|
||||
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
|
||||
if (!freeGroomer) {
|
||||
return c.json(
|
||||
{ error: "No groomers available at this time. Please choose another slot." },
|
||||
@@ -238,7 +206,7 @@ bookRouter.post(
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, freeGroomer.id),
|
||||
lt(appointments.startTime, endWithBuffer),
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
@@ -260,7 +228,6 @@ bookRouter.post(
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
notes: body.notes ?? null,
|
||||
bufferMinutes,
|
||||
})
|
||||
.returning();
|
||||
return apptInserted[0];
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
Reference in New Issue
Block a user