From 37bb805aee8052657d816586967b9b9694065da7 Mon Sep 17 00:00:00 2001 From: Lujarios Date: Mon, 12 Jan 2026 11:26:56 -0700 Subject: [PATCH 01/23] SANC 28: Database instantaition for bookings fix --- drizzle/0000_striped_mandrill.sql | 135 ------------------------------ 1 file changed, 135 deletions(-) delete mode 100644 drizzle/0000_striped_mandrill.sql diff --git a/drizzle/0000_striped_mandrill.sql b/drizzle/0000_striped_mandrill.sql deleted file mode 100644 index a8d00e5..0000000 --- a/drizzle/0000_striped_mandrill.sql +++ /dev/null @@ -1,135 +0,0 @@ -CREATE TABLE "form" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "title" text NOT NULL, - "description" text, - "submitted_by_id" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "account" ( - "id" text PRIMARY KEY NOT NULL, - "account_id" text NOT NULL, - "provider_id" text NOT NULL, - "user_id" text NOT NULL, - "access_token" text, - "refresh_token" text, - "id_token" text, - "access_token_expires_at" timestamp, - "refresh_token_expires_at" timestamp, - "scope" text, - "password" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp NOT NULL -); ---> statement-breakpoint -CREATE TABLE "invitation" ( - "id" text PRIMARY KEY NOT NULL, - "organization_id" text NOT NULL, - "email" text NOT NULL, - "role" text, - "status" text DEFAULT 'pending' NOT NULL, - "expires_at" timestamp NOT NULL, - "inviter_id" text NOT NULL -); ---> statement-breakpoint -CREATE TABLE "member" ( - "id" text PRIMARY KEY NOT NULL, - "organization_id" text NOT NULL, - "user_id" text NOT NULL, - "role" text DEFAULT 'member' NOT NULL, - "created_at" timestamp NOT NULL -); ---> statement-breakpoint -CREATE TABLE "organization" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "slug" text NOT NULL, - "logo" text, - "created_at" timestamp NOT NULL, - "metadata" text, - CONSTRAINT "organization_slug_unique" UNIQUE("slug") -); ---> statement-breakpoint -CREATE TABLE "session" ( - "id" text PRIMARY KEY NOT NULL, - "expires_at" timestamp NOT NULL, - "token" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp NOT NULL, - "ip_address" text, - "user_agent" text, - "user_id" text NOT NULL, - "active_organization_id" text, - CONSTRAINT "session_token_unique" UNIQUE("token") -); ---> statement-breakpoint -CREATE TABLE "user" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "email" text NOT NULL, - "email_verified" boolean DEFAULT false NOT NULL, - "image" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - "role" text DEFAULT 'driver' NOT NULL, - CONSTRAINT "user_email_unique" UNIQUE("email") -); ---> statement-breakpoint -CREATE TABLE "verification" ( - "id" text PRIMARY KEY NOT NULL, - "identifier" text NOT NULL, - "value" text NOT NULL, - "expires_at" timestamp NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "bookings" ( - "id" serial PRIMARY KEY NOT NULL, - "title" text NOT NULL, - "pickup_address" text NOT NULL, - "destination_address" text NOT NULL, - "purpose" text, - "passenger_info" text NOT NULL, - "phone_number" varchar(25), - "status" text DEFAULT 'incomplete' NOT NULL, - "agency_id" text NOT NULL, - "start_time" timestamp with time zone NOT NULL, - "end_time" timestamp with time zone NOT NULL, - "driver_id" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now(), - "created_by" text, - "updated_by" text -); ---> statement-breakpoint -CREATE TABLE "post_trip_surveys" ( - "id" serial PRIMARY KEY NOT NULL, - "booking_id" integer NOT NULL, - "driver_id" text NOT NULL, - "trip_completion_status" text DEFAULT 'completed' NOT NULL, - "start_reading" integer NOT NULL, - "end_reading" integer, - "time_of_departure" timestamp, - "time_of_arrival" timestamp, - "destination_address" text, - "original_location_changed" boolean, - "passenger_fit_rating" integer, - "comments" text, - "created_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "passenger_fit_rating_check" CHECK ("post_trip_surveys"."passenger_fit_rating" >= 1 AND "post_trip_surveys"."passenger_fit_rating" <= 5) -); ---> statement-breakpoint -ALTER TABLE "form" ADD CONSTRAINT "form_submitted_by_id_user_id_fk" FOREIGN KEY ("submitted_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "bookings" ADD CONSTRAINT "bookings_agency_id_user_id_fk" FOREIGN KEY ("agency_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "bookings" ADD CONSTRAINT "bookings_driver_id_user_id_fk" FOREIGN KEY ("driver_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "bookings" ADD CONSTRAINT "bookings_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "bookings" ADD CONSTRAINT "bookings_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_trip_surveys" ADD CONSTRAINT "post_trip_surveys_booking_id_bookings_id_fk" FOREIGN KEY ("booking_id") REFERENCES "public"."bookings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_trip_surveys" ADD CONSTRAINT "post_trip_surveys_driver_id_user_id_fk" FOREIGN KEY ("driver_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; \ No newline at end of file From fca3921c5824025d6812e96149c01c9ddc4c8db7 Mon Sep 17 00:00:00 2001 From: Lujarios Date: Mon, 12 Jan 2026 15:14:24 -0700 Subject: [PATCH 02/23] SANC 28: Bookings debug page date and time selection --- .gitignore | 3 +- src/app/debug/bookings/page.tsx | 70 ++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 5e90206..1fb708b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ /coverage # database -drizzle/meta +drizzle/meta +drizzle # next.js /.next/ diff --git a/src/app/debug/bookings/page.tsx b/src/app/debug/bookings/page.tsx index 6690277..1183ebc 100644 --- a/src/app/debug/bookings/page.tsx +++ b/src/app/debug/bookings/page.tsx @@ -3,23 +3,34 @@ import { Button, Divider, Group, Select, Textarea, TextInput } from "@mantine/core"; import { useForm } from "@mantine/form"; import { notifications } from "@mantine/notifications"; -import { useState } from "react"; -import { authClient } from "@/lib/auth-client"; +import { useMemo, useState } from "react"; +import DatePicker from "@/app/_components/common/datepicker/DatePicker"; // <-- adjust path if needed import { api } from "@/trpc/react"; import { ALL_BOOKING_STATUSES, BookingStatus, type BookingStatusValue } from "@/types/types"; import styles from "./BookingDebugPage.module.scss"; +function isEndAfterStart(start: string, end: string) { + const a = new Date(start).getTime(); + const b = new Date(end).getTime(); + if (Number.isNaN(a) || Number.isNaN(b)) return true; // let backend/zod handle invalid formats + return b > a; +} + export default function BookingDebugPage() { const [bookingId, setBookingId] = useState(1); + // Keep DatePicker values exactly like the styleguide: string | null + const [startPickerValue, setStartPickerValue] = useState(null); + const [endPickerValue, setEndPickerValue] = useState(null); + const form = useForm({ initialValues: { title: "", pickupAddress: "", destinationAddress: "", passengerInfo: "", - start: "", - end: "", + start: "", // will be kept in sync with picker (string) + end: "", // will be kept in sync with picker (string) agencyId: "", purpose: "", driverId: "", @@ -53,6 +64,8 @@ export default function BookingDebugPage() { await allBookingsQuery.refetch(); form.reset(); + setStartPickerValue(null); + setEndPickerValue(null); }, onError: (err) => { notifications.show({ color: "red", message: err.message }); @@ -90,6 +103,11 @@ export default function BookingDebugPage() { label: s.replace("-", " ").replace(/\b\w/g, (c) => c.toUpperCase()), })); + const endAfterStart = useMemo(() => { + if (!form.values.start || !form.values.end) return true; + return isEndAfterStart(form.values.start, form.values.end); + }, [form.values.start, form.values.end]); + return (

Booking API Debug Panel

@@ -132,7 +150,16 @@ export default function BookingDebugPage() { {/* CREATE FORM */}

Create Booking

+ onSubmit={form.onSubmit((values) => { + // Front-end guard matching backend refine() + if (!isEndAfterStart(values.start, values.end)) { + notifications.show({ + color: "red", + message: "End time must be after start time.", + }); + return; + } + createMutation.mutate({ title: values.title, pickupAddress: values.pickupAddress, @@ -144,8 +171,8 @@ export default function BookingDebugPage() { purpose: values.purpose || undefined, driverId: values.driverId || null, status: values.status, - }), - )} + }); + })} className={styles.formGrid} > @@ -159,19 +186,32 @@ export default function BookingDebugPage() { - { + setStartPickerValue(v); + form.setFieldValue("start", v ?? ""); + }} /> - { + setEndPickerValue(v); + form.setFieldValue("end", v ?? ""); + }} /> + {!endAfterStart && ( +

End time must be after start time.

+ )} + From c60784390c275838195f037be74ed730f142e52f Mon Sep 17 00:00:00 2001 From: Lujarios Date: Mon, 2 Feb 2026 23:48:05 -0700 Subject: [PATCH 03/23] SANC 28: Add Google Maps travel time utility and datetime helpers --- src/constants/driver-assignment.ts | 13 ++++ src/lib/datetime.ts | 36 ++++++++++ src/lib/google-maps.ts | 106 +++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/constants/driver-assignment.ts create mode 100644 src/lib/datetime.ts create mode 100644 src/lib/google-maps.ts diff --git a/src/constants/driver-assignment.ts b/src/constants/driver-assignment.ts new file mode 100644 index 0000000..6a36e05 --- /dev/null +++ b/src/constants/driver-assignment.ts @@ -0,0 +1,13 @@ +/** + * Constants for driver trip assignment system + * These values control how driver availability and earliest booking times are calculated + */ + +/** Wait time at pickup location (in minutes) */ +export const PICKUP_WAIT_TIME_MINUTES = 15; + +/** Buffer time added after travel calculations (in minutes) */ +export const TRAVEL_BUFFER_MINUTES = 15; + +/** Time slot rounding increment (in minutes) */ +export const TIME_SLOT_ROUNDING_MINUTES = 15; diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts new file mode 100644 index 0000000..e2d9345 --- /dev/null +++ b/src/lib/datetime.ts @@ -0,0 +1,36 @@ +import { TIME_SLOT_ROUNDING_MINUTES } from "@/constants/driver-assignment"; + +/** + * Rounds a date up to the nearest time slot increment (default: 15 minutes) + * Examples: + * - 14:07 → 14:15 + * - 14:15 → 14:15 + * - 14:16 → 14:30 + * - 14:45 → 15:00 + * + * @param date - The date to round up + * @param incrementMinutes - The increment in minutes (default: 15) + * @returns A new Date rounded up to the nearest increment + */ +export function roundUpToNearestIncrement( + date: Date, + incrementMinutes: number = TIME_SLOT_ROUNDING_MINUTES, +): Date { + const rounded = new Date(date); + const minutes = rounded.getMinutes(); + const roundedMinutes = Math.ceil(minutes / incrementMinutes) * incrementMinutes; + + // If rounding goes past 60 minutes, add an hour and reset minutes + if (roundedMinutes >= 60) { + rounded.setHours(rounded.getHours() + 1); + rounded.setMinutes(roundedMinutes - 60); + } else { + rounded.setMinutes(roundedMinutes); + } + + // Reset seconds and milliseconds + rounded.setSeconds(0); + rounded.setMilliseconds(0); + + return rounded; +} diff --git a/src/lib/google-maps.ts b/src/lib/google-maps.ts new file mode 100644 index 0000000..a422797 --- /dev/null +++ b/src/lib/google-maps.ts @@ -0,0 +1,106 @@ +import { env } from "@/env"; + +/** + * Response from Google Maps Distance Matrix API + */ +interface DistanceMatrixResponse { + status: string; + rows: Array<{ + elements: Array<{ + status: string; + duration?: { + value: number; // Duration in seconds + text: string; // Human-readable duration + }; + distance?: { + value: number; // Distance in meters + text: string; // Human-readable distance + }; + }>; + }>; + error_message?: string; +} + +/** + * Options for calculating travel time + */ +export interface TravelTimeOptions { + /** Travel mode (default: "driving") */ + mode?: "driving" | "walking" | "bicycling" | "transit"; + /** Unit system (default: "metric") */ + units?: "metric" | "imperial"; +} + +/** + * Calculates travel time between two addresses using Google Maps Distance Matrix API + * + * @param originAddress - Origin address as a string + * @param destinationAddress - Destination address as a string + * @param options - Optional travel mode and unit settings + * @returns Travel duration in minutes, or null if calculation fails + * @throws Error if API key is missing or API request fails critically + */ +export async function getTravelTimeMinutes( + originAddress: string, + destinationAddress: string, + options: TravelTimeOptions = {}, +): Promise { + const apiKey = env.GOOGLE_MAPS_API_KEY; + + if (!apiKey) { + throw new Error("GOOGLE_MAPS_API_KEY is not configured"); + } + + const { mode = "driving", units = "metric" } = options; + + // Build the Distance Matrix API URL + const baseUrl = "https://maps.googleapis.com/maps/api/distancematrix/json"; + const params = new URLSearchParams({ + origins: originAddress, + destinations: destinationAddress, + mode, + units, + key: apiKey, + }); + + const url = `${baseUrl}?${params.toString()}`; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Google Maps API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as DistanceMatrixResponse; + + // Check for API-level errors + if (data.status !== "OK") { + const errorMsg = data.error_message || `API returned status: ${data.status}`; + console.error("Google Maps Distance Matrix API error:", errorMsg); + return null; + } + + // Check if we have valid data + if (!data.rows?.[0]?.elements?.[0] || data.rows[0].elements[0].status !== "OK") { + const elementStatus = data.rows[0]?.elements?.[0]?.status || "UNKNOWN"; + console.error("Google Maps Distance Matrix element error:", elementStatus); + return null; + } + + const duration = data.rows[0].elements[0].duration; + + if (!duration) { + console.error("Google Maps Distance Matrix: No duration data returned"); + return null; + } + + // Convert seconds to minutes and round up + const minutes = Math.ceil(duration.value / 60); + return minutes; + } catch (error) { + // Log error but return null instead of throwing to allow graceful degradation + console.error("Error calculating travel time:", error); + return null; + } +} From cb1e5f38247acd77d1bafc498e360cd555c997aa Mon Sep 17 00:00:00 2001 From: Lujarios Date: Tue, 3 Feb 2026 00:02:49 -0700 Subject: [PATCH 04/23] SANC 28: Add list drivers API endpoint --- src/server/api/routers/bookings.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/server/api/routers/bookings.ts b/src/server/api/routers/bookings.ts index 0e73dbd..9b2f010 100644 --- a/src/server/api/routers/bookings.ts +++ b/src/server/api/routers/bookings.ts @@ -1,12 +1,28 @@ import { TRPCError } from "@trpc/server"; -import { desc, eq, or } from "drizzle-orm"; +import { asc, desc, eq, or } from "drizzle-orm"; import { z } from "zod"; +import { user } from "../../db/auth-schema"; import { BOOKING_STATUS, bookings } from "../../db/booking-schema"; import { createTRPCRouter, protectedProcedure } from "../trpc"; const StatusZ = z.enum(BOOKING_STATUS); // ← uses "cancelled" (double-L) export const bookingsRouter = createTRPCRouter({ + // GET /bookings/drivers (list all drivers) + listDrivers: protectedProcedure.query(async ({ ctx }) => { + const drivers = await ctx.db + .select({ + id: user.id, + name: user.name, + email: user.email, + }) + .from(user) + .where(eq(user.role, "driver")) + .orderBy(asc(user.name)); + + return drivers; + }), + // POST /bookings (create) create: protectedProcedure .input( From f3ad441664477b183ee24d94543d172b6d0c0167 Mon Sep 17 00:00:00 2001 From: Lujarios Date: Tue, 3 Feb 2026 01:59:34 -0700 Subject: [PATCH 05/23] SANC 28: Add driver availability overlap check --- src/server/api/routers/bookings.ts | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/server/api/routers/bookings.ts b/src/server/api/routers/bookings.ts index 9b2f010..cd3a392 100644 --- a/src/server/api/routers/bookings.ts +++ b/src/server/api/routers/bookings.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server"; -import { asc, desc, eq, or } from "drizzle-orm"; +import { and, asc, desc, eq, gt, lt, ne, or } from "drizzle-orm"; import { z } from "zod"; import { user } from "../../db/auth-schema"; import { BOOKING_STATUS, bookings } from "../../db/booking-schema"; @@ -23,6 +23,48 @@ export const bookingsRouter = createTRPCRouter({ return drivers; }), + // GET /bookings/is-driver-available + isDriverAvailable: protectedProcedure + .input( + z.object({ + driverId: z.string().min(1), + startTime: z.string().datetime(), + endTime: z.string().datetime(), + /** + * Optional booking ID to exclude from the overlap check. + * Useful when editing an existing booking. + */ + excludeBookingId: z.number().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { driverId, startTime, endTime, excludeBookingId } = input; + + // Overlap condition: + // existing.startTime < endTime AND existing.endTime > startTime + const baseCondition = and( + eq(bookings.driverId, driverId), + ne(bookings.status, "cancelled"), + lt(bookings.startTime, endTime), + gt(bookings.endTime, startTime), + ); + + const whereCondition = + excludeBookingId !== undefined + ? and(baseCondition, ne(bookings.id, excludeBookingId)) + : baseCondition; + + const overlapping = await ctx.db + .select({ id: bookings.id }) + .from(bookings) + .where(whereCondition) + .limit(1); + + return { + available: overlapping.length === 0, + }; + }), + // POST /bookings (create) create: protectedProcedure .input( From a7a65d41692fc14aaf11a3194b87336099cf62e0 Mon Sep 17 00:00:00 2001 From: Lujarios Date: Wed, 4 Feb 2026 22:35:32 -0700 Subject: [PATCH 06/23] SANC 28: Add earliest next booking time calculation with travel time --- src/server/api/routers/bookings.ts | 74 ++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/server/api/routers/bookings.ts b/src/server/api/routers/bookings.ts index cd3a392..cabd2ca 100644 --- a/src/server/api/routers/bookings.ts +++ b/src/server/api/routers/bookings.ts @@ -1,10 +1,16 @@ import { TRPCError } from "@trpc/server"; import { and, asc, desc, eq, gt, lt, ne, or } from "drizzle-orm"; import { z } from "zod"; +import { PICKUP_WAIT_TIME_MINUTES, TRAVEL_BUFFER_MINUTES } from "@/constants/driver-assignment"; +import { roundUpToNearestIncrement } from "@/lib/datetime"; +import { getTravelTimeMinutes } from "@/lib/google-maps"; import { user } from "../../db/auth-schema"; import { BOOKING_STATUS, bookings } from "../../db/booking-schema"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +/** Fallback travel minutes per leg when Google Maps API fails */ +const FALLBACK_TRAVEL_MINUTES = 15; + const StatusZ = z.enum(BOOKING_STATUS); // ← uses "cancelled" (double-L) export const bookingsRouter = createTRPCRouter({ @@ -65,6 +71,74 @@ export const bookingsRouter = createTRPCRouter({ }; }), + // GET /bookings/earliest-start-for-driver + getEarliestStartForDriver: protectedProcedure + .input( + z.object({ + driverId: z.string().min(1), + newPickupAddress: z.string().min(1), + /** Optional: only consider bookings ending after this time (ISO string) */ + afterDate: z.string().datetime().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { driverId, newPickupAddress, afterDate } = input; + + const driverCondition = and( + eq(bookings.driverId, driverId), + ne(bookings.status, "cancelled"), + ); + const whereCondition = + afterDate !== undefined + ? and(driverCondition, gt(bookings.endTime, afterDate)) + : driverCondition; + + const [lastBooking] = await ctx.db + .select({ + id: bookings.id, + startTime: bookings.startTime, + endTime: bookings.endTime, + pickupAddress: bookings.pickupAddress, + destinationAddress: bookings.destinationAddress, + }) + .from(bookings) + .where(whereCondition) + .orderBy(desc(bookings.endTime)) + .limit(1); + + if ( + !lastBooking?.startTime || + !lastBooking?.pickupAddress || + !lastBooking?.destinationAddress + ) { + return { earliestStart: null }; + } + + const prevStart = new Date(lastBooking.startTime); + + const travel1 = + (await getTravelTimeMinutes(lastBooking.pickupAddress, lastBooking.destinationAddress)) ?? + FALLBACK_TRAVEL_MINUTES; + const travel2 = + (await getTravelTimeMinutes(lastBooking.destinationAddress, newPickupAddress)) ?? + FALLBACK_TRAVEL_MINUTES; + + const earliest = new Date(prevStart.getTime()); + earliest.setMinutes( + earliest.getMinutes() + + PICKUP_WAIT_TIME_MINUTES + + travel1 + + travel2 + + TRAVEL_BUFFER_MINUTES, + ); + + const rounded = roundUpToNearestIncrement(earliest); + + return { + earliestStart: rounded.toISOString(), + }; + }), + // POST /bookings (create) create: protectedProcedure .input( From 4a8179d1197eae921ab05a0abd4083792ca7ae9e Mon Sep 17 00:00:00 2001 From: Lujarios Date: Wed, 4 Feb 2026 22:36:59 -0700 Subject: [PATCH 07/23] SANC 28: Validate driver availability on booking create/update --- src/server/api/routers/bookings.ts | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/server/api/routers/bookings.ts b/src/server/api/routers/bookings.ts index cabd2ca..2db1a4c 100644 --- a/src/server/api/routers/bookings.ts +++ b/src/server/api/routers/bookings.ts @@ -188,6 +188,29 @@ export const bookingsRouter = createTRPCRouter({ if (input.driverId !== undefined) bookingData.driverId = input.driverId; if (input.status !== undefined) bookingData.status = input.status; + // Validate driver availability when assigning a driver (no overlapping bookings) + if (input.driverId) { + const overlapping = await ctx.db + .select({ id: bookings.id }) + .from(bookings) + .where( + and( + eq(bookings.driverId, input.driverId), + ne(bookings.status, "cancelled"), + lt(bookings.startTime, input.endTime), + gt(bookings.endTime, input.startTime), + ), + ) + .limit(1); + + if (overlapping.length > 0) { + throw new TRPCError({ + code: "CONFLICT", + message: "Driver has another booking at that time.", + }); + } + } + const [row] = await ctx.db.insert(bookings).values(bookingData).returning(); if (!row) { @@ -289,6 +312,30 @@ export const bookingsRouter = createTRPCRouter({ Object.entries(updates).filter(([, v]) => v !== undefined), ); + // 3b) Validate driver availability when assigning/changing driver (no overlapping bookings) + if (updatesToApply.driverId !== undefined && updatesToApply.driverId) { + const overlapping = await ctx.db + .select({ id: bookings.id }) + .from(bookings) + .where( + and( + eq(bookings.driverId, updatesToApply.driverId), + ne(bookings.status, "cancelled"), + lt(bookings.startTime, existing.endTime), + gt(bookings.endTime, existing.startTime), + ne(bookings.id, id), + ), + ) + .limit(1); + + if (overlapping.length > 0) { + throw new TRPCError({ + code: "CONFLICT", + message: "Driver has another booking at that time.", + }); + } + } + // 4) Perform update with updatedBy field set const res = await ctx.db .update(bookings) From e77b9be20a3ebb81923617bc3c9e3047954a178d Mon Sep 17 00:00:00 2001 From: Lujarios Date: Thu, 5 Feb 2026 01:04:31 -0700 Subject: [PATCH 08/23] SANC 28: Validate travel time from previous booking to new pickup within 1 hour --- src/constants/driver-assignment.ts | 6 ++ src/server/api/routers/bookings.ts | 111 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/constants/driver-assignment.ts b/src/constants/driver-assignment.ts index 6a36e05..032c3db 100644 --- a/src/constants/driver-assignment.ts +++ b/src/constants/driver-assignment.ts @@ -11,3 +11,9 @@ export const TRAVEL_BUFFER_MINUTES = 15; /** Time slot rounding increment (in minutes) */ export const TIME_SLOT_ROUNDING_MINUTES = 15; + +/** + * Only run "travel from previous destination to new pickup" check when the new + * booking start is within this many minutes after the previous booking end. + */ +export const MAX_GAP_MINUTES_FOR_TRAVEL_CHECK = 60; diff --git a/src/server/api/routers/bookings.ts b/src/server/api/routers/bookings.ts index 2db1a4c..e116343 100644 --- a/src/server/api/routers/bookings.ts +++ b/src/server/api/routers/bookings.ts @@ -1,7 +1,11 @@ import { TRPCError } from "@trpc/server"; import { and, asc, desc, eq, gt, lt, ne, or } from "drizzle-orm"; import { z } from "zod"; -import { PICKUP_WAIT_TIME_MINUTES, TRAVEL_BUFFER_MINUTES } from "@/constants/driver-assignment"; +import { + MAX_GAP_MINUTES_FOR_TRAVEL_CHECK, + PICKUP_WAIT_TIME_MINUTES, + TRAVEL_BUFFER_MINUTES, +} from "@/constants/driver-assignment"; import { roundUpToNearestIncrement } from "@/lib/datetime"; import { getTravelTimeMinutes } from "@/lib/google-maps"; import { user } from "../../db/auth-schema"; @@ -209,6 +213,57 @@ export const bookingsRouter = createTRPCRouter({ message: "Driver has another booking at that time.", }); } + + // When new start is within 1 hour of driver's previous booking end, ensure + // there is enough time to travel from previous destination to new pickup. + const [prevBooking] = await ctx.db + .select({ + endTime: bookings.endTime, + destinationAddress: bookings.destinationAddress, + }) + .from(bookings) + .where( + and( + eq(bookings.driverId, input.driverId), + ne(bookings.status, "cancelled"), + lt(bookings.endTime, input.startTime), + ), + ) + .orderBy(desc(bookings.endTime)) + .limit(1); + + if ( + prevBooking?.endTime && + prevBooking?.destinationAddress && + prevBooking.destinationAddress.trim() !== "" + ) { + const prevEndMs = new Date(prevBooking.endTime).getTime(); + const newStartMs = new Date(input.startTime).getTime(); + const gapMinutes = (newStartMs - prevEndMs) / (60 * 1000); + + if (gapMinutes <= MAX_GAP_MINUTES_FOR_TRAVEL_CHECK) { + const travelMinutes = + (await getTravelTimeMinutes(prevBooking.destinationAddress, input.pickupAddress)) ?? + FALLBACK_TRAVEL_MINUTES; + const requiredMinutes = travelMinutes + TRAVEL_BUFFER_MINUTES; + + if (gapMinutes < requiredMinutes) { + const nextPossible = new Date(prevEndMs); + nextPossible.setMinutes(nextPossible.getMinutes() + requiredMinutes); + const nextRounded = roundUpToNearestIncrement(nextPossible); + const nextFormatted = nextRounded.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid start time. Travel time from previous booking to pickup is ${travelMinutes} minutes and more time is required to reach pickup. Next possible start time is ${nextFormatted}.`, + }); + } + } + } } const [row] = await ctx.db.insert(bookings).values(bookingData).returning(); @@ -334,6 +389,60 @@ export const bookingsRouter = createTRPCRouter({ message: "Driver has another booking at that time.", }); } + + // When this booking's start is within 1 hour of driver's previous booking end, + // ensure there is enough time to travel from previous destination to this pickup. + const [prevBooking] = await ctx.db + .select({ + endTime: bookings.endTime, + destinationAddress: bookings.destinationAddress, + }) + .from(bookings) + .where( + and( + eq(bookings.driverId, updatesToApply.driverId), + ne(bookings.status, "cancelled"), + lt(bookings.endTime, existing.startTime), + ne(bookings.id, id), + ), + ) + .orderBy(desc(bookings.endTime)) + .limit(1); + + if ( + prevBooking?.endTime && + prevBooking?.destinationAddress && + prevBooking.destinationAddress.trim() !== "" + ) { + const prevEndMs = new Date(prevBooking.endTime).getTime(); + const newStartMs = new Date(existing.startTime).getTime(); + const gapMinutes = (newStartMs - prevEndMs) / (60 * 1000); + + if (gapMinutes <= MAX_GAP_MINUTES_FOR_TRAVEL_CHECK) { + const travelMinutes = + (await getTravelTimeMinutes( + prevBooking.destinationAddress, + existing.pickupAddress, + )) ?? FALLBACK_TRAVEL_MINUTES; + const requiredMinutes = travelMinutes + TRAVEL_BUFFER_MINUTES; + + if (gapMinutes < requiredMinutes) { + const nextPossible = new Date(prevEndMs); + nextPossible.setMinutes(nextPossible.getMinutes() + requiredMinutes); + const nextRounded = roundUpToNearestIncrement(nextPossible); + const nextFormatted = nextRounded.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid start time. Travel time from previous booking to pickup is ${travelMinutes} minutes and more time is required to reach pickup. Next possible start time is ${nextFormatted}.`, + }); + } + } + } } // 4) Perform update with updatedBy field set From 284778def2c0c1eb28419025a37ccf850cb3d622 Mon Sep 17 00:00:00 2001 From: Lujarios Date: Thu, 5 Feb 2026 01:17:26 -0700 Subject: [PATCH 09/23] SANC 28: Show driver availability on debug page before submit --- src/app/debug/bookings/page.tsx | 38 ++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/app/debug/bookings/page.tsx b/src/app/debug/bookings/page.tsx index 1183ebc..032e8eb 100644 --- a/src/app/debug/bookings/page.tsx +++ b/src/app/debug/bookings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Divider, Group, Select, Textarea, TextInput } from "@mantine/core"; +import { Alert, Button, Divider, Group, Select, Text, Textarea, TextInput } from "@mantine/core"; import { useForm } from "@mantine/form"; import { notifications } from "@mantine/notifications"; import { useMemo, useState } from "react"; @@ -56,6 +56,21 @@ export default function BookingDebugPage() { staleTime: 0, }); + const canCheckAvailability = + !!form.values.driverId?.trim() && + !!form.values.start && + !!form.values.end && + isEndAfterStart(form.values.start, form.values.end); + + const availabilityQuery = api.bookings.isDriverAvailable.useQuery( + { + driverId: form.values.driverId, + startTime: form.values.start, + endTime: form.values.end, + }, + { enabled: canCheckAvailability }, + ); + const createMutation = api.bookings.create.useMutation({ onSuccess: async () => { notifications.show({ color: "green", message: "Booking created!" }); @@ -212,6 +227,27 @@ export default function BookingDebugPage() {

End time must be after start time.

)} + {canCheckAvailability && availabilityQuery.isLoading && ( + + Checking availability… + + )} + {canCheckAvailability && availabilityQuery.data && ( + + {availabilityQuery.data.available + ? "Driver is available for this time." + : "Driver has another booking at this time."} + + )} + {!canCheckAvailability && form.values.driverId?.trim() && ( + + Enter start and end time to check driver availability. + + )} + From eba8b32786b7c04e37d050de3db441b2f64e5c40 Mon Sep 17 00:00:00 2001 From: Lujarios Date: Thu, 5 Feb 2026 01:28:03 -0700 Subject: [PATCH 10/23] SANC 28: Driver dropdown on debug page from listDrivers --- src/app/debug/bookings/page.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/debug/bookings/page.tsx b/src/app/debug/bookings/page.tsx index 032e8eb..ee8e74f 100644 --- a/src/app/debug/bookings/page.tsx +++ b/src/app/debug/bookings/page.tsx @@ -56,6 +56,16 @@ export default function BookingDebugPage() { staleTime: 0, }); + const listDriversQuery = api.bookings.listDrivers.useQuery(); + const driverOptions = useMemo( + () => + (listDriversQuery.data ?? []).map((d) => ({ + value: d.id, + label: `${d.name} (${d.email})`, + })), + [listDriversQuery.data], + ); + const canCheckAvailability = !!form.values.driverId?.trim() && !!form.values.start && @@ -200,7 +210,14 @@ export default function BookingDebugPage() {