package migrations import ( "doormile/internal/ai/registry" "doormile/models" "doormile/utils" "gorm.io/gorm" ) func Migrate(db *gorm.DB) error { utils.Info("Starting database auto-migrations for 21 logistics tables...") err := db.AutoMigrate( &models.PartnerInfo{}, &models.Hub{}, &models.Vehicle{}, &models.AppUser{}, &models.MilerProfile{}, &models.AppCustomer{}, &models.AppCustomerLocation{}, &models.Consignment{}, &models.PickupBooking{}, &models.BookingParcel{}, &models.BookingServiceOption{}, &models.BookingPayment{}, &models.BookingAssignment{}, &models.BookingVehicleRequirement{}, &models.Tripsheet{}, &models.TripsheetItem{}, &models.DeliveryProof{}, &models.Pricing{}, &models.ConsignmentHistory{}, &models.ConsignmentException{}, &models.TenantLocation{}, &models.AppLocation{}, &models.Tenant{}, &models.Customer{}, &models.CustomerLocation{}, &models.DoormileClient{}, &models.DoormileAuth{}, &models.CompetitorBranch{}, &models.CarrierPricing{}, &models.DoormilePricing{}, &models.AgentDecision{}, &models.HubStaffAccount{}, &models.HubConversation{}, &models.HubMessage{}, &models.MilerDutyLog{}, &models.MilerBreakLog{}, &models.MilerSupportTicket{}, // Customer app (doormile_cx). All additive: new tables plus new // nullable/zero-default columns on pickupbookings, so an existing row // and every console-created booking stay valid with no backfill. &models.ServiceableState{}, &models.ServiceableDistrict{}, &models.PickupSlotTemplate{}, &models.CustomerBookingLimit{}, &models.BookingDestination{}, &models.BookingParcelPhoto{}, &models.BookingStageEvent{}, &models.CustomerRefreshToken{}, &models.CustomerDevice{}, // AI agent registry (agent-platform-plan Phase 1). Five new tables, // nothing existing touched. &models.AIAgent{}, &models.AITool{}, &models.AISkill{}, &models.AISkillTool{}, &models.AIRegistryAudit{}, // Agent runs recorded from AI_engine telemetry (plan Phase 4). One new // append-only table, pruned after 30 days. &models.AIAgentRun{}, ) if err != nil { utils.Error("❌ Database migration failed", "error", err) return err } utils.Info("✅ Database migration completed successfully!") // Upsert the code-defined agent registry. A failure is logged, not fatal: // the registry is read by Agent Studio and AI_engine, and neither is on a // booking's path, so it must not stop the API from serving orders. if err := registry.Seed(db); err != nil { utils.Error("⚠️ AI registry seed failed", "error", err.Error()) } if res := db.Exec(`ALTER TABLE agent_decisions ADD COLUMN IF NOT EXISTS context_embedding vector(1536)`); res.Error != nil { utils.Error("❌ Failed to add context_embedding column", "error", res.Error) } else { utils.Info("✅ context_embedding vector column ready") } if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_agent_decisions_embedding ON agent_decisions USING ivfflat (context_embedding vector_cosine_ops) WITH (lists = 100)`); res.Error != nil { utils.Error("❌ Failed to create ivfflat index on context_embedding", "error", res.Error) } else { utils.Info("✅ ivfflat index on context_embedding ready") } // The tripsheets.status CHECK constraint predates this codebase (not derived // from any gorm tag) and only allowed Draft/Dispatched/Arrived/Cancelled. // Hub batches need an intermediate "Ready" stage before dispatch. db.Exec(`ALTER TABLE tripsheets DROP CONSTRAINT IF EXISTS tripsheets_status_check`) if res := db.Exec(`ALTER TABLE tripsheets ADD CONSTRAINT tripsheets_status_check CHECK (status::text = ANY (ARRAY['Draft','Ready','Dispatched','Arrived','Cancelled']::text[]))`); res.Error != nil { utils.Error("❌ Failed to widen tripsheets_status_check constraint", "error", res.Error) } else { utils.Info("✅ tripsheets_status_check constraint includes Ready") } // The consignments.status CHECK constraint also predates this codebase (not // derived from a gorm tag) and was missing two values the code actually // writes: Collected_By_Miler — the hyperlocal two-step intermediate state, // which a pickup-complete writes when MILER_COLLECTED_STATE_ENABLED is on — // and Cancelled, which the admin console sets via PUT /consignments/:id/status. // Without them the INSERT/UPDATE fails with a 23514 check violation. Widen it // to the full set of constants.Consignment* statuses plus Cancelled. db.Exec(`ALTER TABLE consignments DROP CONSTRAINT IF EXISTS consignments_status_check`) if res := db.Exec(`ALTER TABLE consignments ADD CONSTRAINT consignments_status_check CHECK (status::text = ANY (ARRAY['Created','Inwarded_at_Hub','Collected_By_Miler','Tripsheet_Loaded','In_Transit','Out_for_Delivery','Delivered','RTO_Initiated','Returned_to_Sender','Missing','Damaged','Cancelled']::text[]))`); res.Error != nil { utils.Error("❌ Failed to widen consignments_status_check constraint", "error", res.Error) } else { utils.Info("✅ consignments_status_check constraint includes Collected_By_Miler") } // Human-facing identifiers for the customer app: DM-###### for a pickup // booking, DMX######## for an order. Sequence-backed rather than random, // because both columns are UNIQUE and a random short id collides long // before a short id runs out — a collision here is a failed booking at the // moment of payment, not a retry. // // No CYCLE and no MAXVALUE on purpose: past 999999 the format simply grows // a digit (DM-1000000) instead of wrapping onto an id that already exists. // Existing rows keep their old DM-BK-/DM-TRK- strings; nothing parses // either format, so the two coexist safely. if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_booking_reference_seq START 100000 INCREMENT 1`); res.Error != nil { utils.Error("❌ Failed to create cx_booking_reference_seq", "error", res.Error) } else { utils.Info("✅ cx_booking_reference_seq ready") } if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_tracking_seq START 10000000 INCREMENT 1`); res.Error != nil { utils.Error("❌ Failed to create cx_tracking_seq", "error", res.Error) } else { utils.Info("✅ cx_tracking_seq ready") } // One booking must not hold two destinations at the same position: the // customer addresses a destination by index in // PATCH /customer/bookings/{ref}/destinations/{index}, so a duplicate seq // makes that route ambiguous and would let an edit land on the wrong // address. if res := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bookingdestinations_booking_seq ON bookingdestinations (bookingid, seq)`); res.Error != nil { utils.Error("❌ Failed to create bookingdestinations (bookingid, seq) unique index", "error", res.Error) } else { utils.Info("✅ bookingdestinations (bookingid, seq) unique index ready") } // The timeline is read as "every event for this booking, oldest first" on // every tracking poll, which is the hottest customer read there is. if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookingstageevents_booking_time ON bookingstageevents (bookingid, occurredat)`); res.Error != nil { utils.Error("❌ Failed to create bookingstageevents (bookingid, occurredat) index", "error", res.Error) } else { utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready") } return nil }