diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 68d6d25..61c90c3 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -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:00–10:00 appointment with 30-min buffer; POST appointment at 10:15 → should succeed (10:15 > 10:30 effective end) | 201 Created | +| TC-API-4b.7 | Buffer prevents overlap booking | Groomer has 09:00–10:00 appointment with 30-min buffer; POST appointment at 10:00 → should be blocked (10:00 ≤ 10:30 effective end) | 409 Conflict | +| TC-API-4b.8 | Backward compatibility — no buffer params | GET /availability without petSizeCategory/petCoatType and POST without them | Behaves as before with 0-min buffer or default 10-min | +| TC-API-4b.9 | Admin booking also uses buffers | Create appointment via POST /api/appointments (admin) with pet info → bufferMinutes resolved and stored | 201 Created, bufferMinutes set | + ### 4.5 Services | # | Scenario | Steps | Expected | diff --git a/apps/api/migrations/0031_buffer_and_pet_size.sql b/apps/api/migrations/0031_buffer_and_pet_size.sql new file mode 100644 index 0000000..7a9f4be --- /dev/null +++ b/apps/api/migrations/0031_buffer_and_pet_size.sql @@ -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; \ No newline at end of file diff --git a/apps/api/migrations/meta/0031_snapshot.json b/apps/api/migrations/meta/0031_snapshot.json new file mode 100644 index 0000000..5159cf5 --- /dev/null +++ b/apps/api/migrations/meta/0031_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index 782d371..d328b4a 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1775914467192, "tag": "0030_extended_pet_profile", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1776000867192, + "tag": "0031_buffer_and_pet_size", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/__tests__/slots.test.ts b/apps/api/src/__tests__/slots.test.ts index 8461dbc..675ecef 100644 --- a/apps/api/src/__tests__/slots.test.ts +++ b/apps/api/src/__tests__/slots.test.ts @@ -214,7 +214,7 @@ describe("generateAvailableSlots", () => { }); describe("resolveBufferMinutes", () => { - it("returns 0 buffer for unknown size/coat", () => { + 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); }); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 77f6709..2ff67bf 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -94,11 +94,6 @@ function pick(arr: T[]): T { return arr[Math.floor(rand() * arr.length)]!; } -/** Return n distinct random elements from an array. */ -function pickN(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; @@ -1105,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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 478ed17..b9ccd84 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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"; diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 63163d5..23344e0 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -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 { 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 { 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, }, }, diff --git a/apps/api/src/middleware/portalAudit.ts b/apps/api/src/middleware/portalAudit.ts index d76541c..cf631f9 100644 --- a/apps/api/src/middleware/portalAudit.ts +++ b/apps/api/src/middleware/portalAudit.ts @@ -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"; /** diff --git a/apps/api/src/middleware/portalSession.ts b/apps/api/src/middleware/portalSession.ts index b5d1f53..4fda18a 100644 --- a/apps/api/src/middleware/portalSession.ts +++ b/apps/api/src/middleware/portalSession.ts @@ -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: { diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index ae105eb..a3c9d8b 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -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; diff --git a/apps/api/src/routes/admin/seed.ts b/apps/api/src/routes/admin/seed.ts index 8e89748..1220991 100644 --- a/apps/api/src/routes/admin/seed.ts +++ b/apps/api/src/routes/admin/seed.ts @@ -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(); diff --git a/apps/api/src/routes/appointmentGroups.ts b/apps/api/src/routes/appointmentGroups.ts index e75ec66..b6c8e68 100644 --- a/apps/api/src/routes/appointmentGroups.ts +++ b/apps/api/src/routes/appointmentGroups.ts @@ -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(); diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 0f6539b..760b639 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -11,6 +11,7 @@ import { lte, ne, or, + sql, appointments, clients, pets, @@ -19,13 +20,8 @@ import { services, staff, } from "../db"; -import { - buildConfirmationEmail, - sendEmail, -} from "../services/email.js"; -import { - resolveBufferMinutes, -} from "../lib/slots.js"; +import { buildConfirmationEmail, sendEmail } from "../services/email.js"; +import { resolveBufferMinutes } from "../lib/slots.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import type { AppEnv } from "../middleware/rbac.js"; @@ -193,7 +189,7 @@ appointmentsRouter.post( and( eq(appointments.staffId, apptFields.staffId), lt(appointments.startTime, endWithBuffer), - gte(appointments.endTime, start), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ) @@ -215,7 +211,7 @@ appointmentsRouter.post( eq(appointments.batherStaffId, apptFields.batherStaffId) ), lt(appointments.startTime, endWithBuffer), - gte(appointments.endTime, start), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ) @@ -267,7 +263,7 @@ appointmentsRouter.post( and( eq(appointments.staffId, apptFields.staffId), lt(appointments.startTime, instanceEndWithBuffer), - gte(appointments.endTime, instanceStart), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ) @@ -289,7 +285,7 @@ appointmentsRouter.post( eq(appointments.batherStaffId, apptFields.batherStaffId) ), lt(appointments.startTime, instanceEndWithBuffer), - gte(appointments.endTime, instanceStart), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ) @@ -498,7 +494,7 @@ appointmentsRouter.patch( and( eq(appointments.staffId, newStaffId), lt(appointments.startTime, conflictEnd), - gte(appointments.endTime, newStart), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ne(appointments.id, appt.id), @@ -528,7 +524,7 @@ appointmentsRouter.patch( eq(appointments.batherStaffId, newBatherStaffId) ), lt(appointments.startTime, conflictEnd), - gte(appointments.endTime, newStart), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ne(appointments.id, appt.id), @@ -653,7 +649,7 @@ appointmentsRouter.patch( and( eq(appointments.staffId, staffId), lt(appointments.startTime, conflictEnd), - gte(appointments.endTime, start), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ne(appointments.id, id), @@ -679,7 +675,7 @@ appointmentsRouter.patch( eq(appointments.batherStaffId, batherStaffId) ), lt(appointments.startTime, conflictEnd), - gte(appointments.endTime, start), + sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`, ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ne(appointments.id, id), diff --git a/apps/api/src/routes/authProvider.ts b/apps/api/src/routes/authProvider.ts index 9bd4f2f..4cf502f 100644 --- a/apps/api/src/routes/authProvider.ts +++ b/apps/api/src/routes/authProvider.ts @@ -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"; diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index 1fe3ce9..98cde36 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -14,7 +14,7 @@ import { appointments, clients, pets, -} from "../db"; +} from "../db/index.js"; import { generateAvailableSlots, resolveBufferMinutes, @@ -184,9 +184,6 @@ bookRouter.post( if (effectiveEnd > existing) busyGroomers.set(b.staffId ?? "", effectiveEnd); } - // New appointment's effective end (end + its buffer) - const newEffectiveEnd = endWithBuffer.getTime(); - const freeGroomer = groomers.find(({ id }) => { const busyUntil = busyGroomers.get(id) ?? 0; return busyUntil <= start.getTime(); diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index 1aba590..ba745ef 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -10,7 +10,7 @@ import { pets, services, staff, -} from "../db"; +} from "../db/index.js"; export const calendarRouter = new Hono(); diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index 679d3b7..2ae09f0 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -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(); diff --git a/apps/api/src/routes/dev.ts b/apps/api/src/routes/dev.ts index 34e8aa5..8154eaa 100644 --- a/apps/api/src/routes/dev.ts +++ b/apps/api/src/routes/dev.ts @@ -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(); diff --git a/apps/api/src/routes/groomingLogs.ts b/apps/api/src/routes/groomingLogs.ts index 8d24d53..f3a0f5b 100644 --- a/apps/api/src/routes/groomingLogs.ts +++ b/apps/api/src/routes/groomingLogs.ts @@ -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(); diff --git a/apps/api/src/routes/impersonation.ts b/apps/api/src/routes/impersonation.ts index 7cd98f9..bcfe43d 100644 --- a/apps/api/src/routes/impersonation.ts +++ b/apps/api/src/routes/impersonation.ts @@ -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(); diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index ca30cae..799bc49 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -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(); diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index a18f85c..379d2be 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -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, diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 421fc6d..2fe4f91 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -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"; diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index aeffc95..024dfff 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -12,7 +12,7 @@ import { invoiceTipSplits, services, staff, -} from "../db"; +} from "../db/index.js"; export const reportsRouter = new Hono(); diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts index e72d700..0c08179 100644 --- a/apps/api/src/routes/search.ts +++ b/apps/api/src/routes/search.ts @@ -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(); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index bffe6c4..993cb96 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -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(); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 32c48a2..3ad7b25 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -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"; diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index 1ad4c25..90d6c17 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -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; diff --git a/apps/api/src/routes/staff.ts b/apps/api/src/routes/staff.ts index 80c3262..de4c92b 100644 --- a/apps/api/src/routes/staff.ts +++ b/apps/api/src/routes/staff.ts @@ -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(); diff --git a/apps/api/src/routes/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts index e4c5238..b40f063 100644 --- a/apps/api/src/routes/stripe-webhooks.ts +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -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(); diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts index 897e531..c1fe302 100644 --- a/apps/api/src/routes/waitlist.ts +++ b/apps/api/src/routes/waitlist.ts @@ -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(); diff --git a/apps/api/src/services/payment.ts b/apps/api/src/services/payment.ts index 93ede92..fd11805 100644 --- a/apps/api/src/services/payment.ts +++ b/apps/api/src/services/payment.ts @@ -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; diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 255505c..82ab9c7 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -14,7 +14,7 @@ import { staff, reminderLogs, session, -} from "../db"; +} from "../db/index.js"; import { buildReminderEmail, sendEmail, diff --git a/apps/api/src/services/waitlistNotify.ts b/apps/api/src/services/waitlistNotify.ts index bd6f76a..36dfcc3 100644 --- a/apps/api/src/services/waitlistNotify.ts +++ b/apps/api/src/services/waitlistNotify.ts @@ -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(