Files
doormile_backend/migrations/migrate.go

154 lines
6.5 KiB
Go

package migrations
import (
"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{},
)
if err != nil {
utils.Error("❌ Database migration failed", "error", err)
return err
}
utils.Info("✅ Database migration completed successfully!")
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
}