Compare commits
5 Commits
dev
...
d9e7c36a09
| Author | SHA1 | Date | |
|---|---|---|---|
| d9e7c36a09 | |||
| 213a29c1bd | |||
| 7233e5ab16 | |||
| 434c7b94e2 | |||
| 70af9da338 |
@@ -67,6 +67,20 @@ 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.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 |
|
| 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
|
### 4.5 Services
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "./src/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./migrations",
|
out: "./migrations",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration: 0030_extended_pet_profile
|
||||||
|
-- Adds extended profile fields to the pets table
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE pets ADD COLUMN coat_type text;
|
||||||
|
ALTER TABLE pets ADD COLUMN temperament_score integer;
|
||||||
|
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"id": "0030_extended_pet_profile",
|
||||||
|
"prevId": "0028_sms_reminders",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.pets": {
|
||||||
|
"name": "pets",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||||
|
"client_id": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||||
|
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||||
|
"species": { "name": "species", "type": "text", "isNullable": false },
|
||||||
|
"breed": { "name": "breed", "type": "text", "isNullable": true },
|
||||||
|
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true },
|
||||||
|
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true },
|
||||||
|
"health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true },
|
||||||
|
"grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true },
|
||||||
|
"cut_style": { "name": "cut_style", "type": "text", "isNullable": true },
|
||||||
|
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true },
|
||||||
|
"special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true },
|
||||||
|
"custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" },
|
||||||
|
"photo_key": { "name": "photo_key", "type": "text", "isNullable": true },
|
||||||
|
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true },
|
||||||
|
"image": { "name": "image", "type": "text", "isNullable": true },
|
||||||
|
"coat_type": { "name": "coat_type", "type": "text", "isNullable": true },
|
||||||
|
"temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true },
|
||||||
|
"temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||||
|
"medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||||
|
"preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||||
|
"created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||||
|
"updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||||
|
},
|
||||||
|
"indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } },
|
||||||
|
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade" } },
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,27 @@
|
|||||||
"when": 1775741667192,
|
"when": 1775741667192,
|
||||||
"tag": "0028_sms_reminders",
|
"tag": "0028_sms_reminders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775828067192,
|
||||||
|
"tag": "0029_db_indexes_constraints",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775914467192,
|
||||||
|
"tag": "0030_extended_pet_profile",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776000867192,
|
||||||
|
"tag": "0031_buffer_and_pet_size",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
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";
|
||||||
|
import { petsRouter } from "../routes/pets.js";
|
||||||
|
|
||||||
|
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MANAGER: StaffRow = {
|
||||||
|
id: "staff-manager-id",
|
||||||
|
oidcSub: "oidc-manager-sub",
|
||||||
|
userId: null,
|
||||||
|
role: "manager",
|
||||||
|
isSuperUser: true,
|
||||||
|
name: "Manager McManager",
|
||||||
|
email: "manager@example.com",
|
||||||
|
active: true,
|
||||||
|
icalToken: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CLIENT_ID = "12345678-1234-1234-1234-123456789abc";
|
||||||
|
const PET_ID = "pet-uuid-extended";
|
||||||
|
|
||||||
|
let petRows: Record<string, unknown>[] = [];
|
||||||
|
let appointmentRows: Record<string, unknown>[] = [];
|
||||||
|
let insertedValues: Record<string, unknown>[] = [];
|
||||||
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
|
let deletedId: string | null = null;
|
||||||
|
|
||||||
|
function resetMock() {
|
||||||
|
petRows = [{
|
||||||
|
id: PET_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
name: "Biscuit",
|
||||||
|
species: "dog",
|
||||||
|
breed: "Golden Retriever",
|
||||||
|
weightKg: "30.00",
|
||||||
|
dateOfBirth: null,
|
||||||
|
healthAlerts: null,
|
||||||
|
groomingNotes: null,
|
||||||
|
cutStyle: null,
|
||||||
|
shampooPreference: null,
|
||||||
|
specialCareNotes: null,
|
||||||
|
customFields: {},
|
||||||
|
photoKey: null,
|
||||||
|
photoUploadedAt: null,
|
||||||
|
image: null,
|
||||||
|
coatType: null,
|
||||||
|
temperamentScore: null,
|
||||||
|
temperamentFlags: [],
|
||||||
|
medicalAlerts: [],
|
||||||
|
preferredCuts: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}];
|
||||||
|
appointmentRows = [];
|
||||||
|
insertedValues = [];
|
||||||
|
updatedValues = [];
|
||||||
|
deletedId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSelectChainable(rows: unknown[]): unknown {
|
||||||
|
const chain = new Proxy([...rows], {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||||
|
return () => chain;
|
||||||
|
}
|
||||||
|
// @ts-expect-error proxy
|
||||||
|
return target[prop];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInsertChainable(): unknown {
|
||||||
|
let vals: Record<string, unknown> = {};
|
||||||
|
const chain = new Proxy({}, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === "values") {
|
||||||
|
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
||||||
|
}
|
||||||
|
if (prop === "returning") {
|
||||||
|
return () => {
|
||||||
|
insertedValues.push(vals);
|
||||||
|
return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUpdateChainable(): unknown {
|
||||||
|
let vals: Record<string, unknown> = {};
|
||||||
|
let whereId: string | null = null;
|
||||||
|
const chain = new Proxy({}, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === "set") {
|
||||||
|
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
||||||
|
}
|
||||||
|
if (prop === "where") {
|
||||||
|
return (cond: unknown) => {
|
||||||
|
// Extract id from condition if it's an eq call
|
||||||
|
if (whereId) vals = { ...vals };
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === "returning") {
|
||||||
|
return () => {
|
||||||
|
const merged = { ...petRows[0], ...vals };
|
||||||
|
updatedValues.push(vals);
|
||||||
|
return [merged];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDeleteChainable(): unknown {
|
||||||
|
let whereId: string | null = null;
|
||||||
|
const chain = new Proxy({}, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === "where") {
|
||||||
|
return (cond: unknown) => {
|
||||||
|
whereId = PET_ID;
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === "returning") {
|
||||||
|
return () => {
|
||||||
|
const row = petRows[0];
|
||||||
|
deletedId = row.id as string;
|
||||||
|
return [row];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../db", () => {
|
||||||
|
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
||||||
|
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
||||||
|
return {
|
||||||
|
getDb: () => ({
|
||||||
|
select: () => ({
|
||||||
|
from: (table: unknown) => {
|
||||||
|
const name = (table as { _name?: string })._name;
|
||||||
|
if (name === "appointments") return makeSelectChainable(appointmentRows);
|
||||||
|
return makeSelectChainable(petRows);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
insert: () => makeInsertChainable(),
|
||||||
|
update: () => makeUpdateChainable(),
|
||||||
|
delete: () => makeDeleteChainable(),
|
||||||
|
}),
|
||||||
|
pets,
|
||||||
|
appointments,
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
exists,
|
||||||
|
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: "smooth" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.coatType).toBe("smooth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates temperamentScore", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await app.request(`/pets/${PET_ID}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ temperamentScore: 2 }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.temperamentScore).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects temperamentScore 0 on update", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await app.request(`/pets/${PET_ID}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ temperamentScore: 0 }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid severity on update", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await app.request(`/pets/${PET_ID}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects too many temperamentFlags (>20)", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`);
|
||||||
|
const res = await app.request("/pets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects too many preferredCuts (>20)", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`);
|
||||||
|
const res = await app.request("/pets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects too many medicalAlerts (>50)", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const alerts = Array.from({ length: 51 }, (_, i) => ({
|
||||||
|
type: `type_${i}`,
|
||||||
|
description: `desc_${i}`,
|
||||||
|
severity: "low" as const,
|
||||||
|
}));
|
||||||
|
const res = await app.request("/pets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns extended fields in GET response", async () => {
|
||||||
|
petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }];
|
||||||
|
const app = createApp();
|
||||||
|
const res = await app.request(`/pets/${PET_ID}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.coatType).toBe("wire");
|
||||||
|
expect(body.temperamentScore).toBe(3);
|
||||||
|
expect(body.temperamentFlags).toEqual(["gentle"]);
|
||||||
|
expect(body.preferredCuts).toEqual(["scissor cut"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,6 +101,10 @@ vi.mock("../db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
|
impersonationAuditLogs: new Proxy(
|
||||||
|
{ _name: "impersonationAuditLogs" },
|
||||||
|
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||||
|
),
|
||||||
appointments,
|
appointments,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
generateAvailableSlots,
|
generateAvailableSlots,
|
||||||
|
resolveBufferMinutes,
|
||||||
BUSINESS_START_HOUR,
|
BUSINESS_START_HOUR,
|
||||||
BUSINESS_END_HOUR,
|
BUSINESS_END_HOUR,
|
||||||
} from "../lib/slots.js";
|
} from "../lib/slots.js";
|
||||||
@@ -113,4 +114,132 @@ describe("generateAvailableSlots", () => {
|
|||||||
expect(new Date(last!).getUTCHours()).toBe(16);
|
expect(new Date(last!).getUTCHours()).toBe(16);
|
||||||
expect(new Date(last!).getUTCMinutes()).toBe(30);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ─── Shared types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface MedicalAlert {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
severity: MedicalAlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
||||||
@@ -146,6 +156,13 @@ export const pets = pgTable(
|
|||||||
photoKey: text("photo_key"),
|
photoKey: text("photo_key"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
image: text("image"),
|
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([]),
|
||||||
|
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
@@ -224,6 +241,8 @@ export const appointments = pgTable(
|
|||||||
startTime: timestamp("start_time").notNull(),
|
startTime: timestamp("start_time").notNull(),
|
||||||
endTime: timestamp("end_time").notNull(),
|
endTime: timestamp("end_time").notNull(),
|
||||||
notes: text("notes"),
|
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)
|
// Override price at time of booking (null = use service base price)
|
||||||
priceCents: integer("price_cents"),
|
priceCents: integer("price_cents"),
|
||||||
// Recurring series support
|
// Recurring series support
|
||||||
|
|||||||
@@ -10,22 +10,49 @@ export interface BookedSlot {
|
|||||||
staffId: string | null;
|
staffId: string | null;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime: Date;
|
endTime: Date;
|
||||||
|
bufferMinutes?: number; // minutes of buffer after endTime; defaults to 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate all available appointment start times for a given date,
|
* Generate all available appointment start times for a given date,
|
||||||
* returning only slots where at least one groomer is free.
|
* 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({
|
export function generateAvailableSlots({
|
||||||
dateStr,
|
dateStr,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
groomerIds,
|
groomerIds,
|
||||||
booked,
|
booked,
|
||||||
|
newBufferMinutes = 0,
|
||||||
}: {
|
}: {
|
||||||
dateStr: string;
|
dateStr: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
groomerIds: string[];
|
groomerIds: string[];
|
||||||
booked: BookedSlot[];
|
booked: BookedSlot[];
|
||||||
|
newBufferMinutes?: number;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||||
@@ -33,18 +60,20 @@ export function generateAvailableSlots({
|
|||||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||||
|
|
||||||
const durationMs = durationMinutes * 60_000;
|
const durationMs = durationMinutes * 60_000;
|
||||||
|
const newBufferMs = newBufferMinutes * 60_000;
|
||||||
const slots: string[] = [];
|
const slots: string[] = [];
|
||||||
let slotStart = dayStart.getTime();
|
let slotStart = dayStart.getTime();
|
||||||
|
|
||||||
while (slotStart + durationMs <= dayEnd.getTime()) {
|
while (slotStart + durationMs + newBufferMs <= dayEnd.getTime()) {
|
||||||
const slotEnd = slotStart + durationMs;
|
const slotEnd = slotStart + durationMs;
|
||||||
|
const newEndWithBuffer = slotEnd + newBufferMs;
|
||||||
const hasGroomer = groomerIds.some(
|
const hasGroomer = groomerIds.some(
|
||||||
(groomerId) =>
|
(groomerId) =>
|
||||||
!booked.some(
|
!booked.some(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.staffId === groomerId &&
|
a.staffId === groomerId &&
|
||||||
a.startTime.getTime() < slotEnd &&
|
a.startTime.getTime() < newEndWithBuffer &&
|
||||||
a.endTime.getTime() > slotStart
|
a.endTime.getTime() + (a.bufferMinutes ?? 0) * 60_000 > slotStart
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
lte,
|
lte,
|
||||||
ne,
|
ne,
|
||||||
or,
|
or,
|
||||||
|
sql,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
@@ -18,8 +19,9 @@ import {
|
|||||||
reminderLogs,
|
reminderLogs,
|
||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "../db/index.js";
|
} from "../db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
|
import { resolveBufferMinutes } from "../lib/slots.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ const createAppointmentSchema = z.object({
|
|||||||
endTime: z.string().datetime(),
|
endTime: z.string().datetime(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
priceCents: z.number().int().positive().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
|
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
||||||
recurrence: z
|
recurrence: z
|
||||||
.object({
|
.object({
|
||||||
@@ -159,7 +164,14 @@ appointmentsRouter.post(
|
|||||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { recurrence, ...apptFields } = body;
|
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);
|
||||||
|
|
||||||
// Wrap conflict check + insert in a transaction to prevent double-booking
|
// Wrap conflict check + insert in a transaction to prevent double-booking
|
||||||
// race conditions under concurrent load (fixes #18).
|
// race conditions under concurrent load (fixes #18).
|
||||||
@@ -176,8 +188,8 @@ appointmentsRouter.post(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, apptFields.staffId),
|
eq(appointments.staffId, apptFields.staffId),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gte(appointments.endTime, start),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
)
|
)
|
||||||
@@ -198,8 +210,8 @@ appointmentsRouter.post(
|
|||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gte(appointments.endTime, start),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
)
|
)
|
||||||
@@ -214,7 +226,7 @@ appointmentsRouter.post(
|
|||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
.insert(appointments)
|
.insert(appointments)
|
||||||
.values({ ...apptFields, startTime: start, endTime: end })
|
.values({ ...apptFields, startTime: start, endTime: end, bufferMinutes })
|
||||||
.returning();
|
.returning();
|
||||||
if (!inserted) throw new Error("Insert failed");
|
if (!inserted) throw new Error("Insert failed");
|
||||||
return inserted;
|
return inserted;
|
||||||
@@ -239,6 +251,9 @@ appointmentsRouter.post(
|
|||||||
const instanceEnd = new Date(
|
const instanceEnd = new Date(
|
||||||
instanceStart.getTime() + durationMs
|
instanceStart.getTime() + durationMs
|
||||||
);
|
);
|
||||||
|
const instanceEndWithBuffer = new Date(
|
||||||
|
instanceEnd.getTime() + bufferMinutes * 60_000
|
||||||
|
);
|
||||||
|
|
||||||
if (apptFields.staffId) {
|
if (apptFields.staffId) {
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
@@ -247,8 +262,8 @@ appointmentsRouter.post(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, apptFields.staffId),
|
eq(appointments.staffId, apptFields.staffId),
|
||||||
lt(appointments.startTime, instanceEnd),
|
lt(appointments.startTime, instanceEndWithBuffer),
|
||||||
gte(appointments.endTime, instanceStart),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
)
|
)
|
||||||
@@ -269,8 +284,8 @@ appointmentsRouter.post(
|
|||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, instanceEnd),
|
lt(appointments.startTime, instanceEndWithBuffer),
|
||||||
gte(appointments.endTime, instanceStart),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
)
|
)
|
||||||
@@ -289,6 +304,7 @@ appointmentsRouter.post(
|
|||||||
endTime: instanceEnd,
|
endTime: instanceEnd,
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
seriesIndex: i,
|
seriesIndex: i,
|
||||||
|
bufferMinutes,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||||
@@ -469,14 +485,16 @@ appointmentsRouter.patch(
|
|||||||
endDeltaMs !== 0 ||
|
endDeltaMs !== 0 ||
|
||||||
updateFields.staffId !== undefined)
|
updateFields.staffId !== undefined)
|
||||||
) {
|
) {
|
||||||
|
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, newStaffId),
|
eq(appointments.staffId, newStaffId),
|
||||||
lt(appointments.startTime, newEnd),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, newStart),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
ne(appointments.id, appt.id),
|
ne(appointments.id, appt.id),
|
||||||
@@ -494,6 +512,8 @@ appointmentsRouter.patch(
|
|||||||
endDeltaMs !== 0 ||
|
endDeltaMs !== 0 ||
|
||||||
updateFields.batherStaffId !== undefined)
|
updateFields.batherStaffId !== undefined)
|
||||||
) {
|
) {
|
||||||
|
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -503,8 +523,8 @@ appointmentsRouter.patch(
|
|||||||
eq(appointments.staffId, newBatherStaffId),
|
eq(appointments.staffId, newBatherStaffId),
|
||||||
eq(appointments.batherStaffId, newBatherStaffId)
|
eq(appointments.batherStaffId, newBatherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, newEnd),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, newStart),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
ne(appointments.id, appt.id),
|
ne(appointments.id, appt.id),
|
||||||
@@ -619,14 +639,17 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (staffId) {
|
if (staffId) {
|
||||||
|
const currentBuffer =
|
||||||
|
(current.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(end.getTime() + currentBuffer);
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, staffId),
|
eq(appointments.staffId, staffId),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, start),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
ne(appointments.id, id),
|
ne(appointments.id, id),
|
||||||
@@ -639,6 +662,9 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (batherStaffId) {
|
if (batherStaffId) {
|
||||||
|
const currentBuffer =
|
||||||
|
(current.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(end.getTime() + currentBuffer);
|
||||||
const bathConflicts = await tx
|
const bathConflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -648,8 +674,8 @@ appointmentsRouter.patch(
|
|||||||
eq(appointments.staffId, batherStaffId),
|
eq(appointments.staffId, batherStaffId),
|
||||||
eq(appointments.batherStaffId, batherStaffId)
|
eq(appointments.batherStaffId, batherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, start),
|
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
ne(appointments.id, id),
|
ne(appointments.id, id),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "../db/index.js";
|
} from "../db/index.js";
|
||||||
import {
|
import {
|
||||||
generateAvailableSlots,
|
generateAvailableSlots,
|
||||||
|
resolveBufferMinutes,
|
||||||
BUSINESS_START_HOUR,
|
BUSINESS_START_HOUR,
|
||||||
BUSINESS_END_HOUR,
|
BUSINESS_END_HOUR,
|
||||||
} from "../lib/slots.js";
|
} from "../lib/slots.js";
|
||||||
@@ -43,6 +44,8 @@ bookRouter.get("/services", async (c) => {
|
|||||||
bookRouter.get("/availability", async (c) => {
|
bookRouter.get("/availability", async (c) => {
|
||||||
const serviceId = c.req.query("serviceId");
|
const serviceId = c.req.query("serviceId");
|
||||||
const dateStr = c.req.query("date");
|
const dateStr = c.req.query("date");
|
||||||
|
const petSizeCategory = c.req.query("petSizeCategory");
|
||||||
|
const petCoatType = c.req.query("petCoatType");
|
||||||
|
|
||||||
if (!serviceId || !dateStr) {
|
if (!serviceId || !dateStr) {
|
||||||
return c.json({ error: "serviceId and date are required" }, 400);
|
return c.json({ error: "serviceId and date are required" }, 400);
|
||||||
@@ -70,12 +73,16 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||||
|
|
||||||
// Fetch all active appointments for the day (any groomer)
|
// Resolve buffer for the new appointment
|
||||||
|
const newBufferMinutes = resolveBufferMinutes({ petSizeCategory, petCoatType });
|
||||||
|
|
||||||
|
// Fetch all active appointments for the day (any groomer) with their buffer
|
||||||
const booked = await db
|
const booked = await db
|
||||||
.select({
|
.select({
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
endTime: appointments.endTime,
|
endTime: appointments.endTime,
|
||||||
|
bufferMinutes: appointments.bufferMinutes,
|
||||||
})
|
})
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
@@ -92,6 +99,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
durationMinutes: service.durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
groomerIds: groomers.map((g) => g.id),
|
groomerIds: groomers.map((g) => g.id),
|
||||||
booked,
|
booked,
|
||||||
|
newBufferMinutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(slots);
|
return c.json(slots);
|
||||||
@@ -113,6 +121,8 @@ const bookingSchema = z.object({
|
|||||||
petSpecies: z.string().min(1).max(100),
|
petSpecies: z.string().min(1).max(100),
|
||||||
petBreed: z.string().max(100).optional(),
|
petBreed: z.string().max(100).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
|
petSizeCategory: z.enum(["small", "medium", "large"]).optional(),
|
||||||
|
petCoatType: z.string().max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
bookRouter.post(
|
bookRouter.post(
|
||||||
@@ -129,6 +139,12 @@ bookRouter.post(
|
|||||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
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);
|
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||||
|
|
||||||
// Find all active groomers
|
// Find all active groomers
|
||||||
@@ -141,21 +157,37 @@ bookRouter.post(
|
|||||||
return c.json({ error: "No groomers available" }, 409);
|
return c.json({ error: "No groomers available" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find conflicting appointments for this time window
|
// Find conflicting appointments for this time window (including existing buffers)
|
||||||
|
const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000);
|
||||||
const booked = await db
|
const booked = await db
|
||||||
.select({ staffId: appointments.staffId })
|
.select({
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
endTime: appointments.endTime,
|
||||||
|
bufferMinutes: appointments.bufferMinutes,
|
||||||
|
})
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gt(appointments.endTime, start),
|
gt(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const busyIds = new Set(booked.map((a) => a.staffId));
|
// Build busy groomer map: staffId -> effective end (endTime + buffer)
|
||||||
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
|
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();
|
||||||
|
});
|
||||||
if (!freeGroomer) {
|
if (!freeGroomer) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "No groomers available at this time. Please choose another slot." },
|
{ error: "No groomers available at this time. Please choose another slot." },
|
||||||
@@ -206,7 +238,7 @@ bookRouter.post(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, freeGroomer.id),
|
eq(appointments.staffId, freeGroomer.id),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gt(appointments.endTime, start),
|
gt(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -228,6 +260,7 @@ bookRouter.post(
|
|||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
notes: body.notes ?? null,
|
notes: body.notes ?? null,
|
||||||
|
bufferMinutes,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return apptInserted[0];
|
return apptInserted[0];
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ 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(),
|
||||||
|
coatType: z.string().max(100).optional(),
|
||||||
|
temperamentScore: z.number().int().min(1).max(5).optional(),
|
||||||
|
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
||||||
|
medicalAlerts: z.array(z.object({
|
||||||
|
type: z.string().max(100),
|
||||||
|
description: z.string().max(1000),
|
||||||
|
severity: z.enum(["low", "medium", "high"]),
|
||||||
|
})).max(50).optional(),
|
||||||
|
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||||
|
|||||||
@@ -42,10 +42,23 @@ export interface Pet {
|
|||||||
customFields: Record<string, string>;
|
customFields: Record<string, string>;
|
||||||
photoKey?: string;
|
photoKey?: string;
|
||||||
photoUploadedAt?: string;
|
photoUploadedAt?: string;
|
||||||
|
coatType?: string | null;
|
||||||
|
temperamentScore?: number | null;
|
||||||
|
temperamentFlags?: string[];
|
||||||
|
medicalAlerts?: MedicalAlert[];
|
||||||
|
preferredCuts?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface MedicalAlert {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
severity: MedicalAlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroomingVisitLog {
|
export interface GroomingVisitLog {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user