Compare commits

..

6 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
39 changed files with 834 additions and 65 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 |
@@ -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;
+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": {}
}
}
+7
View File
@@ -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
}
]
}
@@ -1,3 +1,4 @@
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";
@@ -21,7 +22,7 @@ const MANAGER: StaffRow = {
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "client-uuid-extended";
const CLIENT_ID = "12345678-1234-1234-1234-123456789abc";
const PET_ID = "pet-uuid-extended";
let petRows: Record<string, unknown>[] = [];
+4
View File
@@ -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(),
+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);
});
});
+3
View File
@@ -158,6 +158,7 @@ export const pets = pgTable(
image: text("image"),
// Extended profile fields
coatType: text("coat_type"),
petSizeCategory: text("pet_size_category"), // "small" | "medium" | "large"
temperamentScore: integer("temperament_score"),
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
@@ -240,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
+1 -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;
@@ -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;
+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>();
+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, 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,
+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(