174 lines
7.2 KiB
Go
174 lines
7.2 KiB
Go
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
|
|
}
|