From 4884961c8e79c7c01fc03f692a8feb6f99534225 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 07:47:55 +0000 Subject: [PATCH] feat(GRO-2152): route optimization schema migration Add the database foundation for mobile groomer route optimization: - clients: latitude/longitude (double precision) + geocodedAt - groomer_routes: per-(staff, date) route with route_status enum, totals, optimizedAt; UNIQUE(staff_id, route_date) - route_stops: ordered stops FK->groomer_routes (cascade) + appointments, lat/lng, per-leg travel mins/distance, bufferMins; UNIQUE(route_id, appointment_id) and UNIQUE(route_id, stop_order) - business_settings: defaultTravelBufferMins (default 15), routeOptimizationProvider (default nominatim), googleMapsApiKey (encrypted at rest at the app layer) - Idempotent hand-authored migration 0041 + journal entry (when=max+1) Lands in packages/db (the deployed schema/migration source per the Dockerfile migrate stage); apps/api is the legacy CI-only copy. Co-Authored-By: Claude Opus 4.8 --- .../db/migrations/0041_route_optimization.sql | 66 +++++++++++++++ packages/db/migrations/meta/_journal.json | 9 +- packages/db/src/factories.ts | 3 + packages/db/src/schema.ts | 82 +++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 packages/db/migrations/0041_route_optimization.sql diff --git a/packages/db/migrations/0041_route_optimization.sql b/packages/db/migrations/0041_route_optimization.sql new file mode 100644 index 0000000..634bfa5 --- /dev/null +++ b/packages/db/migrations/0041_route_optimization.sql @@ -0,0 +1,66 @@ +-- Migration: 0041_route_optimization.sql +-- Route optimization schema: geocoding columns on clients, groomerRoutes + +-- routeStops tables, and route settings on business_settings. +-- Written idempotently so it is safe to re-run. + +-- ─── Enums ──────────────────────────────────────────────────────────────────── + +DO $$ BEGIN + CREATE TYPE "route_status" AS ENUM ('draft', 'optimized', 'in_progress', 'completed'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ─── Clients: geocoding columns ─────────────────────────────────────────────── + +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "latitude" double precision; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "longitude" double precision; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "geocoded_at" timestamp; + +-- ─── Business settings: route optimization config ───────────────────────────── + +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "default_travel_buffer_mins" integer NOT NULL DEFAULT 15; +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "route_optimization_provider" text DEFAULT 'nominatim'; +-- Encrypted at rest at the application layer (AES-256-GCM). +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "google_maps_api_key" text; + +-- ─── Groomer routes table ───────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "groomer_routes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "staff_id" uuid NOT NULL REFERENCES "staff"("id") ON DELETE CASCADE, + "route_date" date NOT NULL, + "status" "route_status" NOT NULL DEFAULT 'draft', + "total_travel_mins" integer, + "total_distance_km" numeric(8, 2), + "optimized_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_groomer_routes_staff_date" UNIQUE ("staff_id", "route_date") +); + +CREATE INDEX IF NOT EXISTS "idx_groomer_routes_staff_id" + ON "groomer_routes"("staff_id"); + +-- ─── Route stops table ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "route_stops" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "route_id" uuid NOT NULL REFERENCES "groomer_routes"("id") ON DELETE CASCADE, + "appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE, + "stop_order" integer NOT NULL, + "latitude" double precision NOT NULL, + "longitude" double precision NOT NULL, + "travel_mins_from_prev" integer, + "travel_distance_km_from_prev" numeric(8, 2), + "buffer_mins" integer NOT NULL DEFAULT 15, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_route_stops_route_appointment" UNIQUE ("route_id", "appointment_id"), + CONSTRAINT "uq_route_stops_route_order" UNIQUE ("route_id", "stop_order") +); + +CREATE INDEX IF NOT EXISTS "idx_route_stops_route_id" + ON "route_stops"("route_id"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 47e54de..1e0c785 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1780000000002, "tag": "0040_register_missing_coat_type_values", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1780000000003, + "tag": "0041_route_optimization", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index c15d42e..866e9b5 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -78,6 +78,9 @@ export function buildClient(overrides: Partial = {}): ClientRow { stripeCustomerId: null, status: "active", disabledAt: null, + latitude: null, + longitude: null, + geocodedAt: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), ...overrides, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3a12d96..292fe1c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,5 +1,7 @@ import { boolean, + date, + doublePrecision, index, integer, jsonb, @@ -140,6 +142,10 @@ export const clients = pgTable( stripeCustomerId: text("stripe_customer_id"), status: clientStatusEnum("status").notNull().default("active"), disabledAt: timestamp("disabled_at"), + // Geocoded coordinates for route optimization; null until geocoded. + latitude: doublePrecision("latitude"), + longitude: doublePrecision("longitude"), + geocodedAt: timestamp("geocoded_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, @@ -555,6 +561,16 @@ export const businessSettings = pgTable("business_settings", { accentColor: text("accent_color").notNull().default("#8b7355"), messagingPhoneNumber: text("messaging_phone_number"), telnyxMessagingProfileId: text("telnyx_messaging_profile_id"), + // Route optimization settings. + defaultTravelBufferMins: integer("default_travel_buffer_mins") + .notNull() + .default(15), + routeOptimizationProvider: text("route_optimization_provider").default( + "nominatim" + ), + // Encrypted at rest at the application layer (AES-256-GCM), mirroring + // the handling of authProviderConfigs.clientSecret. + googleMapsApiKey: text("google_maps_api_key"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); @@ -658,3 +674,69 @@ export const bufferRules = pgTable( index("idx_buffer_rules_service_id").on(t.serviceId), ] ); + +// ─── Route Optimization ─────────────────────────────────────────────────────── + +export const routeStatusEnum = pgEnum("route_status", [ + "draft", + "optimized", + "in_progress", + "completed", +]); + +// A groomer's optimized route for a single day. One row per (staff, date). +export const groomerRoutes = pgTable( + "groomer_routes", + { + id: uuid("id").primaryKey().defaultRandom(), + staffId: uuid("staff_id") + .notNull() + .references(() => staff.id, { onDelete: "cascade" }), + routeDate: date("route_date", { mode: "string" }).notNull(), + status: routeStatusEnum("status").notNull().default("draft"), + // Populated once the route is optimized. + totalTravelMins: integer("total_travel_mins"), + totalDistanceKm: numeric("total_distance_km", { precision: 8, scale: 2 }), + optimizedAt: timestamp("optimized_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // One route per groomer per day. + unique("uq_groomer_routes_staff_date").on(t.staffId, t.routeDate), + index("idx_groomer_routes_staff_id").on(t.staffId), + ] +); + +// An ordered stop within a groomer's route, tied to an appointment. +export const routeStops = pgTable( + "route_stops", + { + id: uuid("id").primaryKey().defaultRandom(), + routeId: uuid("route_id") + .notNull() + .references(() => groomerRoutes.id, { onDelete: "cascade" }), + appointmentId: uuid("appointment_id") + .notNull() + .references(() => appointments.id, { onDelete: "cascade" }), + stopOrder: integer("stop_order").notNull(), + latitude: doublePrecision("latitude").notNull(), + longitude: doublePrecision("longitude").notNull(), + // Null for the first stop in the route. + travelMinsFromPrev: integer("travel_mins_from_prev"), + travelDistanceKmFromPrev: numeric("travel_distance_km_from_prev", { + precision: 8, + scale: 2, + }), + bufferMins: integer("buffer_mins").notNull().default(15), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // An appointment appears at most once per route. + unique("uq_route_stops_route_appointment").on(t.routeId, t.appointmentId), + // Stop order is unique within a route. + unique("uq_route_stops_route_order").on(t.routeId, t.stopOrder), + index("idx_route_stops_route_id").on(t.routeId), + ] +);