Compare commits

...

13 Commits

Author SHA1 Message Date
Chris Farhood d9e7c36a09 fix(GRO-1214): align slot generation with buffer semantics and correct test mocks
- slots.ts: make bufferMinutes optional on BookedSlot (defaults to 0 via ??)
  to handle test fixtures and legacy data that omit this field
- slots.test.ts: fix "blocks a slot when buffer reaches into booking" assertion
  — new algorithm correctly blocks 09:00 slot when existing booking has
  30-min buffer and new appointment uses 60-min buffer
- petsExtendedFields.test.ts: add missing top-level imports for and/eq/exists/or
  from drizzle-orm so vi.mock factory closure resolves correctly
- portal.test.ts: add missing impersonationAuditLogs mock export so
  portalAudit middleware writes succeed without "no export defined" errors

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 13:54:48 +00:00
Chris Farhood 213a29c1bd Merge dev into flea-flicker/gro-1162-pet-buffer-time
Resolve conflicts in appointments.ts (import style) and stage all fixes:
- UAT_PLAYBOOK.md: add §4.4b buffer-aware availability test cases
- Migration 0031: add buffer_minutes and pet_size_category columns
- Fix DB conflict queries to use effective end (endTime + bufferMinutes)
- Remove dead code in book.ts
- Fix misleading test name in slots.test.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 12:29:51 +00:00
the-dogfather-cto[bot] db10320c8f fix(auth): override Better Auth sign-in rate limit defaults (#11)
fix(auth): override Better Auth sign-in rate limit defaults
2026-05-14 10:52:31 +00:00
Chris Farhood 40a4023c65 feat(GRO-1202): add sign-in/sign-up rate limit overrides
Port rate limit customRules from groombook/app PR #392 to groombook/api.
Adds per-route limits for /sign-in/social, /sign-in/email, and /sign-up/email
to both AUTH_DISABLED and production better-auth() instances.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 10:34:32 +00:00
Chris Farhood 7233e5ab16 feat(api): scheduling engine buffer time integration
- Add bufferMinutes column to appointments table (default 0)
- Add petSizeCategory to pets table for buffer resolution
- Extend BookedSlot interface with bufferMinutes
- Update generateAvailableSlots() to account for existing buffers
  and new appointment's buffer when checking availability
- Add resolveBufferMinutes() helper based on pet size/coat
- Update GET /availability to accept petSizeCategory/petCoatType params
  and pass newBufferMinutes to slot generation
- Update POST /appointments to resolve and store bufferMinutes
  and check existing appointment buffers in conflict detection
- Update admin appointments.ts: resolve buffer on create, account
  for existing buffers in all conflict checks (create/update/cascade)
- Add buffer time test cases to slots.test.ts covering:
  - new appointment buffer blocks overlapping slots
  - existing booking buffer extends blocking window
  - business hours check includes new appointment buffer
  - backward compatibility (bufferMinutes=0)
  - resolveBufferMinutes() for all size/coat combinations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 09:44:18 +00:00
groombook-engineer[bot] d598511b75 fix: resolve pre-existing TypeScript errors for CI compliance (#9)
Merge PR #9: fix pre-existing TypeScript errors for CI compliance

All Lint & Typecheck and Test checks pass. Ready to merge.

cc @cpfarhood
2026-05-14 07:50:28 +00:00
Chris Farhood 434c7b94e2 fix: export named DB utilities in petsExtendedFields test mock
pets.ts imports pets, appointments, and, eq, exists, or directly from
"../db". The vi.mock factory only returned getDb, causing vitest to throw
"No 'pets' export is defined" and 7 tests to get 400 instead of 201/200.
Fix adds the missing named exports to the mock return object.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 07:24:52 +00:00
Chris Farhood 70af9da338 feat(api): add extended pet profile fields — schema, migration, CRUD, Zod validation
Adds five new nullable columns to the pets table:
- coat_type (text)
- temperament_score (integer, range 1–5)
- temperament_flags (jsonb, string[])
- medical_alerts (jsonb, typed MedicalAlert[])
- preferred_cuts (jsonb, string[])

Also:
- Exports MedicalAlert interface and MedicalAlertSeverity type from schema
- Updates shared Pet type in packages/types
- Adds Zod validators for all fields (ranges, max lengths, enum)
- Adds 14 tests covering happy path and validation edge cases
- Fixes drizzle.config.ts schema path (was ./src/schema.ts, correct is ./src/db/schema.ts)

Refs: GRO-1176

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 04:35:51 +00:00
the-dogfather-cto[bot] e714200b71 Merge pull request #7 from groombook/fix/uat-tester-oidc-sub
fix(api): add UAT Tester staff creation in seed script
2026-05-12 21:57:44 +00:00
Chris Farhood 1e70e01046 fix(api): add UAT Tester staff creation in seed script
Adds dedicated SEED_UAT_TESTER_OIDC_SUB handling to create the
uat-tester staff record with proper oidcSub mapping to Authentik user PK 237.

Fixes GRO-1151
2026-05-12 21:44:42 +00:00
the-dogfather-cto[bot] 83d7fecdd3 fix: correct test mock paths from "./db" to "../db" (#5)
fix: correct test mock paths from "./db" to "../db"
2026-05-12 21:33:02 +00:00
Chris Farhood 2448887924 fix: regenerate pnpm-lock.yaml to sync with package.json
- Adds missing drizzle-kit, drizzle-orm, postgres dependencies
- Addresses CI failures from Lint & Typecheck and Test jobs
- Resolves QA feedback from Lint Roller on PR #5
2026-05-12 21:13:55 +00:00
Chris Farhood f4995d987d fix: correct test mock paths from "./db" to "../db"
Fixes incorrect vi.mock paths that were causing tests to fail.
The mock path should match the import path in the route files.

This addresses the authProvider test mock path issue on PR #2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-12 19:54:29 +00:00
54 changed files with 1414 additions and 114 deletions
+14
View File
@@ -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.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:0010: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:0010: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 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema.ts",
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
@@ -0,0 +1,12 @@
-- Migration: 0030_extended_pet_profile
-- Adds extended profile fields to the pets table
BEGIN;
ALTER TABLE pets ADD COLUMN coat_type text;
ALTER TABLE pets ADD COLUMN temperament_score integer;
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
COMMIT;
@@ -0,0 +1,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": {} }
}
+512
View File
@@ -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": {}
}
}
+21
View File
@@ -204,6 +204,27 @@
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1775828067192,
"tag": "0029_db_indexes_constraints",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1775914467192,
"tag": "0030_extended_pet_profile",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1776000867192,
"tag": "0031_buffer_and_pet_size",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ let dbSelectResult: unknown[] = [];
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
vi.mock("./db", () => {
vi.mock("../db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
+1 -1
View File
@@ -38,7 +38,7 @@ const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: fa
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("./db", () => {
vi.mock("../db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
+1 -1
View File
@@ -40,7 +40,7 @@ function resetMock() {
deletedId = null;
}
vi.mock("./db", () => {
vi.mock("../db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
+1 -1
View File
@@ -39,7 +39,7 @@ function resetMock() {
lastUpdate = {};
}
vi.mock("./db", () => {
vi.mock("../db", () => {
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
+1 -1
View File
@@ -76,7 +76,7 @@ function makeChainableResult(data: unknown[]): unknown {
});
}
vi.mock("./db", () => {
vi.mock("../db", () => {
function makeTable(name: string) {
return new Proxy(
{ _name: name },
+1 -1
View File
@@ -40,7 +40,7 @@ function resetDb() {
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("./db", () => {
vi.mock("../db", () => {
const pets = new Proxy(
{ _name: "pets" },
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
@@ -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 15", 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"]);
});
});
+5 -1
View File
@@ -47,7 +47,7 @@ function resetMock() {
updatedValues = [];
}
vi.mock("./db", () => {
vi.mock("../db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
@@ -101,6 +101,10 @@ vi.mock("./db", () => {
}),
}),
impersonationSessions,
impersonationAuditLogs: new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
),
appointments,
eq: vi.fn(),
and: vi.fn(),
+1 -1
View File
@@ -46,7 +46,7 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
vi.mock("./db", () => {
vi.mock("../db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
+1 -1
View File
@@ -23,7 +23,7 @@ const PET_ROW = {
let clientResults: typeof ACTIVE_CLIENT[] = [];
let petResults: typeof PET_ROW[] = [];
vi.mock("./db", () => {
vi.mock("../db", () => {
// Proxy objects for table/column references — values don't matter for tests
const tableProxy = (name: string) =>
new Proxy(
+1 -1
View File
@@ -39,7 +39,7 @@ function clearAuthEnv() {
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("./db", () => {
vi.mock("../db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
+129
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
generateAvailableSlots,
resolveBufferMinutes,
BUSINESS_START_HOUR,
BUSINESS_END_HOUR,
} from "../lib/slots.js";
@@ -113,4 +114,132 @@ 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:0011: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:0010:00 + 30-min buffer = 10:30
// and existing booking ends at 11:00 with 30-min buffer = 11:30
// Actually: new appointment 09:0010:00, buffer to 10:30. Existing 10:0011: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:0011:00, buffer to 11:30. Existing 10:0011: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:0011:00 with 30-min buffer (effective until 11:30)
// New appointment at 09:0010:00 with 60-min buffer → effective end 10:30
// Existing booking start 10:00 < 11:00 (newEndWithBuffer) → blocks 09:00
// New appointment at 09:3010: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:0011: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:0016: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);
});
});
+1 -1
View File
@@ -49,7 +49,7 @@ function resetMock() {
updatedValues = [];
}
vi.mock("./db", () => {
vi.mock("../db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
+19
View File
@@ -12,6 +12,16 @@ import {
uuid,
} from "drizzle-orm/pg-core";
// ─── Shared types ───────────────────────────────────────────────────────────────
export type MedicalAlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
type: string;
description: string;
severity: MedicalAlertSeverity;
}
// ─── Enums ────────────────────────────────────────────────────────────────────
export const appointmentStatusEnum = pgEnum("appointment_status", [
@@ -146,6 +156,13 @@ export const pets = pgTable(
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
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(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
@@ -224,6 +241,8 @@ 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
+27 -6
View File
@@ -94,11 +94,6 @@ function pick<T>(arr: T[]): T {
return arr[Math.floor(rand() * arr.length)]!;
}
/** Return n distinct random elements from an array. */
function pickN<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => rand() - 0.5);
return shuffled.slice(0, n);
}
function randInt(min: number, max: number): number {
return Math.floor(rand() * (max - min + 1)) + min;
@@ -459,6 +454,32 @@ async function seedKnownUsers() {
}
}
// ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ──
const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB;
if (uatTesterOidcSub) {
const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007";
const [existingUatTester] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "uat-tester@groombook.dev"))
.limit(1);
if (existingUatTester) {
console.log(`✓ Staff 'UAT Tester' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: UAT_TESTER_STAFF_ID,
name: "UAT Tester",
email: "uat-tester@groombook.dev",
oidcSub: uatTesterOidcSub,
role: "groomer",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`);
}
}
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
@@ -1079,7 +1100,7 @@ async function seed() {
const groomer = pick(groomers);
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
let startTime = randDate(appointmentsBackDate, now);
const startTime = randDate(appointmentsBackDate, now);
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
const effectivePrice = svc.price;
+1 -1
View File
@@ -22,7 +22,7 @@ import { searchRouter } from "./routes/search.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "./db";
import { getDb, businessSettings, eq, staff } from "./db/index.js";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
+8 -2
View File
@@ -1,8 +1,8 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "./db";
import { decryptSecret } from "./db";
import { getDb, authProviderConfig, eq } from "../db/index.js";
import { decryptSecret } from "../db/index.js";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
window: 10,
storage: "memory",
customRules: {
"/sign-in/social": { max: 10, window: 60 },
"/sign-in/email": { max: 10, window: 60 },
"/sign-up/email": { max: 5, window: 60 },
"/get-session": false,
},
},
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
window: 10,
storage: "memory",
customRules: {
"/sign-in/social": { max: 10, window: 60 },
"/sign-in/email": { max: 10, window: 60 },
"/sign-up/email": { max: 5, window: 60 },
"/get-session": false,
},
},
+32 -3
View File
@@ -10,22 +10,49 @@ 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);
@@ -33,18 +60,20 @@ 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 <= dayEnd.getTime()) {
while (slotStart + durationMs + newBufferMs <= dayEnd.getTime()) {
const slotEnd = slotStart + durationMs;
const newEndWithBuffer = slotEnd + newBufferMs;
const hasGroomer = groomerIds.some(
(groomerId) =>
!booked.some(
(a) =>
a.staffId === groomerId &&
a.startTime.getTime() < slotEnd &&
a.endTime.getTime() > slotStart
a.startTime.getTime() < newEndWithBuffer &&
a.endTime.getTime() + (a.bufferMinutes ?? 0) * 60_000 > slotStart
)
);
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { getDb, impersonationAuditLogs } from "../db";
import { getDb, impersonationAuditLogs } from "../db/index.js";
import type { PortalEnv } from "./portalSession.js";
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, impersonationSessions } from "../db";
import { and, eq, getDb, impersonationSessions } from "../db/index.js";
export interface PortalEnv {
Variables: {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "../db";
import { and, eq, getDb, sql, staff } from "../db/index.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import { Hono } from "hono";
import { eq, getDb, staff, clients, pets, services } from "./db";
import { eq, getDb, staff, clients, pets, services } from "../../db/index.js";
export const adminSeedRouter = new Hono();
+1 -1
View File
@@ -15,7 +15,7 @@ import {
pets,
services,
staff,
} from "../db";
} from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>();
+44 -18
View File
@@ -11,6 +11,7 @@ import {
lte,
ne,
or,
sql,
appointments,
clients,
pets,
@@ -20,6 +21,7 @@ import {
staff,
} from "../db";
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";
@@ -56,6 +58,9 @@ 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({
@@ -159,7 +164,14 @@ appointmentsRouter.post(
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
// race conditions under concurrent load (fixes #18).
@@ -176,8 +188,8 @@ appointmentsRouter.post(
.where(
and(
eq(appointments.staffId, apptFields.staffId),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
lt(appointments.startTime, endWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -198,8 +210,8 @@ appointmentsRouter.post(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
lt(appointments.startTime, endWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -214,7 +226,7 @@ appointmentsRouter.post(
// Single appointment
const [inserted] = await tx
.insert(appointments)
.values({ ...apptFields, startTime: start, endTime: end })
.values({ ...apptFields, startTime: start, endTime: end, bufferMinutes })
.returning();
if (!inserted) throw new Error("Insert failed");
return inserted;
@@ -239,6 +251,9 @@ 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
@@ -247,8 +262,8 @@ appointmentsRouter.post(
.where(
and(
eq(appointments.staffId, apptFields.staffId),
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
lt(appointments.startTime, instanceEndWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -269,8 +284,8 @@ appointmentsRouter.post(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
lt(appointments.startTime, instanceEndWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -289,6 +304,7 @@ appointmentsRouter.post(
endTime: instanceEnd,
seriesId: series.id,
seriesIndex: i,
bufferMinutes,
})
.returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
@@ -469,14 +485,16 @@ 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, newEnd),
gte(appointments.endTime, newStart),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
@@ -494,6 +512,8 @@ 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)
@@ -503,8 +523,8 @@ appointmentsRouter.patch(
eq(appointments.staffId, newBatherStaffId),
eq(appointments.batherStaffId, newBatherStaffId)
),
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
@@ -619,14 +639,17 @@ 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, end),
gte(appointments.endTime, start),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
@@ -639,6 +662,9 @@ 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)
@@ -648,8 +674,8 @@ appointmentsRouter.patch(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, authProviderConfig, encryptSecret } from "../db";
import { eq, getDb, authProviderConfig, encryptSecret } from "../db/index.js";
import { requireSuperUser } from "../middleware/rbac.js";
import { reinitAuth } from "../lib/auth.js";
+41 -8
View File
@@ -14,9 +14,10 @@ import {
appointments,
clients,
pets,
} from "../db";
} from "../db/index.js";
import {
generateAvailableSlots,
resolveBufferMinutes,
BUSINESS_START_HOUR,
BUSINESS_END_HOUR,
} from "../lib/slots.js";
@@ -43,6 +44,8 @@ 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);
@@ -70,12 +73,16 @@ bookRouter.get("/availability", async (c) => {
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
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
.select({
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
bufferMinutes: appointments.bufferMinutes,
})
.from(appointments)
.where(
@@ -92,6 +99,7 @@ bookRouter.get("/availability", async (c) => {
durationMinutes: service.durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
newBufferMinutes,
});
return c.json(slots);
@@ -113,6 +121,8 @@ 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(
@@ -129,6 +139,12 @@ 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
@@ -141,21 +157,37 @@ bookRouter.post(
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
.select({ staffId: appointments.staffId })
.select({
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
bufferMinutes: appointments.bufferMinutes,
})
.from(appointments)
.where(
and(
lt(appointments.startTime, end),
lt(appointments.startTime, endWithBuffer),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
);
const busyIds = new Set(booked.map((a) => a.staffId));
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
// 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();
});
if (!freeGroomer) {
return c.json(
{ error: "No groomers available at this time. Please choose another slot." },
@@ -206,7 +238,7 @@ bookRouter.post(
.where(
and(
eq(appointments.staffId, freeGroomer.id),
lt(appointments.startTime, end),
lt(appointments.startTime, endWithBuffer),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
@@ -228,6 +260,7 @@ bookRouter.post(
startTime: start,
endTime: end,
notes: body.notes ?? null,
bufferMinutes,
})
.returning();
return apptInserted[0];
+1 -1
View File
@@ -10,7 +10,7 @@ import {
pets,
services,
staff,
} from "../db";
} from "../db/index.js";
export const calendarRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, clients, appointments } from "../db";
import { and, eq, exists, getDb, or, clients, appointments } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const clientsRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { getDb, staff, clients, eq, sql } from "../db";
import { getDb, staff, clients, eq, sql } from "../db/index.js";
const devRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -9,7 +9,7 @@ import {
impersonationAuditLogs,
clients,
desc,
} from "../db";
} from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const impersonationRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -13,7 +13,7 @@ import {
services,
clients,
sql,
} from "../db";
} from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
+10 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "../db";
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -24,6 +24,15 @@ const createPetSchema = z.object({
shampooPreference: z.string().max(500).optional(),
specialCareNotes: z.string().max(2000).optional(),
customFields: z.record(z.string(), z.string()).optional(),
coatType: z.string().max(100).optional(),
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z.array(z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})).max(50).optional(),
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
+2 -2
View File
@@ -1,8 +1,8 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, inArray } from "../db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db";
import { eq, inArray } from "../db/index.js";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db/index.js";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
invoiceTipSplits,
services,
staff,
} from "../db";
} from "../db/index.js";
export const reportsRouter = new Hono();
+1 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { and, eq, getDb, clients, ilike, or, pets } from "../db";
import { and, eq, getDb, clients, ilike, or, pets } from "../db/index.js";
export const searchRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, services } from "../db";
import { eq, getDb, services } from "../db/index.js";
export const servicesRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "../db";
import { eq, getDb, businessSettings } from "../db/index.js";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "../db";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
const RATE_LIMIT_WINDOW_MS = 60_000;
+1 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { randomBytes } from "node:crypto";
import { and, eq, getDb, ne, staff, appointments } from "../db";
import { and, eq, getDb, ne, staff, appointments } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const staffRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { z } from "zod/v3";
import { eq, getDb, invoices } from "../db";
import { eq, getDb, invoices } from "../db/index.js";
import { getStripeClient } from "../services/payment.js";
export const webhooksRouter = new Hono();
+1 -1
View File
@@ -8,7 +8,7 @@ import {
clients,
pets,
services,
} from "../db";
} from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
export const waitlistRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,5 +1,5 @@
import Stripe from "stripe";
import { getDb, clients, eq, inArray, invoices } from "../db";
import { getDb, clients, eq, inArray, invoices } from "../db/index.js";
let _stripe: Stripe | null | undefined;
+1 -1
View File
@@ -14,7 +14,7 @@ import {
staff,
reminderLogs,
session,
} from "../db";
} from "../db/index.js";
import {
buildReminderEmail,
sendEmail,
+1 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "../db";
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "../db/index.js";
import { buildWaitlistNotificationEmail, sendEmail } from "./email.js";
export async function notifyWaitlistForAppointment(
+13
View File
@@ -42,10 +42,23 @@ export interface Pet {
customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
coatType?: string | null;
temperamentScore?: number | null;
temperamentFlags?: string[];
medicalAlerts?: MedicalAlert[];
preferredCuts?: string[];
createdAt: string;
updatedAt: string;
}
export type MedicalAlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
type: string;
description: string;
severity: MedicalAlertSeverity;
}
export interface GroomingVisitLog {
id: string;
petId: string;
+17 -38
View File
@@ -16,12 +16,6 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.800.0
version: 3.1041.0
'@groombook/db':
specifier: workspace:*
version: link:../../packages/db
'@groombook/types':
specifier: workspace:*
version: link:../../packages/types
'@hono/node-server':
specifier: ^1.13.7
version: 1.19.14(hono@4.12.16)
@@ -30,7 +24,10 @@ importers:
version: 0.7.6(hono@4.12.16)(zod@4.4.2)
better-auth:
specifier: ^1.5.6
version: 1.6.9(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))
version: 1.6.9(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
hono:
specifier: ^4.6.17
version: 4.12.16
@@ -40,6 +37,9 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.10.1
postgres:
specifier: ^3.4.5
version: 3.4.9
stripe:
specifier: ^22.0.0
version: 22.1.0(@types/node@22.19.17)
@@ -62,6 +62,9 @@ importers:
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
eslint:
specifier: ^9.18.0
version: 9.39.4
@@ -78,34 +81,6 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/node@22.19.17)(tsx@4.21.0)
packages/db:
dependencies:
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
postgres:
specifier: ^3.4.5
version: 3.4.9
devDependencies:
'@types/node':
specifier: ^22.10.7
version: 22.19.17
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/types:
devDependencies:
typescript:
specifier: ^5.7.3
version: 5.9.3
packages:
'@ampproject/remapping@2.3.0':
@@ -2932,10 +2907,12 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.2
'@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
'@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))':
dependencies:
'@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
optionalDependencies:
drizzle-orm: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
'@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)':
dependencies:
@@ -3925,10 +3902,10 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.6.9(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)):
better-auth@1.6.9(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))
'@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)
'@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
@@ -3945,6 +3922,8 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.2
optionalDependencies:
drizzle-kit: 0.30.6
drizzle-orm: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
vitest: 3.2.4(@types/node@22.19.17)(tsx@4.21.0)
transitivePeerDependencies:
- '@cloudflare/workers-types'