From 707323b68ce7c13a7ae6f808a03349d25f48b43d Mon Sep 17 00:00:00 2001 From: Suriya Date: Sat, 4 Jul 2026 16:14:12 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20hub=20console=20backend=20=E2=80=94=20c?= =?UTF-8?q?omplete=20API=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + config/config.go | 10 + controllers/adminController.go | 42 +- controllers/booking_assignment_service.go | 84 ++ controllers/hubController.go | 929 +++++++++++++++++++++- controllers/otpController.go | 105 +++ dto/auth.go | 10 + internal/assignment/crm_assignment.go | 73 ++ internal/mail/mail.go | 70 ++ routes/routes.go | 14 + 10 files changed, 1302 insertions(+), 42 deletions(-) create mode 100644 controllers/booking_assignment_service.go create mode 100644 controllers/otpController.go create mode 100644 internal/mail/mail.go diff --git a/.env.example b/.env.example index aac0e16..9ca62dc 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,13 @@ REDIS_PORT=6379 REDIS_USER=admin REDIS_PASSWORD=Package@321# +# SMTP Configuration (email OTP verification) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-16-char-app-password +SMTP_FROM=your-email@gmail.com + $env:PATH += ";C:\Program Files\Docker\Docker\resources\bin" >> diff --git a/config/config.go b/config/config.go index 3b07185..118a907 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,11 @@ type Config struct { NatsUser string NatsPassword string AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082) + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPassword string + SMTPFrom string } func Load() *Config { @@ -41,6 +46,11 @@ func Load() *Config { NatsUser: getEnv("NATS_USER", "doormile"), NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"), AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnv("SMTP_PORT", "465"), + SMTPUser: getEnv("SMTP_USER", ""), + SMTPPassword: getEnv("SMTP_PASSWORD", ""), + SMTPFrom: getEnv("SMTP_FROM", ""), } } diff --git a/controllers/adminController.go b/controllers/adminController.go index 03ebd2a..05c754f 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -1144,47 +1144,11 @@ func AdminAssignMiler(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } - tx := db.DB.Begin() - - var booking models.PickupBooking - if err := tx.First(&booking, id).Error; err != nil { - tx.Rollback() - return utils.NotFound(c, "booking not found") - } - - booking.Status = constants.BookingMilerAssigned - booking.Assignedmileruserid = &req.Mileruserid - booking.Updatedat = time.Now() - tx.Save(&booking) - adminUserID := c.Locals("userid").(int) - assignment := models.BookingAssignment{ - Bookingid: booking.Bookingid, - Mileruserid: req.Mileruserid, - Assignedbyuserid: &adminUserID, - Assignmentstatus: constants.AssignmentAssigned, - } - tx.Create(&assignment) - - // Update Miler status - tx.Model(&models.MilerProfile{}).Where("userid = ?", req.Mileruserid).Update("availabilitystatus", constants.MilerAssigned) - - tx.Commit() - - if db.Js != nil { - payload := map[string]interface{}{ - "booking_id": booking.Bookingid, - "booking_no": booking.Bookingno, - "status": booking.Status, - "miler_id": req.Mileruserid, - "updated_at": time.Now().UnixMilli(), - } - if data, err := json.Marshal(payload); err == nil { - if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil { - utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err) - } - } + booking, err := AssignMilerToBooking(id, req.Mileruserid, &adminUserID) + if err != nil { + return utils.NotFound(c, "booking not found") } return utils.OK(c, booking) diff --git a/controllers/booking_assignment_service.go b/controllers/booking_assignment_service.go new file mode 100644 index 0000000..e60616f --- /dev/null +++ b/controllers/booking_assignment_service.go @@ -0,0 +1,84 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "time" + + "doormile/constants" + "doormile/db" + "doormile/internal/notify" + "doormile/models" + "doormile/utils" +) + +// AssignMilerToBooking is the single source of truth for manually assigning a +// miler to a pickup booking — shared by the admin console (AdminAssignMiler) +// and the hub console (HubAssignMiler) so both go through identical DB +// updates, NATS publish, and FCM notify instead of duplicating the logic. +func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*models.PickupBooking, error) { + tx := db.DB.Begin() + + var booking models.PickupBooking + if err := tx.First(&booking, bookingID).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("booking not found") + } + + booking.Status = constants.BookingMilerAssigned + booking.Assignedmileruserid = &milerUserID + booking.Updatedat = time.Now() + if err := tx.Save(&booking).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to update booking: %w", err) + } + + assignment := models.BookingAssignment{ + Bookingid: booking.Bookingid, + Mileruserid: milerUserID, + Assignedbyuserid: assignedByUserID, + Assignmentstatus: constants.AssignmentAssigned, + } + if err := tx.Create(&assignment).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to create assignment: %w", err) + } + + if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + Update("availabilitystatus", constants.MilerAssigned).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to update miler availability: %w", err) + } + + tx.Commit() + + if db.Js != nil { + payload := map[string]interface{}{ + "booking_id": booking.Bookingid, + "booking_no": booking.Bookingno, + "status": booking.Status, + "miler_id": milerUserID, + "updated_at": time.Now().UnixMilli(), + } + if data, err := json.Marshal(payload); err == nil { + if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil { + utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err) + } + } + } + + var miler models.MilerProfile + if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" { + if err := notify.SendToDevice( + miler.Devicetoken, + "New Pickup Assigned", + "New booking assigned — tap to view details", + map[string]string{"booking_id": fmt.Sprintf("%d", booking.Bookingid)}, + ); err != nil { + utils.Warn("FCM: failed to notify miler on manual assignment", + "miler_id", milerUserID, "booking_id", booking.Bookingid, "error", err) + } + } + + return &booking, nil +} diff --git a/controllers/hubController.go b/controllers/hubController.go index 091ba91..c46ae6e 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -2,6 +2,8 @@ package controllers import ( "fmt" + "math" + "sort" "strconv" "strings" "time" @@ -10,12 +12,87 @@ import ( "doormile/constants" "doormile/db" "doormile/dto" + "doormile/internal/assignment" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) +// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for +// TryAssignOnce (GEOSEARCH + AI decision + commit) before returning 202 and +// letting the assignment finish in the background. +const hubAutoAssignTimeout = 8 * time.Second + +// assignableBookingStatuses gates both HubAssignMiler and HubAutoAssign. +// Pending_Assignment / Assignment_Failed aren't statuses this codebase +// currently sets anywhere (only Pending_Pickup is real today) but are kept +// here so the gate doesn't need touching if those states get introduced later. +var assignableBookingStatuses = map[string]bool{ + constants.BookingPendingPickup: true, + "Pending_Assignment": true, + "Assignment_Failed": true, +} + +// defaultJourneyMinutes is the assumed total truck transit time until real +// GPS telemetry (EMQX) is wired up; used to interpolate in-transit position. +const defaultJourneyMinutes = 480 // 8 hours + +// zoneNames maps a handful of known pincodes in our 4 operating cities to +// human-readable locality names. Unmapped pincodes fall back to "Zone ". +var zoneNames = map[string]string{ + "641001": "Gandhipuram", + "641002": "RS Puram", + "641004": "Peelamedu", + "641012": "Jupiter Nagar", + "641035": "Saravanampatti", + "500003": "Secunderabad", + "500032": "Financial District", + "500034": "Banjara Hills", + "500081": "Gachibowli", + "560034": "Koramangala", + "560038": "Indiranagar", + "560066": "Whitefield", + "560100": "Electronic City", + "600017": "T Nagar", + "600032": "Guindy", + "600040": "Anna Nagar", + "600042": "Velachery", +} + +func zoneName(pincode string) string { + if name, ok := zoneNames[pincode]; ok { + return name + } + return "Zone " + pincode +} + +// haversineKM returns the great-circle distance between two lat/lon points in km. +func haversineKM(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusKM = 6371.0 + toRad := func(deg float64) float64 { return deg * math.Pi / 180 } + dLat := toRad(lat2 - lat1) + dLon := toRad(lon2 - lon1) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) + return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago". +func humanizeRelativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%d min ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%d hrs ago", int(d.Hours())) + default: + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + } +} + // todayMidnight returns the start of the current day in server local time, // used to scope "today" counters on the hub dashboard. func todayMidnight() time.Time { @@ -196,7 +273,37 @@ func GetHubInboundToday(c *fiber.Ctx) error { return utils.Internal(c, "failed to fetch inbound consignments") } - return utils.List(c, consignments, int64(len(consignments))) + response := make([]fiber.Map, 0, len(consignments)) + for _, cs := range consignments { + originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode) + if cs.Originhubid != nil { + var oh models.Hub + if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil { + originName = oh.Hubname + } + } + + response = append(response, fiber.Map{ + "consignmentid": cs.Consignmentid, + "trackingno": cs.Trackingno, + // sendername is empty: consignments carry no FK back to any + // customer/booking record (Senderid/Receiverid are never + // populated anywhere in this codebase). + "sendername": "", + "originname": originName, + // destinationname: consignments store no free-text delivery + // address, only deliverypincode — used as the best available label. + "destinationname": cs.Deliverypincode, + "condition": cs.Condition, + "shelf": cs.Shelf, + // temperature: no column exists anywhere yet for this. + "temperature": "N/A", + "status": cs.Status, + "updatedat": cs.Updatedat, + }) + } + + return utils.List(c, response, int64(len(response))) } func CreateInboundScan(c *fiber.Ctx) error { @@ -433,15 +540,100 @@ func UpdateBatchStatus(c *fiber.Ctx) error { } // GetHubMilers is the hub-scoped equivalent of admin's GetMilers, filtered to -// only the milers assigned to the requesting hub staff's own hub. +// only the milers assigned to the requesting hub staff's own hub, enriched +// with computed operational stats per miler. func GetHubMilers(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) + midnight := todayMidnight() + weekAgo := time.Now().AddDate(0, 0, -7) var profiles []models.MilerProfile if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil { return utils.Internal(c, "failed to fetch milers") } - return utils.List(c, profiles, int64(len(profiles))) + + response := make([]fiber.Map, 0, len(profiles)) + for _, mp := range profiles { + var zones []string + db.DB.Model(&models.BookingAssignment{}). + Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid"). + Where("bookingassignments.mileruserid = ? AND bookingassignments.assignedat >= ?", mp.Userid, weekAgo). + Distinct("pb.pickuppincode"). + Pluck("pb.pickuppincode", &zones) + if zones == nil { + zones = []string{} + } + + // assignmentstatus (BookingAssignment's real status column) only has + // Assigned/Accepted/Rejected/Reassigned/Completed/Cancelled — treated + // Assigned+Accepted as "still active load". + var assignedLoad int64 + db.DB.Model(&models.BookingAssignment{}). + Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). + Count(&assignedLoad) + + // Pending_Pickup lives on pickupbookings.status, not on the + // assignment itself, so this counts via a join. + var pickupsPending int64 + db.DB.Model(&models.BookingAssignment{}). + Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid"). + Where("bookingassignments.mileruserid = ? AND pb.status = ?", mp.Userid, constants.BookingPendingPickup). + Count(&pickupsPending) + + // bookingpayments has no mileruserid column; collectedbyuserid is the + // app user id of whoever collected it, which for miler collections is + // the miler's own userid. + var codCollected float64 + db.DB.Model(&models.BookingPayment{}). + Where("collectedbyuserid = ? AND paymentstatus = ? AND createdat >= ?", mp.Userid, constants.PaymentStatusPaid, midnight). + Select("COALESCE(SUM(amount), 0)").Scan(&codCollected) + + var codPending float64 + db.DB.Model(&models.BookingPayment{}). + Where("collectedbyuserid = ? AND paymentstatus = ?", mp.Userid, constants.PaymentStatusPending). + Select("COALESCE(SUM(amount), 0)").Scan(&codPending) + + var firstAssignment models.BookingAssignment + var checkinAt *time.Time + if db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight). + Order("assignedat ASC").First(&firstAssignment).Error == nil { + checkinAt = &firstAssignment.Assignedat + } + + hoursActive := 0.0 + if checkinAt != nil { + hoursActive = time.Since(*checkinAt).Hours() + } + + response = append(response, fiber.Map{ + "milerprofileid": mp.Milerprofileid, + "userid": mp.Userid, + "displayname": mp.Displayname, + "phone": mp.Phone, + "vehicleid": mp.Vehicleid, + "hubid": mp.Hubid, + "defaultvehicletype": mp.Defaultvehicletype, + "currentlatitude": mp.Currentlatitude, + "currentlongitude": mp.Currentlongitude, + "currentpincode": mp.Currentpincode, + "availabilitystatus": mp.Availabilitystatus, + "rating": mp.Rating, + "totalcompletedpickups": mp.Totalcompletedpickups, + "totalcancelledpickups": mp.Totalcancelledpickups, + "zones": zones, + "assignedload": assignedLoad, + "capacity": 30, // hardcoded until vehicle capacity is modelled + "pickupspending": pickupsPending, + "codcollected": codCollected, + "codpending": codPending, + "checkinat": checkinAt, + "hoursactive": hoursActive, + "isverified": mp.Devicetoken != "", + }) + } + + return utils.List(c, response, int64(len(response))) } // -------------------- @@ -537,6 +729,8 @@ func GetHubsInCity(c *fiber.Ctx) error { "pincode": h.Pincode, "status": h.Status, "capacity": h.Capacity, + "lat": h.Latitude, + "lon": h.Longitude, "has_staff": staffCount > 0, }) } @@ -591,3 +785,732 @@ func CreateCityHub(c *fiber.Ctx) error { return utils.Created(c, hub) } + +// -------------------- +// IN-TRANSIT TRACKING & INBOUND VEHICLES +// -------------------- + +// GetInTransitTripsheets returns tripsheets currently moving to/from this hub, +// with a linearly-interpolated position along the source→dest line since we +// don't have real truck GPS yet. Swap in real coordinates later — shape stays. +func GetInTransitTripsheets(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + var tripsheets []models.Tripsheet + if err := db.DB.Where("(sourcehubid = ? OR destinationhubid = ?) AND status IN ? AND deletedat IS NULL", + hubID, hubID, []string{constants.TripsheetDispatched, "In_Transit"}). + Order("dispatchtime DESC").Find(&tripsheets).Error; err != nil { + return utils.Internal(c, "failed to fetch in-transit tripsheets") + } + + response := make([]fiber.Map, 0, len(tripsheets)) + for _, ts := range tripsheets { + var source, dest models.Hub + db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source) + db.DB.Where("hubid = ?", ts.Destinationhubid).First(&dest) + + var vehicleNo string + if ts.Vehicleid != nil { + var v models.Vehicle + if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil { + vehicleNo = v.Vehicleno + } + } + + var itemCount int64 + db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount) + + elapsedMinutes := 0.0 + if ts.Dispatchtime != nil { + elapsedMinutes = time.Since(*ts.Dispatchtime).Minutes() + } + progressPct := int(math.Min(95, (elapsedMinutes/defaultJourneyMinutes)*100)) + if progressPct < 0 { + progressPct = 0 + } + etaMinutes := int(defaultJourneyMinutes - elapsedMinutes) + if etaMinutes < 0 { + etaMinutes = 0 + } + + frac := float64(progressPct) / 100.0 + currentLat := source.Latitude + (dest.Latitude-source.Latitude)*frac + currentLon := source.Longitude + (dest.Longitude-source.Longitude)*frac + + response = append(response, fiber.Map{ + "tripsheetid": ts.Tripsheetid, + "tripsheetno": ts.Tripsheetno, + "label": fmt.Sprintf("%s → %s", source.Hubname, dest.Hubname), + "originlat": source.Latitude, + "originlon": source.Longitude, + "destlat": dest.Latitude, + "destlon": dest.Longitude, + "currentlat": currentLat, + "currentlon": currentLon, + "status": ts.Status, + "progresspct": progressPct, + "vehicleno": vehicleNo, + "itemcount": itemCount, + "dispatchtime": ts.Dispatchtime, + "eta_minutes": etaMinutes, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +// GetInboundVehicles returns vehicles arriving at or expected at this hub. +func GetInboundVehicles(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + var tripsheets []models.Tripsheet + if err := db.DB.Where("destinationhubid = ? AND status IN ? AND deletedat IS NULL", + hubID, []string{constants.TripsheetDispatched, "In_Transit", constants.TripsheetArrived}). + Order("dispatchtime DESC").Limit(20).Find(&tripsheets).Error; err != nil { + return utils.Internal(c, "failed to fetch inbound vehicles") + } + + response := make([]fiber.Map, 0, len(tripsheets)) + for _, ts := range tripsheets { + var source models.Hub + db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source) + + var vehicleNo, vehicleType string + if ts.Vehicleid != nil { + var v models.Vehicle + if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil { + vehicleNo = v.Vehicleno + vehicleType = v.Vehicletype + } + } + + var totalItems, unloadedItems int64 + db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&totalItems) + + status := "On the way" + unloadedPct := 0 + if ts.Status == constants.TripsheetArrived { + status = "Unloading" + db.DB.Model(&models.TripsheetItem{}). + Where("tripsheetid = ? AND scanstatus = ?", ts.Tripsheetid, constants.ScanUnloaded). + Count(&unloadedItems) + if totalItems > 0 { + unloadedPct = int(float64(unloadedItems) / float64(totalItems) * 100) + } + } + + eta := "Arrived" + if ts.Status != constants.TripsheetArrived && ts.Dispatchtime != nil { + remaining := defaultJourneyMinutes - int(time.Since(*ts.Dispatchtime).Minutes()) + if remaining < 0 { + remaining = 0 + } + eta = fmt.Sprintf("%d hrs %d min", remaining/60, remaining%60) + } + + response = append(response, fiber.Map{ + "vehicleno": vehicleNo, + "vehicletype": vehicleType, + "origin": source.Hubname, + "tripsheetid": ts.Tripsheetid, + "status": status, + "eta": eta, + "unloadedpct": unloadedPct, + "itemcount": totalItems, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +// -------------------- +// HUB ACTIVITY FEED +// -------------------- + +type activityEntry struct { + Time time.Time `gorm:"column:time"` + Type string `gorm:"column:type"` + Text string `gorm:"column:text"` +} + +// GetHubActivity aggregates real events from 4 sources (inbound scans, +// dispatches, exceptions, miler assignments) into one timeline. +func GetHubActivity(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + limit, err := strconv.Atoi(c.Query("limit", "10")) + if err != nil || limit <= 0 { + limit = 10 + } + midnight := todayMidnight() + + // Source 4 (miler assignments) is scoped by mp.hubid via a join — the + // ticket's version had no hub filter at all, which would leak every + // hub's assignment activity into every other hub's feed. + query := ` + (SELECT c.updatedat AS time, 'inbound' AS type, + ('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text + FROM consignments c + LEFT JOIN hubs oh ON c.originhubid = oh.hubid + WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat > ?) + UNION ALL + (SELECT t.dispatchtime AS time, 'dispatch' AS type, + ('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text + FROM tripsheets t + WHERE t.sourcehubid = ? AND t.dispatchtime > ?) + UNION ALL + (SELECT ce.createdat AS time, 'exception' AS type, + ('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text + FROM consignmentexceptions ce + JOIN consignments c ON ce.consignmentid = c.consignmentid + WHERE ce.hubid = ? AND ce.createdat > ?) + UNION ALL + (SELECT ba.assignedat AS time, 'sorting' AS type, + ('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text + FROM bookingassignments ba + JOIN milerprofiles mp ON ba.mileruserid = mp.userid + WHERE mp.hubid = ? AND ba.assignedat > ?) + ORDER BY time DESC + LIMIT ? + ` + + var entries []activityEntry + if err := db.DB.Raw(query, + hubID, constants.ConsignmentInwardedAtHub, midnight, + hubID, midnight, + hubID, midnight, + hubID, midnight, + limit, + ).Scan(&entries).Error; err != nil { + return utils.Internal(c, "failed to fetch hub activity") + } + + response := make([]fiber.Map, 0, len(entries)) + for _, e := range entries { + response = append(response, fiber.Map{ + "time": e.Time, + "type": e.Type, + "text": e.Text, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +// -------------------- +// DELIVERY ZONES +// -------------------- + +// GetHubZones returns delivery zone breakdown for this hub's milers, falling +// back to static known zones for the hub's city when there's too little +// assignment data yet (new hub, few bookings). +func GetHubZones(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + type zoneRow struct { + Zone string `gorm:"column:zone"` + Milers int64 `gorm:"column:milers"` + Parcels int64 `gorm:"column:parcels"` + Status string `gorm:"column:status"` + } + + // ba.pickuppincode / ba.status don't exist on bookingassignments — + // pincode lives on pickupbookings (joined here) and the real status + // column is assignmentstatus. + query := ` + SELECT pb.pickuppincode AS zone, + COUNT(DISTINCT ba.mileruserid) AS milers, + COUNT(ba.bookingassignmentid) AS parcels, + CASE WHEN COUNT(DISTINCT ba.mileruserid) = 0 THEN 'Need Milers' ELSE 'Active' END AS status + FROM bookingassignments ba + JOIN milerprofiles mp ON ba.mileruserid = mp.userid + JOIN pickupbookings pb ON pb.bookingid = ba.bookingid + WHERE mp.hubid = ? AND ba.assignmentstatus IN ? + GROUP BY pb.pickuppincode + ORDER BY parcels DESC + LIMIT 10 + ` + + var rows []zoneRow + db.DB.Raw(query, hubID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).Scan(&rows) + + seen := make(map[string]bool) + response := make([]fiber.Map, 0, len(rows)) + for _, r := range rows { + seen[r.Zone] = true + response = append(response, fiber.Map{ + "zone": r.Zone, + "zonename": zoneName(r.Zone), + "parcels": r.Parcels, + "milers": r.Milers, + "status": r.Status, + }) + } + + if len(response) < 3 { + prefix := hubPincodePrefix(hubID) + for pincode := range zoneNames { + if strings.HasPrefix(pincode, prefix) && !seen[pincode] { + response = append(response, fiber.Map{ + "zone": pincode, + "zonename": zoneName(pincode), + "parcels": 0, + "milers": 0, + "status": "Need Milers", + }) + } + } + } + + return utils.List(c, response, int64(len(response))) +} + +// -------------------- +// NOTIFICATIONS (synthetic — no dedicated table yet) +// -------------------- + +type notificationEntry struct { + Title string + Type string + Time time.Time +} + +// GetHubNotifications generates notifications from real events across 4 +// sources since there's no dedicated notifications table yet. +func GetHubNotifications(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + midnight := todayMidnight() + last24h := time.Now().Add(-24 * time.Hour) + + var notifications []notificationEntry + + var exceptions []models.ConsignmentException + db.DB.Where("hubid = ? AND createdat > ?", hubID, last24h).Find(&exceptions) + for _, e := range exceptions { + notifications = append(notifications, notificationEntry{ + Title: "Exception: " + e.Exceptiontype, + Type: "exception", + Time: e.Createdat, + }) + } + + var inbound []models.Tripsheet + db.DB.Where("destinationhubid = ? AND status IN ?", hubID, []string{constants.TripsheetDispatched, "In_Transit"}).Find(&inbound) + for _, ts := range inbound { + var source models.Hub + db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source) + t := ts.Updatedat + if ts.Dispatchtime != nil { + t = *ts.Dispatchtime + } + notifications = append(notifications, notificationEntry{ + Title: "Truck arriving from " + source.Hubname, + Type: "inbound", + Time: t, + }) + } + + var offlineMilers []models.MilerProfile + db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerOffline).Find(&offlineMilers) + for _, mp := range offlineMilers { + var hadActivityToday int64 + db.DB.Model(&models.BookingAssignment{}).Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).Count(&hadActivityToday) + if hadActivityToday > 0 { + notifications = append(notifications, notificationEntry{ + Title: "Miler " + mp.Displayname + " went offline", + Type: "warning", + Time: mp.Updatedat, + }) + } + } + + prefix := hubPincodePrefix(hubID) + thirtyMinAgo := time.Now().Add(-30 * time.Minute) + var stalePending []models.PickupBooking + staleQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ? AND createdat < ?", constants.BookingPendingPickup, thirtyMinAgo) + if prefix != "" { + staleQuery = staleQuery.Where("pickuppincode LIKE ?", prefix+"%") + } + staleQuery.Find(&stalePending) + for _, b := range stalePending { + notifications = append(notifications, notificationEntry{ + Title: "Pickup waiting 30+ min", + Type: "alert", + Time: b.Createdat, + }) + } + + sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) }) + if len(notifications) > 20 { + notifications = notifications[:20] + } + + response := make([]fiber.Map, 0, len(notifications)) + for i, n := range notifications { + response = append(response, fiber.Map{ + "id": i + 1, + "title": n.Title, + "type": n.Type, + "time": humanizeRelativeTime(n.Time), + "read": false, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +// MarkNotificationRead is a stub until a notifications table with read-state +// exists — always succeeds without persisting anything. +func MarkNotificationRead(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"success": true}) +} + +// -------------------- +// PARCEL ROUTING (SCAN) +// -------------------- + +// resolveNextHop compares the delivery pincode's city prefix against the +// requesting hub's own city prefix to decide local delivery vs. transfer. +func resolveNextHop(destPrefix, myPrefix string) string { + if destPrefix == "" || destPrefix == myPrefix { + return "Local delivery" + } + var hub models.Hub + if db.DB.Where("pincode LIKE ?", destPrefix+"%").First(&hub).Error == nil { + return "Transfer to " + hub.Hubname + } + return "Transfer to Regional Hub" +} + +func recommendShelfForRouting(condition string, isColdChain bool, nextHop string) string { + switch { + case strings.Contains(condition, "Damaged"): + return "Exception Area" + case isColdChain: + return "Zone C (Cold Room)" + case nextHop == "Local delivery": + return "Zone B" + default: + return "Zone A" + } +} + +// GetRoutingInfo scans a parcel and returns its sort destination, checking +// both consignments.trackingno and pickupbookings.bookingno (a booking not +// yet converted to a consignment has no trackingno of its own). +func GetRoutingInfo(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + trackingNo := c.Params("trackingno") + myPrefix := hubPincodePrefix(hubID) + + var consignment models.Consignment + if db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error == nil { + destPrefix := "" + if len(consignment.Deliverypincode) >= 3 { + destPrefix = consignment.Deliverypincode[:3] + } + nextHop := resolveNextHop(destPrefix, myPrefix) + + shelf := consignment.Shelf + if shelf == "" { + shelf = recommendShelfForRouting(consignment.Condition, false, nextHop) + } + + return utils.OK(c, fiber.Map{ + "trackingno": consignment.Trackingno, + "destination": consignment.Deliverypincode, + "recommendedshelf": shelf, + "nexthop": nextHop, + "condition": consignment.Condition, + "iscoldchain": false, + "weight": fmt.Sprintf("%.1f kg", consignment.Chargeableweight), + // no FK from consignments back to a customer/booking record + "customername": "", + "bookingid": nil, + }) + } + + var booking models.PickupBooking + found := db.DB.Where("bookingno = ?", trackingNo).First(&booking).Error == nil + if !found { + if id, err := strconv.Atoi(trackingNo); err == nil { + found = db.DB.Where("bookingid = ?", id).First(&booking).Error == nil + } + } + if !found { + return utils.NotFound(c, "tracking number not found") + } + + destPrefix := "" + if len(booking.Deliverypincode) >= 3 { + destPrefix = booking.Deliverypincode[:3] + } + nextHop := resolveNextHop(destPrefix, myPrefix) + shelf := recommendShelfForRouting("", false, nextHop) + + var customer models.AppCustomer + db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer) + customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname) + + weight := "N/A" + var parcel models.BookingParcel + if db.DB.Where("bookingid = ?", booking.Bookingid).First(&parcel).Error == nil { + weight = fmt.Sprintf("%.1f kg", parcel.Weight) + } + + return utils.OK(c, fiber.Map{ + "trackingno": booking.Bookingno, + "destination": fmt.Sprintf("%s, %s", booking.Deliveryaddress, booking.Deliverypincode), + "recommendedshelf": shelf, + "nexthop": nextHop, + "condition": "Pending Scan", + "iscoldchain": false, + "weight": weight, + "customername": customerName, + "bookingid": booking.Bookingid, + }) +} + +// -------------------- +// RIDER ROUTES +// -------------------- + +// buildMilerRoute computes today's planned stops for one miler. eta_minutes +// for pending stops is a simple 15-min-per-stop placeholder until a real +// routing engine is wired up — same spirit as the in-transit interpolation. +func buildMilerRoute(mp models.MilerProfile) fiber.Map { + var assignments []models.BookingAssignment + db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, todayMidnight()). + Order("assignedat ASC").Find(&assignments) + + stops := make([]fiber.Map, 0, len(assignments)) + var coords [][2]float64 + completedCount := 0 + pendingEtaMinutes := 0 + + for i, a := range assignments { + var booking models.PickupBooking + if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil { + continue + } + + itemCount := 0 + var parcel models.BookingParcel + if db.DB.Where("bookingid = ?", a.Bookingid).First(&parcel).Error == nil { + itemCount = 1 + } + + status := "pending" + switch booking.Status { + case constants.BookingPickedUp, constants.BookingConvertedConsignment: + status = "completed" + case constants.BookingPickupScheduled: + status = "in_progress" + } + + etaMinutes := 0 + if status == "completed" { + completedCount++ + } else { + pendingEtaMinutes += 15 + etaMinutes = pendingEtaMinutes + } + + coords = append(coords, [2]float64{booking.Pickuplatitude, booking.Pickuplongitude}) + + stops = append(stops, fiber.Map{ + "seq": i + 1, + "address": booking.Pickupaddress, + "lat": booking.Pickuplatitude, + "lon": booking.Pickuplongitude, + "items": itemCount, + "bookingid": booking.Bookingid, + "status": status, + "eta_minutes": etaMinutes, + }) + } + + totalDistance := 0.0 + for i := 1; i < len(coords); i++ { + totalDistance += haversineKM(coords[i-1][0], coords[i-1][1], coords[i][0], coords[i][1]) + } + + return fiber.Map{ + "mileruserid": mp.Userid, + "milername": mp.Displayname, + "mode": "pickup", + "totalstops": len(stops), + "completedstops": completedCount, + "totaldistance_km": math.Round(totalDistance*10) / 10, + "stops": stops, + } +} + +// GetMilerRoute returns today's planned stops for a specific miler (:id is +// the mileruserid, matching bookingassignments.mileruserid). +func GetMilerRoute(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.BadRequest(c, "invalid miler id") + } + + var mp models.MilerProfile + if err := db.DB.Where("userid = ?", id).First(&mp).Error; err != nil { + return utils.NotFound(c, "miler not found") + } + + return utils.OK(c, buildMilerRoute(mp)) +} + +// GetAllRiderRoutes returns the same route structure for every miler at this +// hub today, for the Rider Routes overview page. +func GetAllRiderRoutes(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + var profiles []models.MilerProfile + if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil { + return utils.Internal(c, "failed to fetch milers") + } + + routes := make([]fiber.Map, 0, len(profiles)) + for _, mp := range profiles { + routes = append(routes, buildMilerRoute(mp)) + } + + return utils.List(c, routes, int64(len(routes))) +} + +// -------------------- +// HUB-SCOPED ASSIGNMENT (manual override + auto-assign) +// -------------------- + +// HubAssignMiler is the hub console's manual assignment override — hub staff +// (role 6) can't call POST /admin/bookings/:id/assign-miler (admin-only) or +// rely on POST /internal/bookings/:id/reassign (requires an already-assigned +// booking), so this exists for a hub-scoped path onto the same shared +// AssignMilerToBooking logic AdminAssignMiler uses. +func HubAssignMiler(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + bookingID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.BadRequest(c, "invalid booking id") + } + + type AssignRequest struct { + Mileruserid int `json:"mileruserid"` + } + req := new(AssignRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Mileruserid == 0 { + return utils.BadRequest(c, "mileruserid is required") + } + + var booking models.PickupBooking + if err := db.DB.First(&booking, bookingID).Error; err != nil { + return utils.NotFound(c, "booking not found") + } + + if !assignableBookingStatuses[booking.Status] { + return utils.BadRequest(c, "booking is not in an assignable state") + } + + var miler models.MilerProfile + if err := db.DB.Where("userid = ? AND hubid = ?", req.Mileruserid, hubID).First(&miler).Error; err != nil { + return utils.Forbidden(c, "miler does not belong to this hub") + } + + // assignedbyuserid carries a real FK to appusers(userid); hub staff + // accounts live in a separate hubstaffaccounts id space, so passing that + // id here would violate the constraint (or worse, silently misattribute + // to whatever appuser happens to share that id). Pass nil instead — the + // field is nullable and this is the honest option until hub staff get a + // mirrored appusers row. + updatedBooking, err := AssignMilerToBooking(bookingID, req.Mileruserid, nil) + if err != nil { + return utils.Internal(c, "failed to assign miler") + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "miler assigned", + "data": fiber.Map{ + "bookingid": updatedBooking.Bookingid, + "mileruserid": req.Mileruserid, + "milername": miler.Displayname, + "status": updatedBooking.Status, + }, + }) +} + +// HubAutoAssign triggers the AI assignment engine for one booking that wasn't +// automatically assigned at creation time. It runs TryAssignOnce (a single +// GEOSEARCH + AI-decision + commit attempt, no retry loop) in a goroutine and +// waits up to hubAutoAssignTimeout — long enough for the typical sub-second +// Redis GEO query plus the AI layer's own 5s HTTP timeout, but bounded so the +// HTTP handler never hangs indefinitely. If it doesn't finish in time, the +// goroutine keeps running in the background and the assignment still lands. +func HubAutoAssign(c *fiber.Ctx) error { + bookingID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.BadRequest(c, "invalid booking id") + } + + var booking models.PickupBooking + if err := db.DB.First(&booking, bookingID).Error; err != nil { + return utils.NotFound(c, "booking not found") + } + + if !assignableBookingStatuses[booking.Status] { + return utils.BadRequest(c, "booking is not in an assignable state") + } + + type outcome struct { + res assignment.AutoAssignResult + err error + } + resultCh := make(chan outcome, 1) + + go func() { + res, err := assignment.TryAssignOnce(bookingID) + resultCh <- outcome{res, err} + }() + + select { + case out := <-resultCh: + if out.err != nil { + return utils.Internal(c, "assignment failed: "+out.err.Error()) + } + + if !out.res.Assigned { + return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{ + "success": false, + "message": "No eligible miler found in range", + "data": fiber.Map{ + "searched_radius_km": out.res.SearchedRadiusKm, + "candidates_found": out.res.CandidatesFound, + "reasoning": out.res.Reasoning, + }, + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "data": fiber.Map{ + "mileruserid": out.res.MilerUserID, + "milername": out.res.MilerName, + "distance_km": out.res.DistanceKm, + // no confidence field: the AI decision engine's response has + // no such value anywhere in this pipeline to report honestly. + "reasoning": out.res.Reasoning, + }, + }) + + case <-time.After(hubAutoAssignTimeout): + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ + "success": true, + "message": "assignment in progress", + }) + } +} diff --git a/controllers/otpController.go b/controllers/otpController.go new file mode 100644 index 0000000..e658e0e --- /dev/null +++ b/controllers/otpController.go @@ -0,0 +1,105 @@ +package controllers + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "time" + + "doormile/config" + "doormile/db" + "doormile/dto" + "doormile/internal/mail" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" +) + +const ( + otpTTL = 5 * time.Minute + otpMaxAttempts = 5 +) + +func generateOtpCode() string { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return "000000" + } + return fmt.Sprintf("%06d", n.Int64()) +} + +func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) } +func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) } + +func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + req := new(dto.SendEmailOtpRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Email == "" { + return utils.BadRequest(c, "email is required") + } + + if db.Rdb == nil { + return utils.Internal(c, "verification service unavailable") + } + + code := generateOtpCode() + ctx := context.Background() + + if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil { + return utils.Internal(c, "failed to generate verification code") + } + db.Rdb.Del(ctx, otpAttemptsKey(req.Email)) + + if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil { + utils.Warn("failed to send OTP email", "email", req.Email, "error", err) + return utils.Internal(c, "failed to send verification email") + } + + return utils.Message(c, "verification code sent") + } +} + +func VerifyCustomerEmailOtp() fiber.Handler { + return func(c *fiber.Ctx) error { + req := new(dto.VerifyEmailOtpRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Email == "" || req.Otp == "" { + return utils.BadRequest(c, "email and otp are required") + } + + if db.Rdb == nil { + return utils.Internal(c, "verification service unavailable") + } + + ctx := context.Background() + key := otpKey(req.Email) + + stored, err := db.Rdb.Get(ctx, key).Result() + if err == redis.Nil { + return utils.BadRequest(c, "verification code expired or not found, please resend") + } else if err != nil { + return utils.Internal(c, "failed to verify code") + } + + if stored != req.Otp { + attemptsKey := otpAttemptsKey(req.Email) + attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result() + db.Rdb.Expire(ctx, attemptsKey, otpTTL) + if attempts >= otpMaxAttempts { + db.Rdb.Del(ctx, key, attemptsKey) + return utils.BadRequest(c, "too many incorrect attempts, please request a new code") + } + return utils.Unauthorized(c, "incorrect verification code") + } + + db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email)) + return utils.Message(c, "email verified successfully") + } +} diff --git a/dto/auth.go b/dto/auth.go index 6e33ea3..945a167 100644 --- a/dto/auth.go +++ b/dto/auth.go @@ -27,6 +27,16 @@ type CustomerResetPinRequest struct { Configid int `json:"configid"` } +type SendEmailOtpRequest struct { + Email string `json:"email" xml:"email" form:"email"` + Phone string `json:"phone" xml:"phone" form:"phone"` +} + +type VerifyEmailOtpRequest struct { + Email string `json:"email" xml:"email" form:"email"` + Otp string `json:"otp" xml:"otp" form:"otp"` +} + type MilerLoginRequest struct { Phone string `json:"phone" xml:"phone" form:"phone"` Configid int `json:"configid"` diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index 7ed54d8..ac7f761 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -111,6 +111,79 @@ func tryAssign(bookingID int) (bool, error) { return true, nil } +// AutoAssignResult carries enough detail for a synchronous caller (the hub +// console's manual "auto-assign" trigger) to report what happened, since that +// caller can't rely on the fire-and-forget logging AssignCRMMiler normally uses. +type AutoAssignResult struct { + Assigned bool + Escalated bool + MilerUserID int + MilerName string + DistanceKm float64 + Reasoning string + SearchedRadiusKm float64 + CandidatesFound int +} + +// TryAssignOnce performs a single assignment attempt (GEOSEARCH → AI decision +// → commit) with no retry loop, for a booking that wasn't auto-assigned at +// creation time. AssignCRMMiler/AssignCustomerMiler retry over ~10 minutes, +// which is too slow for a hub staff member waiting on a synchronous response; +// this wraps the same single-attempt core (tryAssign) used internally by both. +func TryAssignOnce(bookingID int) (AutoAssignResult, error) { + var booking models.PickupBooking + if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil { + return AutoAssignResult{}, fmt.Errorf("load booking: %w", err) + } + + if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil { + return AutoAssignResult{Assigned: true}, nil + } + + if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 { + return AutoAssignResult{}, fmt.Errorf("booking %d has no pickup coordinates", bookingID) + } + + nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude) + if err != nil { + return AutoAssignResult{Escalated: true, Reasoning: "miler location search unavailable", SearchedRadiusKm: geoRadiusKm}, nil + } + if len(nearby) == 0 { + return AutoAssignResult{Escalated: true, Reasoning: "no milers within search radius", SearchedRadiusKm: geoRadiusKm}, nil + } + + candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby) + if !found { + return AutoAssignResult{ + Escalated: true, + Reasoning: "no eligible miler after evaluation", + SearchedRadiusKm: geoRadiusKm, + CandidatesFound: len(nearby), + }, nil + } + + if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil { + return AutoAssignResult{}, fmt.Errorf("commit: %w", err) + } + + reasoning := "" + if agentDecisionID != nil { + var ad models.AgentDecision + if db.DB.Where("id = ?", *agentDecisionID).First(&ad).Error == nil { + reasoning = ad.Reasoning + } + } + + return AutoAssignResult{ + Assigned: true, + MilerUserID: candidate.profile.Userid, + MilerName: candidate.profile.Displayname, + DistanceKm: candidate.distanceKm, + Reasoning: reasoning, + CandidatesFound: len(nearby), + }, nil +} + // queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount // milers within geoRadiusKm km, sorted nearest-first, with distances populated. func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) { diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..51a208b --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,70 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + + "doormile/config" +) + +func SendOTPEmail(cfg *config.Config, toEmail, code string) error { + subject := "Your Doormile verification code" + body := fmt.Sprintf( + "Your Doormile verification code is %s.\r\n\r\nIt expires in 5 minutes. If you didn't request this, you can ignore this email.", + code, + ) + + msg := []byte(fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", + cfg.SMTPFrom, toEmail, subject, body, + )) + + auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPassword, cfg.SMTPHost) + + // Port 465 is implicit TLS (encrypted from the first byte); everything else + // (587, 25) goes through smtp.SendMail's opportunistic STARTTLS upgrade. + if cfg.SMTPPort == "465" { + return sendImplicitTLS(cfg, auth, toEmail, msg) + } + + addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort) + return smtp.SendMail(addr, auth, cfg.SMTPFrom, []string{toEmail}, msg) +} + +func sendImplicitTLS(cfg *config.Config, auth smtp.Auth, toEmail string, msg []byte) error { + addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort) + + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SMTPHost}) + if err != nil { + return fmt.Errorf("tls dial failed: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, cfg.SMTPHost) + if err != nil { + return fmt.Errorf("smtp client init failed: %w", err) + } + defer client.Close() + + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth failed: %w", err) + } + if err := client.Mail(cfg.SMTPFrom); err != nil { + return err + } + if err := client.Rcpt(toEmail); err != nil { + return err + } + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write(msg); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return client.Quit() +} diff --git a/routes/routes.go b/routes/routes.go index f42cc0e..fcf8d15 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -64,6 +64,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { customer.Post("/login", controllers.LoginCustomer) customer.Post("/verify-pin", controllers.VerifyCustomerPin(cfg)) customer.Post("/reset-pin", controllers.ResetCustomerPin) + customer.Post("/send-email-otp", controllers.SendCustomerEmailOtp(cfg)) + customer.Post("/verify-email-otp", controllers.VerifyCustomerEmailOtp()) // Authenticated Customer App routes customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9)) @@ -249,6 +251,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { hubAuth.Get("/bookings/unassigned", controllers.GetHubUnassignedBookings) hubAuth.Get("/inbound/today", controllers.GetHubInboundToday) hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan) + hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler) + hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign) hubAuth.Get("/batches", controllers.GetHubBatches) hubAuth.Post("/batches", controllers.CreateHubBatch) hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus) @@ -259,6 +263,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { hubAuth.Get("/hubs", controllers.GetHubsInCity) hubAuth.Post("/hubs", controllers.CreateCityHub) + hubAuth.Get("/tripsheets/in-transit", controllers.GetInTransitTripsheets) + hubAuth.Get("/inbound/vehicles", controllers.GetInboundVehicles) + hubAuth.Get("/activity", controllers.GetHubActivity) + hubAuth.Get("/zones", controllers.GetHubZones) + hubAuth.Get("/notifications", controllers.GetHubNotifications) + hubAuth.Patch("/notifications/:id/read", controllers.MarkNotificationRead) + hubAuth.Get("/routing/:trackingno", controllers.GetRoutingInfo) + hubAuth.Get("/rider-routes", controllers.GetAllRiderRoutes) + hubAuth.Get("/milers/:id/route", controllers.GetMilerRoute) + // Redis active user caching utilities redisUsers := api.Group("/utils/users/redis") redisUsers.Post("/", controllers.CreateUserRedis)