package controllers import ( "encoding/json" "fmt" "sort" "strconv" "strings" "time" "doormile/constants" "doormile/db" "doormile/internal/notify" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // -------------------- // DUTY MANAGEMENT // -------------------- func MilerStartDuty(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } midnight := todayMidnight() var existing models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight). First(&existing).Error; err == nil { return utils.BadRequest(c, "already on duty, end current duty first") } now := time.Now() dutyLog := models.MilerDutyLog{ Userid: milerUserID, Loginat: now, Onduty: true, Startlat: req.Lat, Startlon: req.Lon, Lastlat: req.Lat, Lastlon: req.Lon, } if err := db.DB.Create(&dutyLog).Error; err != nil { return utils.Internal(c, "failed to start duty") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Updates(map[string]interface{}{ "availabilitystatus": constants.MilerAvailable, "currentlatitude": req.Lat, "currentlongitude": req.Lon, "lastlocationupdatedat": now, }) db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 1) return utils.OK(c, fiber.Map{ "dutylogid": dutyLog.Dutylogid, "loginat": dutyLog.Loginat, }) } func MilerEndDuty(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } var dutyLog models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL", milerUserID, true). Order("loginat DESC").First(&dutyLog).Error; err != nil { return utils.BadRequest(c, "not currently on duty") } var activeAssignments int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Count(&activeAssignments) if activeAssignments > 0 { return utils.BadRequest(c, "cannot go off duty with active assignments") } now := time.Now() dutyLog.Logoutat = &now dutyLog.Onduty = false dutyLog.Lastlat = req.Lat dutyLog.Lastlon = req.Lon if err := db.DB.Save(&dutyLog).Error; err != nil { return utils.Internal(c, "failed to end duty") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerOffline) db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 0) return utils.OK(c, fiber.Map{ "dutylogid": dutyLog.Dutylogid, "logoutat": dutyLog.Logoutat, "totalkms": dutyLog.Totalkms, }) } func MilerGetDutyStatus(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) midnight := todayMidnight() var dutyLog models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight). Order("loginat DESC").First(&dutyLog).Error; err != nil { return utils.OK(c, fiber.Map{"onduty": false}) } var profile models.MilerProfile db.DB.Where("userid = ?", milerUserID).First(&profile) return utils.OK(c, fiber.Map{ "onduty": true, "dutylogid": dutyLog.Dutylogid, "loginat": dutyLog.Loginat, "totalkms": dutyLog.Totalkms, "availabilitystatus": profile.Availabilitystatus, }) } // -------------------- // BREAK MANAGEMENT // -------------------- func MilerStartBreak(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Breaktype string `json:"breaktype"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Breaktype == "" { req.Breaktype = "Personal" } var dutyLog models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL", milerUserID, true). Order("loginat DESC").First(&dutyLog).Error; err != nil { return utils.BadRequest(c, "not on duty") } var openBreak models.MilerBreakLog if err := db.DB.Where("userid = ? AND endat IS NULL", milerUserID).First(&openBreak).Error; err == nil { return utils.BadRequest(c, "break already active") } breakLog := models.MilerBreakLog{ Userid: milerUserID, Dutylogid: dutyLog.Dutylogid, Breaktype: req.Breaktype, Startat: time.Now(), } if err := db.DB.Create(&breakLog).Error; err != nil { return utils.Internal(c, "failed to start break") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerBreak) return utils.OK(c, fiber.Map{ "breaklogid": breakLog.Breaklogid, "startat": breakLog.Startat, }) } func MilerEndBreak(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var breakLog models.MilerBreakLog if err := db.DB.Where("userid = ? AND endat IS NULL", milerUserID). Order("startat DESC").First(&breakLog).Error; err != nil { return utils.BadRequest(c, "no active break") } now := time.Now() breakLog.Endat = &now if err := db.DB.Save(&breakLog).Error; err != nil { return utils.Internal(c, "failed to end break") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAvailable) return utils.OK(c, fiber.Map{ "breaklogid": breakLog.Breaklogid, "endat": breakLog.Endat, }) } // -------------------- // MILER'S OWN BOOKINGS // -------------------- func MilerGetMyBookings(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) status := c.Query("status") dateStr := c.Query("date") query := db.DB.Where("assignedmileruserid = ?", milerUserID) if status != "" && status != "All" { query = query.Where("status = ?", status) } if dateStr != "" { if date, err := time.Parse("2006-01-02", dateStr); err == nil { query = query.Where("createdat >= ? AND createdat < ?", date, date.Add(24*time.Hour)) } } var bookings []models.PickupBooking if err := query.Preload("Parcels").Preload("ServiceOptions").Order("createdat DESC").Find(&bookings).Error; err != nil { return utils.Internal(c, "failed to fetch bookings") } response := make([]fiber.Map, 0, len(bookings)) for _, b := range bookings { var customer models.AppCustomer db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer) response = append(response, fiber.Map{ "bookingid": b.Bookingid, "bookingreference": b.Bookingno, "status": b.Status, "pickupaddress": b.Pickupaddress, "pickuplatitude": b.Pickuplatitude, "pickuplongitude": b.Pickuplongitude, "deliveryaddress": b.Deliveryaddress, "deliverylatitude": b.Deliverylatitude, "deliverylongitude": b.Deliverylongitude, "customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), "customerphone": customer.Phone, "parcels": b.Parcels, "serviceoptions": b.ServiceOptions, "createdat": b.Createdat, }) } return utils.List(c, response, int64(len(response))) } // -------------------- // DELIVERY CONFIRMATION // -------------------- func MilerDeliverConsignment(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) consignmentID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid consignment ID") } var req struct { Otp string `json:"otp"` Deliveredtoname string `json:"deliveredtoname"` Photourl string `json:"photourl"` Receiversignatureurl string `json:"receiversignatureurl"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Otp == "" { return utils.BadRequest(c, "otp is required") } if req.Deliveredtoname == "" { return utils.BadRequest(c, "deliveredtoname is required") } var consignment models.Consignment if err := db.DB.First(&consignment, consignmentID).Error; err != nil { return utils.NotFound(c, "consignment not found") } var booking models.PickupBooking if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID). First(&booking).Error; err != nil { return utils.NotFound(c, "assigned consignment not found") } if consignment.Status != constants.ConsignmentOutForDelivery { return utils.BadRequest(c, "consignment is not out for delivery") } tx := db.DB.Begin() // OTP generation/storage is Phase 2 — no delivery_otp field exists yet on // consignments, so acceptance of a non-empty OTP is treated as verified. proof := models.DeliveryProof{ Consignmentid: consignment.Consignmentid, Deliveredat: time.Now(), Deliveredtoname: req.Deliveredtoname, Receiversignatureurl: req.Receiversignatureurl, Photourl: req.Photourl, Otpverified: true, Geolatitude: req.Lat, Geolongitude: req.Lon, Createdby: milerUserID, } if err := tx.Create(&proof).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record delivery proof") } consignment.Status = constants.ConsignmentDelivered consignment.Updatedat = time.Now() if err := tx.Save(&consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to mark consignment delivered") } if err := tx.Model(&models.BookingAssignment{}). Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID). Updates(map[string]interface{}{ "assignmentstatus": constants.AssignmentCompleted, "completedat": time.Now(), }).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to close assignment") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm delivery") } if db.Js != nil { payload := map[string]interface{}{ "bookingid": booking.Bookingid, "outcome": "on_time", "consignmentid": consignment.Consignmentid, "mileruserid": milerUserID, } if data, err := json.Marshal(payload); err == nil { if _, err := db.Js.Publish("booking.outcome", data); err != nil { utils.Warn("MilerDeliverConsignment: NATS publish failed", "consignment_id", consignment.Consignmentid, "error", err) } } } var customer models.AppCustomer if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" { if notifyErr := notify.SendToDevice( customer.Devicetoken, "Parcel Delivered", "Your parcel has been delivered!", map[string]string{ "booking_id": strconv.Itoa(booking.Bookingid), "consignment_id": strconv.Itoa(consignment.Consignmentid), }, ); notifyErr != nil { utils.Warn("FCM: failed to notify customer on delivery", "booking_id", booking.Bookingid, "error", notifyErr) } } return c.JSON(fiber.Map{ "success": true, "message": "delivery confirmed", "data": fiber.Map{ "proofid": proof.Proofid, "deliveredat": proof.Deliveredat, }, }) } // -------------------- // EARNINGS // -------------------- type milerEarningsSummary struct { CompletedStops int64 `gorm:"column:completed_stops"` TotalKms float64 `gorm:"column:total_kms"` TotalEarnings float64 `gorm:"column:total_earnings"` TotalBonus int `gorm:"column:total_bonus"` } func MilerGetEarnings(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) period := c.Query("period", "daily") var start, end time.Time now := time.Now() switch period { case "weekly": start = todayMidnight().Add(-6 * 24 * time.Hour) end = todayMidnight().Add(24 * time.Hour) case "monthly": start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) end = start.AddDate(0, 1, 0) default: period = "daily" day := todayMidnight() if dateStr := c.Query("date"); dateStr != "" { if parsed, err := time.Parse("2006-01-02", dateStr); err == nil { day = parsed } } start = day end = day.Add(24 * time.Hour) } var summary milerEarningsSummary db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ? AND completedat < ?", milerUserID, constants.AssignmentCompleted, start, end). Select("COUNT(*) as completed_stops, COALESCE(SUM(riderkms),0) as total_kms, COALESCE(SUM(ridercharges),0) as total_earnings, COALESCE(SUM(bonuspoints),0) as total_bonus"). Scan(&summary) var breakdown []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ? AND completedat < ?", milerUserID, constants.AssignmentCompleted, start, end). Order("completedat DESC").Find(&breakdown) breakdownResp := make([]fiber.Map, 0, len(breakdown)) for _, a := range breakdown { breakdownResp = append(breakdownResp, fiber.Map{ "bookingid": a.Bookingid, "riderkms": a.Riderkms, "ridercharges": a.Ridercharges, "bonuspoints": a.Bonuspoints, "completedat": a.Completedat, }) } data := fiber.Map{ "period": period, "completed_stops": summary.CompletedStops, "total_kms": summary.TotalKms, "total_earnings": summary.TotalEarnings, "total_bonus": summary.TotalBonus, "breakdown": breakdownResp, } if period == "daily" { data["date"] = start.Format("2006-01-02") } return utils.OK(c, data) } // -------------------- // NOTIFICATIONS (synthetic — no dedicated table yet) // -------------------- // MilerGetNotifications generates notifications from real assignment events, // mirroring GetHubNotifications' synthetic-notification pattern. func MilerGetNotifications(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) midnight := todayMidnight() last24h := time.Now().Add(-24 * time.Hour) var notifications []notificationEntry var assigned []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignedat >= ?", milerUserID, midnight).Find(&assigned) for _, a := range assigned { notifications = append(notifications, notificationEntry{ Title: fmt.Sprintf("New pickup assigned — Booking #%d", a.Bookingid), Type: "assignment", Time: a.Assignedat, }) } var completed []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ?", milerUserID, constants.AssignmentCompleted, midnight).Find(&completed) for _, a := range completed { t := a.Assignedat if a.Completedat != nil { t = *a.Completedat } notifications = append(notifications, notificationEntry{ Title: fmt.Sprintf("Booking #%d completed", a.Bookingid), Type: "completed", Time: t, }) } var rejected []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND assignedat >= ?", milerUserID, constants.AssignmentRejected, last24h).Find(&rejected) for _, a := range rejected { notifications = append(notifications, notificationEntry{ Title: fmt.Sprintf("Assignment rejected — Booking #%d", a.Bookingid), Type: "rejected", Time: a.Assignedat, }) } 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))) } // MilerMarkNotificationRead is a stub until a notifications table with // read-state exists — always succeeds without persisting anything, matching // MarkNotificationRead's hub-console equivalent. func MilerMarkNotificationRead(c *fiber.Ctx) error { return c.JSON(fiber.Map{"success": true}) } // -------------------- // SUPPORT // -------------------- func MilerCreateTicket(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Subject string `json:"subject"` Description string `json:"description"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Subject == "" || req.Description == "" { return utils.BadRequest(c, "subject and description are required") } ticket := models.MilerSupportTicket{ Userid: milerUserID, Subject: req.Subject, Description: req.Description, Status: "Open", } if err := db.DB.Create(&ticket).Error; err != nil { return utils.Internal(c, "failed to create support ticket") } return utils.Created(c, fiber.Map{ "ticketid": ticket.Ticketid, "status": ticket.Status, }) } func MilerGetTickets(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var tickets []models.MilerSupportTicket if err := db.DB.Where("userid = ?", milerUserID).Order("createdat DESC").Find(&tickets).Error; err != nil { return utils.Internal(c, "failed to fetch support tickets") } return utils.List(c, tickets, int64(len(tickets))) }