From 6b3c95c2590477ee625055e7748567c225f21f7b Mon Sep 17 00:00:00 2001 From: Suriya Date: Sat, 18 Jul 2026 11:25:49 +0530 Subject: [PATCH] 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 --- controllers/milerAppController.go | 562 ++++++++++++++++++++++++++++++ migrations/migrate.go | 3 + models/booking.go | 3 + models/users.go | 46 +++ routes/routes.go | 26 ++ 5 files changed, 640 insertions(+) create mode 100644 controllers/milerAppController.go diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go new file mode 100644 index 0000000..5d7ce3e --- /dev/null +++ b/controllers/milerAppController.go @@ -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))) +} diff --git a/migrations/migrate.go b/migrations/migrate.go index ca40615..9042010 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -44,6 +44,9 @@ func Migrate(db *gorm.DB) error { &models.HubStaffAccount{}, &models.HubConversation{}, &models.HubMessage{}, + &models.MilerDutyLog{}, + &models.MilerBreakLog{}, + &models.MilerSupportTicket{}, ) if err != nil { diff --git a/models/booking.go b/models/booking.go index 48c9124..c1a6537 100644 --- a/models/booking.go +++ b/models/booking.go @@ -106,6 +106,9 @@ type BookingAssignment struct { Completedat *time.Time `json:"completedat" gorm:"column:completedat"` Remarks string `json:"remarks" gorm:"column:remarks"` 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 { diff --git a/models/users.go b/models/users.go index eb05ad5..44513fd 100644 --- a/models/users.go +++ b/models/users.go @@ -190,3 +190,49 @@ type HubStaffAccount struct { func (HubStaffAccount) TableName() string { 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" +} diff --git a/routes/routes.go b/routes/routes.go index e2ce532..8b97f09 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -125,6 +125,32 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { milerAuth.Get("/consignments/logs/:consignmentid", controllers.GetConsignmentLogs) 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 // --------------------