feat: miler duty/break tracking, delivery confirmation, earnings, notifications, and support

Adds MilerDutyLog, MilerBreakLog, MilerSupportTicket models and earnings
fields on BookingAssignment, plus a new milerAppController.go wiring 10
endpoints under /api/v1/miler for the rider app: duty start/end/status,
break start/end, own-bookings listing, delivery confirmation (writes
DeliveryProof, completes the assignment, publishes booking.outcome via
NATS, and pushes an FCM delivery notification), earnings summaries
(daily/weekly/monthly), synthetic notifications, and support tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 11:25:49 +05:30
parent 1c24e93a7a
commit 6b3c95c259
5 changed files with 640 additions and 0 deletions

View File

@@ -0,0 +1,562 @@
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()
tx.Save(&consignment)
tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": time.Now(),
})
tx.Commit()
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)))
}

View File

@@ -44,6 +44,9 @@ func Migrate(db *gorm.DB) error {
&models.HubStaffAccount{}, &models.HubStaffAccount{},
&models.HubConversation{}, &models.HubConversation{},
&models.HubMessage{}, &models.HubMessage{},
&models.MilerDutyLog{},
&models.MilerBreakLog{},
&models.MilerSupportTicket{},
) )
if err != nil { if err != nil {

View File

@@ -106,6 +106,9 @@ type BookingAssignment struct {
Completedat *time.Time `json:"completedat" gorm:"column:completedat"` Completedat *time.Time `json:"completedat" gorm:"column:completedat"`
Remarks string `json:"remarks" gorm:"column:remarks"` Remarks string `json:"remarks" gorm:"column:remarks"`
AgentDecisionID *uint64 `json:"agent_decision_id,omitempty" gorm:"column:agent_decision_id"` AgentDecisionID *uint64 `json:"agent_decision_id,omitempty" gorm:"column:agent_decision_id"`
Riderkms float64 `json:"riderkms" gorm:"column:riderkms;default:0"`
Ridercharges float64 `json:"ridercharges" gorm:"column:ridercharges;default:0"`
Bonuspoints int `json:"bonuspoints" gorm:"column:bonuspoints;default:0"`
} }
func (BookingAssignment) TableName() string { func (BookingAssignment) TableName() string {

View File

@@ -190,3 +190,49 @@ type HubStaffAccount struct {
func (HubStaffAccount) TableName() string { func (HubStaffAccount) TableName() string {
return "hubstaffaccounts" return "hubstaffaccounts"
} }
type MilerDutyLog struct {
Dutylogid int `json:"dutylogid" gorm:"primaryKey;column:dutylogid"`
Userid int `json:"userid" gorm:"column:userid;index;not null"`
Loginat time.Time `json:"loginat" gorm:"column:loginat;not null"`
Logoutat *time.Time `json:"logoutat" gorm:"column:logoutat"`
Onduty bool `json:"onduty" gorm:"column:onduty;default:true"`
Startlat float64 `json:"startlat" gorm:"column:startlat"`
Startlon float64 `json:"startlon" gorm:"column:startlon"`
Lastlat float64 `json:"lastlat" gorm:"column:lastlat"`
Lastlon float64 `json:"lastlon" gorm:"column:lastlon"`
Totalkms float64 `json:"totalkms" gorm:"column:totalkms;default:0"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (MilerDutyLog) TableName() string {
return "milerdutylogs"
}
type MilerBreakLog struct {
Breaklogid int `json:"breaklogid" gorm:"primaryKey;column:breaklogid"`
Userid int `json:"userid" gorm:"column:userid;index;not null"`
Dutylogid int `json:"dutylogid" gorm:"column:dutylogid;not null"`
Breaktype string `json:"breaktype" gorm:"column:breaktype;default:Personal"`
Startat time.Time `json:"startat" gorm:"column:startat;not null"`
Endat *time.Time `json:"endat" gorm:"column:endat"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (MilerBreakLog) TableName() string {
return "milerbreaklogs"
}
type MilerSupportTicket struct {
Ticketid int `json:"ticketid" gorm:"primaryKey;column:ticketid"`
Userid int `json:"userid" gorm:"column:userid;index;not null"`
Subject string `json:"subject" gorm:"column:subject;size:200;not null"`
Description string `json:"description" gorm:"column:description;type:text;not null"`
Status string `json:"status" gorm:"column:status;default:Open"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (MilerSupportTicket) TableName() string {
return "milersupporttickets"
}

View File

@@ -125,6 +125,32 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
milerAuth.Get("/consignments/logs/:consignmentid", controllers.GetConsignmentLogs) milerAuth.Get("/consignments/logs/:consignmentid", controllers.GetConsignmentLogs)
milerAuth.Get("/consignments/userlogs/:userid", controllers.GetUserConsignmentLogs) milerAuth.Get("/consignments/userlogs/:userid", controllers.GetUserConsignmentLogs)
// Duty management
milerAuth.Post("/duty/start", controllers.MilerStartDuty)
milerAuth.Put("/duty/end", controllers.MilerEndDuty)
milerAuth.Get("/duty/current", controllers.MilerGetDutyStatus)
// Break management
milerAuth.Post("/breaks/start", controllers.MilerStartBreak)
milerAuth.Put("/breaks/end", controllers.MilerEndBreak)
// Miler's own bookings
milerAuth.Get("/bookings", controllers.MilerGetMyBookings)
// Delivery confirmation
milerAuth.Post("/consignments/:id/deliver", controllers.MilerDeliverConsignment)
// Earnings
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
// Notifications
milerAuth.Get("/notifications", controllers.MilerGetNotifications)
milerAuth.Patch("/notifications/:id/read", controllers.MilerMarkNotificationRead)
// Support
milerAuth.Post("/support", controllers.MilerCreateTicket)
milerAuth.Get("/support", controllers.MilerGetTickets)
// -------------------- // --------------------
// ADMIN CONSOLE APIS — all open, no token required // ADMIN CONSOLE APIS — all open, no token required
// -------------------- // --------------------