Compare commits
13 Commits
e1fd4dc5d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fa4fbb74 | ||
|
|
0c407e5b27 | ||
|
|
cf488b3d76 | ||
|
|
511d369d7f | ||
|
|
f44e8fa3b4 | ||
|
|
42f2a41ff6 | ||
|
|
d85d5571b8 | ||
|
|
6d9232f3b7 | ||
|
|
9b3ec886ce | ||
|
|
39dcba3d80 | ||
|
|
4d03676e60 | ||
|
|
c8a9b5d797 | ||
|
|
fd7cf3e35e |
10
Dockerfile
10
Dockerfile
@@ -9,8 +9,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server .
|
|||||||
# Second Stage: Run the compiled binary inside alpine
|
# Second Stage: Run the compiled binary inside alpine
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
# Fix: Alpine needs ca-certificates to verify SSL certificates
|
# Fix: Alpine needs ca-certificates to verify SSL certificates.
|
||||||
RUN apk add --no-cache ca-certificates
|
# tzdata + TZ: the database connection sets TimeZone=Asia/Kolkata, so
|
||||||
|
# CURRENT_TIMESTAMP defaults write IST wall-clock into the timestamp columns.
|
||||||
|
# With the container defaulting to UTC, every time.Now() the app wrote was 5h30m
|
||||||
|
# behind those defaults, and "today so far" date ranges ended 5h30m in the past —
|
||||||
|
# which silently dropped anything created after noon IST from every report.
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
ENV TZ=Asia/Kolkata
|
||||||
|
|
||||||
# Copy from first stage
|
# Copy from first stage
|
||||||
COPY --from=0 /app/server /app/server
|
COPY --from=0 /app/server /app/server
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
597
controllers/adminMilerOpsController.go
Normal file
597
controllers/adminMilerOpsController.go
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
// Rider-facing console reporting: the roster summary, per-rider GPS trails,
|
||||||
|
// per-consignment delivery logs and the end-to-end booking track view.
|
||||||
|
//
|
||||||
|
// These exist because jupiter's console gave a client a live picture of their
|
||||||
|
// riders — getridersummary, riderlogs, delivery logs — and the express console
|
||||||
|
// had no equivalent. Doormile records all of it already; none of it was
|
||||||
|
// readable from /admin.
|
||||||
|
//
|
||||||
|
// Every handler here is tenant-scoped: a client login sees its own fleet and
|
||||||
|
// nothing else.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"doormile/constants"
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/models"
|
||||||
|
"doormile/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findMilerForConsole resolves a miler profile by its profile id, but only if
|
||||||
|
// the caller is allowed to see that rider. A rider belongs to a client through
|
||||||
|
// the tenantid on their appusers row — the same link GetMilers filters on.
|
||||||
|
// Doormile staff (tenant 0) skip the check.
|
||||||
|
//
|
||||||
|
// The second return is "found", not an error, for the reason on
|
||||||
|
// effectiveTenantID: a version that returned utils.NotFound(...) as its error
|
||||||
|
// hands the caller a nil, so the guard passes and the handler goes on to
|
||||||
|
// dereference a nil profile.
|
||||||
|
//
|
||||||
|
// Callers report a miss as "not found" rather than "forbidden" — a client
|
||||||
|
// should not be able to probe which rider ids exist outside their own fleet.
|
||||||
|
func findMilerForConsole(c *fiber.Ctx, milerProfileID int) (*models.MilerProfile, bool) {
|
||||||
|
var profile models.MilerProfile
|
||||||
|
if err := db.DB.Where("milerprofileid = ?", milerProfileID).First(&profile).Error; err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
own := consoleTenantID(c)
|
||||||
|
if own == 0 {
|
||||||
|
return &profile, true
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.AppUser
|
||||||
|
if err := db.DB.Select("userid", "tenantid").Where("userid = ?", profile.Userid).First(&user).Error; err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if user.Tenantid != own {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &profile, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleMilerUserIDs lists the appusers.userid of every rider the request
|
||||||
|
// should cover — the caller's own fleet for a client login, or the tenant
|
||||||
|
// Doormile staff asked for with ?tenantid=. Returns nil for "no restriction".
|
||||||
|
func visibleMilerUserIDs(c *fiber.Ctx) (ids []int, allowed bool) {
|
||||||
|
tenantID, allowed := effectiveTenantID(c)
|
||||||
|
if !allowed {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if tenantID == 0 {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
return milerUserIDsForTenant(tenantID), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// milerSummaryRow is one line of the roster table — the shape the console's
|
||||||
|
// rider list renders directly.
|
||||||
|
type milerSummaryRow struct {
|
||||||
|
Milerprofileid int `json:"milerprofileid"`
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Displayname string `json:"displayname"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Availabilitystatus string `json:"availabilitystatus"`
|
||||||
|
Defaultvehicletype string `json:"defaultvehicletype"`
|
||||||
|
Hubid *int `json:"hubid"`
|
||||||
|
Hubname string `json:"hubname"`
|
||||||
|
Rating float64 `json:"rating"`
|
||||||
|
Onduty bool `json:"onduty"`
|
||||||
|
Dutystartedat *time.Time `json:"dutystartedat"`
|
||||||
|
Currentlatitude float64 `json:"currentlatitude"`
|
||||||
|
Currentlongitude float64 `json:"currentlongitude"`
|
||||||
|
Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat"`
|
||||||
|
Lastpingat *time.Time `json:"lastpingat"`
|
||||||
|
|
||||||
|
Assigned int64 `json:"assigned"`
|
||||||
|
Accepted int64 `json:"accepted"`
|
||||||
|
Rejected int64 `json:"rejected"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
Cancelled int64 `json:"cancelled"`
|
||||||
|
Delivered int64 `json:"delivered"`
|
||||||
|
|
||||||
|
Riderkms float64 `json:"riderkms"`
|
||||||
|
Ridercharges float64 `json:"ridercharges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMilerSummary is the console's rider roster: one row per rider with their
|
||||||
|
// live state and their numbers for the date range. This is the express-console
|
||||||
|
// equivalent of jupiter's getridersummary.
|
||||||
|
//
|
||||||
|
// GET /admin/milers/summary?from=&to=&applocationid=&hubid=
|
||||||
|
func GetMilerSummary(c *fiber.Ctx) error {
|
||||||
|
from, to, err := parseHubDateRange(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.DB.Model(&models.MilerProfile{})
|
||||||
|
if appLoc := c.Query("applocationid"); appLoc != "" {
|
||||||
|
query = query.Where("applocationid = ?", appLoc)
|
||||||
|
}
|
||||||
|
if hubID := c.Query("hubid"); hubID != "" {
|
||||||
|
query = query.Where("hubid = ?", hubID)
|
||||||
|
}
|
||||||
|
visible, allowed := visibleMilerUserIDs(c)
|
||||||
|
if !allowed {
|
||||||
|
return utils.Forbidden(c, "you can only view your own tenant")
|
||||||
|
}
|
||||||
|
if visible != nil {
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return utils.List(c, []milerSummaryRow{}, 0)
|
||||||
|
}
|
||||||
|
query = query.Where("userid IN ?", visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []models.MilerProfile
|
||||||
|
if err := query.Order("displayname").Find(&profiles).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch milers")
|
||||||
|
}
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return utils.List(c, []milerSummaryRow{}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]int, 0, len(profiles))
|
||||||
|
for _, p := range profiles {
|
||||||
|
userIDs = append(userIDs, p.Userid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One grouped query per fact rather than per rider, so the roster costs a
|
||||||
|
// fixed handful of queries whatever the fleet size.
|
||||||
|
type assignAgg struct {
|
||||||
|
Mileruserid int `gorm:"column:mileruserid"`
|
||||||
|
Status string `gorm:"column:assignmentstatus"`
|
||||||
|
Cnt int64 `gorm:"column:cnt"`
|
||||||
|
Riderkms float64 `gorm:"column:riderkms"`
|
||||||
|
Ridercharges float64 `gorm:"column:ridercharges"`
|
||||||
|
}
|
||||||
|
var aggs []assignAgg
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).
|
||||||
|
Select("mileruserid, assignmentstatus, COUNT(*) AS cnt, COALESCE(SUM(riderkms),0) AS riderkms, COALESCE(SUM(ridercharges),0) AS ridercharges").
|
||||||
|
Where("mileruserid IN ? AND assignedat BETWEEN ? AND ?", userIDs, from, to).
|
||||||
|
Group("mileruserid, assignmentstatus").
|
||||||
|
Scan(&aggs)
|
||||||
|
|
||||||
|
type deliveredAgg struct {
|
||||||
|
Userid int `gorm:"column:userid"`
|
||||||
|
Cnt int64 `gorm:"column:cnt"`
|
||||||
|
}
|
||||||
|
var delivered []deliveredAgg
|
||||||
|
db.DB.Model(&models.ConsignmentHistory{}).
|
||||||
|
Select("userid, COUNT(*) AS cnt").
|
||||||
|
Where("userid IN ? AND eventstatus = ? AND createdat BETWEEN ? AND ?",
|
||||||
|
userIDs, constants.ConsignmentDelivered, from, to).
|
||||||
|
Group("userid").
|
||||||
|
Scan(&delivered)
|
||||||
|
|
||||||
|
var dutyLogs []models.MilerDutyLog
|
||||||
|
db.DB.Where("userid IN ? AND onduty = ?", userIDs, true).Find(&dutyLogs)
|
||||||
|
|
||||||
|
hubNames := map[int]string{}
|
||||||
|
var hubs []models.Hub
|
||||||
|
db.DB.Select("hubid", "hubname").Find(&hubs)
|
||||||
|
for _, h := range hubs {
|
||||||
|
hubNames[h.Hubid] = h.Hubname
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]milerSummaryRow, 0, len(profiles))
|
||||||
|
for _, p := range profiles {
|
||||||
|
row := milerSummaryRow{
|
||||||
|
Milerprofileid: p.Milerprofileid,
|
||||||
|
Userid: p.Userid,
|
||||||
|
Displayname: p.Displayname,
|
||||||
|
Phone: p.Phone,
|
||||||
|
Availabilitystatus: p.Availabilitystatus,
|
||||||
|
Defaultvehicletype: p.Defaultvehicletype,
|
||||||
|
Hubid: p.Hubid,
|
||||||
|
Rating: p.Rating,
|
||||||
|
Currentlatitude: p.Currentlatitude,
|
||||||
|
Currentlongitude: p.Currentlongitude,
|
||||||
|
Lastlocationupdatedat: p.Lastlocationupdatedat,
|
||||||
|
Lastpingat: lastTelemetryPing(p.Userid),
|
||||||
|
}
|
||||||
|
if p.Hubid != nil {
|
||||||
|
row.Hubname = hubNames[*p.Hubid]
|
||||||
|
}
|
||||||
|
for _, d := range dutyLogs {
|
||||||
|
if d.Userid == p.Userid {
|
||||||
|
row.Onduty = true
|
||||||
|
start := d.Loginat
|
||||||
|
row.Dutystartedat = &start
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range aggs {
|
||||||
|
if a.Mileruserid != p.Userid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row.Riderkms += a.Riderkms
|
||||||
|
row.Ridercharges += a.Ridercharges
|
||||||
|
switch a.Status {
|
||||||
|
case constants.AssignmentAssigned:
|
||||||
|
row.Assigned += a.Cnt
|
||||||
|
case constants.AssignmentAccepted:
|
||||||
|
row.Accepted += a.Cnt
|
||||||
|
case constants.AssignmentRejected:
|
||||||
|
row.Rejected += a.Cnt
|
||||||
|
case constants.AssignmentCompleted:
|
||||||
|
row.Completed += a.Cnt
|
||||||
|
case constants.AssignmentCancelled:
|
||||||
|
row.Cancelled += a.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range delivered {
|
||||||
|
if d.Userid == p.Userid {
|
||||||
|
row.Delivered = d.Cnt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"success": true,
|
||||||
|
"data": rows,
|
||||||
|
"total": len(rows),
|
||||||
|
"from": from.Format("2006-01-02"),
|
||||||
|
"to": to.Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastTelemetryPing reads the timestamp of a rider's most recent periodic log
|
||||||
|
// straight off the Redis index. Nil when the rider has never pinged or Redis is
|
||||||
|
// unavailable — telemetry is best-effort and must never fail the roster.
|
||||||
|
func lastTelemetryPing(userID int) *time.Time {
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := db.Rdb.ZRevRangeWithScores(db.Ctx, fmt.Sprintf("miler_periodic_logs:%d", userID), 0, 0).Result()
|
||||||
|
if err != nil || len(res) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.Unix(int64(res[0].Score), 0).UTC()
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMilerLogs returns a rider's GPS trail for a date range — jupiter's
|
||||||
|
// riderlogs, but read from the Redis telemetry index rather than a 1.17M-row
|
||||||
|
// unindexed table.
|
||||||
|
//
|
||||||
|
// GET /admin/milers/:id/logs?from=&to=&limit=
|
||||||
|
func GetMilerLogs(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid miler ID")
|
||||||
|
}
|
||||||
|
profile, ok := findMilerForConsole(c, id)
|
||||||
|
if !ok {
|
||||||
|
return utils.NotFound(c, "miler not found")
|
||||||
|
}
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return utils.Internal(c, "cache service unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
from, to, err := parseHubDateRange(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 500
|
||||||
|
if l := c.QueryInt("limit"); l > 0 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
if limit > 5000 {
|
||||||
|
limit = 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zset is scored by the log's own unix timestamp, so the range query is
|
||||||
|
// the date filter — no scanning every key for the rider.
|
||||||
|
keys, err := db.Rdb.ZRangeByScore(db.Ctx, fmt.Sprintf("miler_periodic_logs:%d", profile.Userid), &redis.ZRangeBy{
|
||||||
|
Min: strconv.FormatInt(from.Unix(), 10),
|
||||||
|
Max: strconv.FormatInt(to.Unix(), 10),
|
||||||
|
Count: int64(limit),
|
||||||
|
}).Result()
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch rider logs")
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"success": true, "data": []models.MilerLog{}, "total": 0,
|
||||||
|
"distancekm": 0.0, "miler": milerLogHeader(profile),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
vals, err := db.Rdb.MGet(db.Ctx, keys...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to read rider logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := make([]models.MilerLog, 0, len(vals))
|
||||||
|
for _, v := range vals {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var l models.MilerLog
|
||||||
|
if json.Unmarshal([]byte(s), &l) == nil {
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trail distance, so the console can show kms actually ridden over the
|
||||||
|
// window rather than only the per-booking figure.
|
||||||
|
var distance float64
|
||||||
|
for i := 1; i < len(logs); i++ {
|
||||||
|
lat1, ok1 := parseCoord(logs[i-1].Latitude)
|
||||||
|
lon1, ok2 := parseCoord(logs[i-1].Longitude)
|
||||||
|
lat2, ok3 := parseCoord(logs[i].Latitude)
|
||||||
|
lon2, ok4 := parseCoord(logs[i].Longitude)
|
||||||
|
if ok1 && ok2 && ok3 && ok4 {
|
||||||
|
distance += haversineKM(lat1, lon1, lat2, lon2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"success": true,
|
||||||
|
"data": logs,
|
||||||
|
"total": len(logs),
|
||||||
|
"distancekm": distance,
|
||||||
|
"miler": milerLogHeader(profile),
|
||||||
|
"from": from.Format("2006-01-02"),
|
||||||
|
"to": to.Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func milerLogHeader(p *models.MilerProfile) fiber.Map {
|
||||||
|
return fiber.Map{
|
||||||
|
"milerprofileid": p.Milerprofileid,
|
||||||
|
"userid": p.Userid,
|
||||||
|
"displayname": p.Displayname,
|
||||||
|
"phone": p.Phone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCoord reads a telemetry coordinate. The Redis log models store lat/long
|
||||||
|
// as strings because that is what the rider app sends.
|
||||||
|
func parseCoord(s string) (float64, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || f == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMilerActivity is one rider's detail page: their roster row, plus the duty
|
||||||
|
// and break sessions and the individual assignments behind the counts.
|
||||||
|
//
|
||||||
|
// GET /admin/milers/:id/activity?from=&to=
|
||||||
|
func GetMilerActivity(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid miler ID")
|
||||||
|
}
|
||||||
|
profile, ok := findMilerForConsole(c, id)
|
||||||
|
if !ok {
|
||||||
|
return utils.NotFound(c, "miler not found")
|
||||||
|
}
|
||||||
|
from, to, err := parseHubDateRange(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignments []models.BookingAssignment
|
||||||
|
db.DB.Where("mileruserid = ? AND assignedat BETWEEN ? AND ?", profile.Userid, from, to).
|
||||||
|
Order("assignedat DESC").Find(&assignments)
|
||||||
|
|
||||||
|
// The booking behind each assignment, so the console can show the pickup and
|
||||||
|
// drop without a request per row.
|
||||||
|
bookingIDs := make([]int, 0, len(assignments))
|
||||||
|
for _, a := range assignments {
|
||||||
|
bookingIDs = append(bookingIDs, a.Bookingid)
|
||||||
|
}
|
||||||
|
var bookings []models.PickupBooking
|
||||||
|
if len(bookingIDs) > 0 {
|
||||||
|
db.DB.Where("bookingid IN ?", bookingIDs).Find(&bookings)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dutyLogs []models.MilerDutyLog
|
||||||
|
db.DB.Where("userid = ? AND loginat BETWEEN ? AND ?", profile.Userid, from, to).
|
||||||
|
Order("loginat DESC").Find(&dutyLogs)
|
||||||
|
|
||||||
|
var breakLogs []models.MilerBreakLog
|
||||||
|
db.DB.Where("userid = ? AND startat BETWEEN ? AND ?", profile.Userid, from, to).
|
||||||
|
Order("startat DESC").Find(&breakLogs)
|
||||||
|
|
||||||
|
var delivered int64
|
||||||
|
db.DB.Model(&models.ConsignmentHistory{}).
|
||||||
|
Where("userid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?",
|
||||||
|
profile.Userid, constants.ConsignmentDelivered, from, to).Count(&delivered)
|
||||||
|
|
||||||
|
var totalKms, totalCharges float64
|
||||||
|
var dutyMinutes float64
|
||||||
|
for _, a := range assignments {
|
||||||
|
totalKms += a.Riderkms
|
||||||
|
totalCharges += a.Ridercharges
|
||||||
|
}
|
||||||
|
for _, d := range dutyLogs {
|
||||||
|
end := utils.DBNow()
|
||||||
|
if d.Logoutat != nil {
|
||||||
|
end = *d.Logoutat
|
||||||
|
}
|
||||||
|
dutyMinutes += end.Sub(d.Loginat).Minutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, fiber.Map{
|
||||||
|
"miler": profile,
|
||||||
|
"from": from.Format("2006-01-02"),
|
||||||
|
"to": to.Format("2006-01-02"),
|
||||||
|
"assignments": assignments,
|
||||||
|
"bookings": bookings,
|
||||||
|
"dutylogs": dutyLogs,
|
||||||
|
"breaklogs": breakLogs,
|
||||||
|
"lastpingat": lastTelemetryPing(profile.Userid),
|
||||||
|
"totals": fiber.Map{
|
||||||
|
"assignments": len(assignments),
|
||||||
|
"delivered": delivered,
|
||||||
|
"riderkms": totalKms,
|
||||||
|
"ridercharges": totalCharges,
|
||||||
|
"dutyminutes": dutyMinutes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminConsignmentLogs returns everything recorded against one parcel: the
|
||||||
|
// durable event history from Postgres and the rider's telemetry trail from
|
||||||
|
// Redis. jupiter kept these in one flat table; here they are two stores, so the
|
||||||
|
// console gets both in one call rather than reconciling them itself.
|
||||||
|
//
|
||||||
|
// GET /admin/consignments/:id/logs
|
||||||
|
func GetAdminConsignmentLogs(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid consignment ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var consignment models.Consignment
|
||||||
|
q := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid")
|
||||||
|
if err := q.Where("consignmentid = ?", id).First(&consignment).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "consignment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var history []models.ConsignmentHistory
|
||||||
|
db.DB.Where("consignmentid = ?", id).Order("createdat").Find(&history)
|
||||||
|
|
||||||
|
var proof models.DeliveryProof
|
||||||
|
hasProof := db.DB.Where("consignmentid = ?", id).First(&proof).Error == nil
|
||||||
|
|
||||||
|
telemetry := readConsignmentTelemetry(id)
|
||||||
|
|
||||||
|
resp := fiber.Map{
|
||||||
|
"consignment": consignment,
|
||||||
|
"history": history,
|
||||||
|
"telemetry": telemetry,
|
||||||
|
}
|
||||||
|
if hasProof {
|
||||||
|
resp["deliveryproof"] = proof
|
||||||
|
}
|
||||||
|
return utils.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readConsignmentTelemetry pulls the rider's per-parcel log list out of Redis.
|
||||||
|
// Always returns a usable slice — telemetry missing is not an error worth
|
||||||
|
// failing a tracking screen over.
|
||||||
|
func readConsignmentTelemetry(consignmentID int) []models.ConsignmentLog {
|
||||||
|
logs := []models.ConsignmentLog{}
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
raw, err := db.Rdb.LRange(db.Ctx, "Consignmentlogs:"+strconv.Itoa(consignmentID), 0, -1).Result()
|
||||||
|
if err != nil {
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
for _, item := range raw {
|
||||||
|
var l models.ConsignmentLog
|
||||||
|
if json.Unmarshal([]byte(item), &l) == nil {
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminBookingTrack is the one call the console's tracking screen needs: the
|
||||||
|
// booking, every assignment attempt against it with the rider behind each, the
|
||||||
|
// consignment it became, that parcel's event history and proof of delivery, and
|
||||||
|
// the rider's live position.
|
||||||
|
//
|
||||||
|
// GET /admin/bookings/:id/track
|
||||||
|
func GetAdminBookingTrack(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid booking ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var booking models.PickupBooking
|
||||||
|
q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid")
|
||||||
|
if err := q.Where("bookingid = ?", id).First(&booking).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "booking not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignments []models.BookingAssignment
|
||||||
|
db.DB.Where("bookingid = ?", id).Order("assignedat").Find(&assignments)
|
||||||
|
|
||||||
|
// Every rider who has touched this booking, not just the current one — a
|
||||||
|
// rejected first attempt is exactly what ops needs to see when asking why a
|
||||||
|
// pickup was slow.
|
||||||
|
riderIDs := make([]int, 0, len(assignments))
|
||||||
|
for _, a := range assignments {
|
||||||
|
riderIDs = append(riderIDs, a.Mileruserid)
|
||||||
|
}
|
||||||
|
riders := []models.MilerProfile{}
|
||||||
|
if len(riderIDs) > 0 {
|
||||||
|
db.DB.Where("userid IN ?", riderIDs).Find(&riders)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := fiber.Map{
|
||||||
|
"booking": booking,
|
||||||
|
"assignments": assignments,
|
||||||
|
"riders": riders,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live position of whoever currently holds it.
|
||||||
|
if booking.Assignedmileruserid != nil {
|
||||||
|
resp["livelocation"] = readMilerLiveLocation(*booking.Assignedmileruserid)
|
||||||
|
resp["lastpingat"] = lastTelemetryPing(*booking.Assignedmileruserid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consignment, once the booking has been picked up and converted.
|
||||||
|
// BookingPickupComplete writes the link back onto the booking.
|
||||||
|
var consignment models.Consignment
|
||||||
|
if booking.Consignmentid != nil &&
|
||||||
|
db.DB.Where("consignmentid = ?", *booking.Consignmentid).First(&consignment).Error == nil {
|
||||||
|
var history []models.ConsignmentHistory
|
||||||
|
db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat").Find(&history)
|
||||||
|
|
||||||
|
resp["consignment"] = consignment
|
||||||
|
resp["history"] = history
|
||||||
|
resp["telemetry"] = readConsignmentTelemetry(consignment.Consignmentid)
|
||||||
|
|
||||||
|
var proof models.DeliveryProof
|
||||||
|
if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&proof).Error == nil {
|
||||||
|
resp["deliveryproof"] = proof
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMilerLiveLocation reads the rider's current position out of the Redis key
|
||||||
|
// UpdateMilerLocation writes on every ping. That key holds a bare "lat,lon"
|
||||||
|
// string with a 30-minute TTL, so a nil here means the rider has not pinged in
|
||||||
|
// the last half hour — normal for someone off duty, and worth showing as
|
||||||
|
// "no live position" rather than a stale one.
|
||||||
|
func readMilerLiveLocation(milerUserID int) interface{} {
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
val, err := db.Rdb.Get(db.Ctx, fmt.Sprintf("miler:gps:%d", milerUserID)).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var lat, lon float64
|
||||||
|
if _, err := fmt.Sscanf(val, "%f,%f", &lat, &lon); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fiber.Map{"latitude": lat, "longitude": lon}
|
||||||
|
}
|
||||||
@@ -189,6 +189,19 @@ func ResetCustomerPin(c *fiber.Ctx) error {
|
|||||||
return utils.NotFound(c, "customer not found")
|
return utils.NotFound(c, "customer not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proof of identity is required before overwriting a login credential.
|
||||||
|
// Without it this endpoint reset any customer's PIN from their phone number
|
||||||
|
// alone — and phone numbers are the login identifier, not a secret — so
|
||||||
|
// reset-pin followed by verify-pin was a complete account takeover.
|
||||||
|
// The caller must first pass /customer/send-email-otp and
|
||||||
|
// /customer/verify-email-otp for this account's registered address.
|
||||||
|
if customer.Email == "" {
|
||||||
|
return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN")
|
||||||
|
}
|
||||||
|
if !ConsumeEmailVerification(customer.Email) {
|
||||||
|
return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp")
|
||||||
|
}
|
||||||
|
|
||||||
pinHash, err := utils.HashPassword(req.NewPin)
|
pinHash, err := utils.HashPassword(req.NewPin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.Internal(c, "failed to process PIN reset")
|
return utils.Internal(c, "failed to process PIN reset")
|
||||||
@@ -620,11 +633,22 @@ func CancelCustomerBooking(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetCustomerBookingQuote(c *fiber.Ctx) error {
|
func GetCustomerBookingQuote(c *fiber.Ctx) error {
|
||||||
|
customerID := c.Locals("userid").(int)
|
||||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.BadRequest(c, "invalid booking ID")
|
return utils.BadRequest(c, "invalid booking ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership is checked here as it is on the other booking routes — without
|
||||||
|
// it any signed-in customer could read the price quoted on anyone else's
|
||||||
|
// booking just by walking the id.
|
||||||
|
var booking models.PickupBooking
|
||||||
|
if err := db.DB.Select("bookingid").
|
||||||
|
Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).
|
||||||
|
First(&booking).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "booking not found")
|
||||||
|
}
|
||||||
|
|
||||||
var serviceOpt models.BookingServiceOption
|
var serviceOpt models.BookingServiceOption
|
||||||
if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil {
|
if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil {
|
||||||
return utils.NotFound(c, "price quote not found for this booking")
|
return utils.NotFound(c, "price quote not found for this booking")
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ func humanizeRelativeTime(t time.Time) string {
|
|||||||
|
|
||||||
// todayMidnight returns the start of the current day in server local time,
|
// todayMidnight returns the start of the current day in server local time,
|
||||||
// used to scope "today" counters on the hub dashboard.
|
// used to scope "today" counters on the hub dashboard.
|
||||||
|
// todayMidnight is the start of the current day as the database records it —
|
||||||
|
// see utils.DBNow. Using the container's own clock here dropped every row
|
||||||
|
// created after noon IST out of "today so far".
|
||||||
func todayMidnight() time.Time {
|
func todayMidnight() time.Time {
|
||||||
now := time.Now()
|
return utils.DBToday()
|
||||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseHubDateRange parses optional from/to (YYYY-MM-DD) query params shared
|
// parseHubDateRange parses optional from/to (YYYY-MM-DD) query params shared
|
||||||
@@ -110,17 +112,20 @@ func parseHubDateRange(c *fiber.Ctx) (time.Time, time.Time, error) {
|
|||||||
toStr := c.Query("to")
|
toStr := c.Query("to")
|
||||||
|
|
||||||
if fromStr == "" && toStr == "" {
|
if fromStr == "" && toStr == "" {
|
||||||
return todayMidnight(), time.Now(), nil
|
return todayMidnight(), utils.DBNow(), nil
|
||||||
}
|
}
|
||||||
if fromStr == "" || toStr == "" {
|
if fromStr == "" || toStr == "" {
|
||||||
return time.Time{}, time.Time{}, fmt.Errorf("both from and to query params are required (YYYY-MM-DD)")
|
return time.Time{}, time.Time{}, fmt.Errorf("both from and to query params are required (YYYY-MM-DD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
// Parsed as UTC, not time.Local: stored timestamps are bare wall-clock
|
||||||
|
// digits, so the bounds must be too — otherwise the window silently shifts
|
||||||
|
// with whatever timezone the container happens to run in.
|
||||||
|
from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid from date, expected YYYY-MM-DD")
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid from date, expected YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.UTC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD")
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,9 +274,6 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return utils.BadRequest(c, "invalid request body")
|
return utils.BadRequest(c, "invalid request body")
|
||||||
}
|
}
|
||||||
if req.Otp == "" {
|
|
||||||
return utils.BadRequest(c, "otp is required")
|
|
||||||
}
|
|
||||||
if req.Deliveredtoname == "" {
|
if req.Deliveredtoname == "" {
|
||||||
return utils.BadRequest(c, "deliveredtoname is required")
|
return utils.BadRequest(c, "deliveredtoname is required")
|
||||||
}
|
}
|
||||||
@@ -296,17 +293,28 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "consignment is not out for delivery")
|
return utils.BadRequest(c, "consignment is not out for delivery")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp),
|
||||||
|
// so an empty one means this delivery was never meant to need a code — that
|
||||||
|
// covers food clients like DailyGrubs as well as parcels already in the
|
||||||
|
// network from before OTPs existed, which would otherwise be unclosable.
|
||||||
|
if consignment.Deliveryotp != "" {
|
||||||
|
if req.Otp == "" {
|
||||||
|
return utils.BadRequest(c, "otp is required for this delivery")
|
||||||
|
}
|
||||||
|
if req.Otp != consignment.Deliveryotp {
|
||||||
|
return utils.BadRequest(c, "incorrect delivery OTP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx := db.DB.Begin()
|
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{
|
proof := models.DeliveryProof{
|
||||||
Consignmentid: consignment.Consignmentid,
|
Consignmentid: consignment.Consignmentid,
|
||||||
Deliveredat: time.Now(),
|
Deliveredat: time.Now(),
|
||||||
Deliveredtoname: req.Deliveredtoname,
|
Deliveredtoname: req.Deliveredtoname,
|
||||||
Receiversignatureurl: req.Receiversignatureurl,
|
Receiversignatureurl: req.Receiversignatureurl,
|
||||||
Photourl: req.Photourl,
|
Photourl: req.Photourl,
|
||||||
Otpverified: true,
|
Otpverified: consignment.Deliveryotp != "",
|
||||||
Geolatitude: req.Lat,
|
Geolatitude: req.Lat,
|
||||||
Geolongitude: req.Lon,
|
Geolongitude: req.Lon,
|
||||||
Createdby: milerUserID,
|
Createdby: milerUserID,
|
||||||
@@ -317,17 +325,57 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
consignment.Status = constants.ConsignmentDelivered
|
consignment.Status = constants.ConsignmentDelivered
|
||||||
|
// Cleared once redeemed so the same code can't close out a second attempt.
|
||||||
|
consignment.Deliveryotp = ""
|
||||||
consignment.Updatedat = time.Now()
|
consignment.Updatedat = time.Now()
|
||||||
if err := tx.Save(&consignment).Error; err != nil {
|
if err := tx.Save(&consignment).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to mark consignment delivered")
|
return utils.Internal(c, "failed to mark consignment delivered")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every other state change on a consignment writes a history row; delivery
|
||||||
|
// did not, so a customer following the tracking timeline never saw the
|
||||||
|
// parcel arrive — it just stopped at Out_for_Delivery.
|
||||||
|
deliveredEvent := models.ConsignmentHistory{
|
||||||
|
Consignmentid: consignment.Consignmentid,
|
||||||
|
Hubid: consignment.Currenthubid,
|
||||||
|
Userid: &milerUserID,
|
||||||
|
Eventstatus: constants.ConsignmentDelivered,
|
||||||
|
Remarks: fmt.Sprintf("Delivered to %s at (%.5f, %.5f)",
|
||||||
|
req.Deliveredtoname, req.Lat, req.Lon),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&deliveredEvent).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return utils.Internal(c, "failed to record delivery history")
|
||||||
|
}
|
||||||
|
|
||||||
|
// riderkms is the distance actually ridden for this booking, measured from
|
||||||
|
// the pickup point to where the rider stood when they confirmed delivery
|
||||||
|
// (falling back to the booking's delivery coordinates if the app sent none).
|
||||||
|
// ridercharges is the order amount the tenant is billed, passed in at
|
||||||
|
// creation as finalprice and stored on the service option — Doormile does
|
||||||
|
// not compute it. Both were only ever read by GET /miler/earnings and never
|
||||||
|
// written, so every completed job reported zero distance and zero value.
|
||||||
|
dropLat, dropLon := req.Lat, req.Lon
|
||||||
|
if dropLat == 0 && dropLon == 0 {
|
||||||
|
dropLat, dropLon = consignment.Deliverylatitude, consignment.Deliverylongitude
|
||||||
|
}
|
||||||
|
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||||
|
|
||||||
|
var serviceOpt models.BookingServiceOption
|
||||||
|
orderAmount := 0.0
|
||||||
|
if tx.Where("bookingid = ?", booking.Bookingid).
|
||||||
|
Order("createdat DESC").First(&serviceOpt).Error == nil {
|
||||||
|
orderAmount = serviceOpt.Estimatedprice
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Model(&models.BookingAssignment{}).
|
if err := tx.Model(&models.BookingAssignment{}).
|
||||||
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
|
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"assignmentstatus": constants.AssignmentCompleted,
|
"assignmentstatus": constants.AssignmentCompleted,
|
||||||
"completedat": time.Now(),
|
"completedat": time.Now(),
|
||||||
|
"riderkms": riderKms,
|
||||||
|
"ridercharges": orderAmount,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to close assignment")
|
return utils.Internal(c, "failed to close assignment")
|
||||||
@@ -477,7 +525,10 @@ func MilerGetEarnings(c *fiber.Ctx) error {
|
|||||||
period := c.Query("period", "daily")
|
period := c.Query("period", "daily")
|
||||||
|
|
||||||
var start, end time.Time
|
var start, end time.Time
|
||||||
now := time.Now()
|
// Database-local, not container-local — see utils.DBNow. The rest of the
|
||||||
|
// range logic already moved off the container clock; this branch would
|
||||||
|
// otherwise put a rider in the wrong month for 5h30m either side of it.
|
||||||
|
now := utils.DBNow()
|
||||||
|
|
||||||
switch period {
|
switch period {
|
||||||
case "weekly":
|
case "weekly":
|
||||||
|
|||||||
@@ -293,7 +293,8 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "invalid request body")
|
return utils.BadRequest(c, "invalid request body")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status == "" {
|
status := req.ResolvedStatus()
|
||||||
|
if status == "" {
|
||||||
return utils.BadRequest(c, "status is required")
|
return utils.BadRequest(c, "status is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +303,7 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
|
|||||||
return utils.NotFound(c, "miler profile not found")
|
return utils.NotFound(c, "miler profile not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.Availabilitystatus = req.Status
|
profile.Availabilitystatus = status
|
||||||
profile.Updatedat = time.Now()
|
profile.Updatedat = time.Now()
|
||||||
if err := db.DB.Save(&profile).Error; err != nil {
|
if err := db.DB.Save(&profile).Error; err != nil {
|
||||||
return utils.Internal(c, "failed to update availability")
|
return utils.Internal(c, "failed to update availability")
|
||||||
@@ -796,9 +797,14 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
consignmentTenantID = *booking.Tenantid
|
consignmentTenantID = *booking.Tenantid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carried over so the consignment stays traceable to the client site it was
|
||||||
|
// collected from — for a food client that's the kitchen, and "how many
|
||||||
|
// parcels went out of which kitchen" is unanswerable without it.
|
||||||
consignment := models.Consignment{
|
consignment := models.Consignment{
|
||||||
Trackingno: trackingNo,
|
Trackingno: trackingNo,
|
||||||
Tenantid: consignmentTenantID,
|
Tenantid: consignmentTenantID,
|
||||||
|
Pickuplocationid: booking.Pickuplocationid,
|
||||||
|
Tenantlocationid: booking.Tenantlocationid,
|
||||||
Pickuplatitude: booking.Pickuplatitude,
|
Pickuplatitude: booking.Pickuplatitude,
|
||||||
Pickuplongitude: booking.Pickuplongitude,
|
Pickuplongitude: booking.Pickuplongitude,
|
||||||
Deliverylatitude: booking.Deliverylatitude,
|
Deliverylatitude: booking.Deliverylatitude,
|
||||||
@@ -829,6 +835,17 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hyperlocal parcel goes straight out for delivery, so its receiver OTP has
|
||||||
|
// to exist before this transaction commits. Anything routed via a hub gets
|
||||||
|
// its OTP when it actually leaves for the final mile instead. Only issued
|
||||||
|
// for clients that ask for it — see Tenant.Requiredeliveryotp.
|
||||||
|
if consignmentStatus == constants.ConsignmentOutForDelivery {
|
||||||
|
var tenant models.Tenant
|
||||||
|
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
|
||||||
|
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Create(&consignment).Error; err != nil {
|
if err := tx.Create(&consignment).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to convert booking to consignment")
|
return utils.Internal(c, "failed to convert booking to consignment")
|
||||||
@@ -865,15 +882,18 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var customer models.AppCustomer
|
var customer models.AppCustomer
|
||||||
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
||||||
if notifyErr := notify.SendToDevice(
|
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
|
||||||
customer.Devicetoken,
|
payload := map[string]string{
|
||||||
"Parcel Picked Up",
|
"booking_id": strconv.Itoa(bookingID),
|
||||||
fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo),
|
"tracking_no": trackingNo,
|
||||||
map[string]string{
|
}
|
||||||
"booking_id": strconv.Itoa(bookingID),
|
// The OTP goes to the receiver and only the receiver — the rider has to
|
||||||
"tracking_no": trackingNo,
|
// be told it at the door, which is what makes it proof of handover.
|
||||||
},
|
if consignment.Deliveryotp != "" {
|
||||||
); notifyErr != nil {
|
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
|
||||||
|
payload["delivery_otp"] = consignment.Deliveryotp
|
||||||
|
}
|
||||||
|
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
|
||||||
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
|
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,6 +939,11 @@ func CreateMilerPeriodicLog(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "invalid request body")
|
return utils.BadRequest(c, "invalid request body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rider's identity comes from their token, never the body. Trusting a
|
||||||
|
// client-supplied userid let any authenticated miler write another miler's
|
||||||
|
// GPS trail, which feeds the location data dispatch reasons over.
|
||||||
|
log.UserID = c.Locals("userid").(int)
|
||||||
|
|
||||||
t, err := time.Parse("2006-01-02 15:04:05", log.LogDate)
|
t, err := time.Parse("2006-01-02 15:04:05", log.LogDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS")
|
return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS")
|
||||||
@@ -987,8 +1012,12 @@ func CreateMilerStatus(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "invalid request body")
|
return utils.BadRequest(c, "invalid request body")
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.UserID == 0 || status.Status == "" {
|
// Identity from the token, not the body — otherwise one rider can set
|
||||||
return utils.BadRequest(c, "userid and status are required")
|
// another rider's live status.
|
||||||
|
status.UserID = c.Locals("userid").(int)
|
||||||
|
|
||||||
|
if status.Status == "" {
|
||||||
|
return utils.BadRequest(c, "status is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := fmt.Sprintf("miler_status:%d", status.UserID)
|
key := fmt.Sprintf("miler_status:%d", status.UserID)
|
||||||
@@ -1088,10 +1117,16 @@ func PublishConsignmentLogs(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "cache service unavailable")
|
return utils.Internal(c, "cache service unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
milerUserID := c.Locals("userid").(int)
|
||||||
|
|
||||||
pipe := db.Rdb.TxPipeline()
|
pipe := db.Rdb.TxPipeline()
|
||||||
tx := db.DB.Begin()
|
tx := db.DB.Begin()
|
||||||
|
|
||||||
for _, item := range input {
|
for _, item := range input {
|
||||||
|
// Same rule as the other telemetry writers: the token owns the identity,
|
||||||
|
// so a batch can't be attributed to some other rider.
|
||||||
|
item.UserID = milerUserID
|
||||||
|
|
||||||
logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate)
|
logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logTime = time.Now()
|
logTime = time.Now()
|
||||||
@@ -1170,6 +1205,13 @@ func GetUserConsignmentLogs(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "invalid user ID")
|
return utils.BadRequest(c, "invalid user ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The path names a rider, so it has to be checked against the caller —
|
||||||
|
// otherwise any miler could read another miler's movement history simply by
|
||||||
|
// changing the number in the URL.
|
||||||
|
if userID != c.Locals("userid").(int) {
|
||||||
|
return utils.Forbidden(c, "you can only read your own consignment logs")
|
||||||
|
}
|
||||||
|
|
||||||
if db.Rdb == nil {
|
if db.Rdb == nil {
|
||||||
return utils.Internal(c, "cache service unavailable")
|
return utils.Internal(c, "cache service unavailable")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
otpTTL = 5 * time.Minute
|
otpTTL = 5 * time.Minute
|
||||||
otpMaxAttempts = 5
|
otpMaxAttempts = 5
|
||||||
|
// otpVerifiedTTL is how long a successful email verification stays usable as
|
||||||
|
// proof of identity for a follow-up action such as a PIN reset. Long enough
|
||||||
|
// to type a new PIN, short enough that a stale verification can't be
|
||||||
|
// redeemed later.
|
||||||
|
otpVerifiedTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateOtpCode() string {
|
func generateOtpCode() string {
|
||||||
@@ -33,6 +38,22 @@ func generateOtpCode() string {
|
|||||||
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
|
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 otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
|
||||||
|
|
||||||
|
// otpVerifiedKey marks an email as recently proven. Verification previously
|
||||||
|
// left no trace at all, so nothing downstream could require it — which is why
|
||||||
|
// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number.
|
||||||
|
func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) }
|
||||||
|
|
||||||
|
// ConsumeEmailVerification reports whether the email was verified recently, and
|
||||||
|
// clears the marker so a single verification can authorise exactly one action.
|
||||||
|
func ConsumeEmailVerification(email string) bool {
|
||||||
|
if db.Rdb == nil || email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result()
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
|
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
req := new(dto.SendEmailOtpRequest)
|
req := new(dto.SendEmailOtpRequest)
|
||||||
@@ -100,6 +121,8 @@ func VerifyCustomerEmailOtp() fiber.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
|
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
|
||||||
|
// Recorded so a follow-up PIN reset can prove this email was verified.
|
||||||
|
db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL)
|
||||||
return utils.Message(c, "email verified successfully")
|
return utils.Message(c, "email verified successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
controllers/tenantscope_test.go
Normal file
103
controllers/tenantscope_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"doormile/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCtx builds a throwaway request context with the given console identity.
|
||||||
|
func newCtx(t *testing.T, tenantID int, query string) (*fiber.Ctx, func()) {
|
||||||
|
t.Helper()
|
||||||
|
app := fiber.New()
|
||||||
|
fctx := &fasthttp.RequestCtx{}
|
||||||
|
fctx.Request.SetRequestURI("/admin/milers?" + query)
|
||||||
|
c := app.AcquireCtx(fctx)
|
||||||
|
c.Locals("tenantid", tenantID)
|
||||||
|
return c, func() { app.ReleaseCtx(c) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponseHelpersReturnNil pins the trap that broke both console access
|
||||||
|
// checks: utils.Forbidden and utils.NotFound write the response and return
|
||||||
|
// c.JSON's nil. A helper that signals refusal by returning one of them hands
|
||||||
|
// its caller a nil error, every `if err != nil` guard passes, and the handler
|
||||||
|
// carries on to write real data into a response already stamped 403 or 404.
|
||||||
|
//
|
||||||
|
// If this test ever fails because the helpers started returning a real error,
|
||||||
|
// the bool-returning access checks can go back to returning errors.
|
||||||
|
func TestResponseHelpersReturnNil(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
c := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||||
|
defer app.ReleaseCtx(c)
|
||||||
|
|
||||||
|
if err := utils.Forbidden(c, "denied"); err != nil {
|
||||||
|
t.Errorf("utils.Forbidden returned %v; the access checks assume nil — see effectiveTenantID", err)
|
||||||
|
}
|
||||||
|
if err := utils.NotFound(c, "missing"); err != nil {
|
||||||
|
t.Errorf("utils.NotFound returned %v; the access checks assume nil — see findMilerForConsole", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveTenantID(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
own int
|
||||||
|
query string
|
||||||
|
wantTenant int
|
||||||
|
wantAllowed bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "doormile staff with no filter see the whole network",
|
||||||
|
own: 0,
|
||||||
|
query: "",
|
||||||
|
wantTenant: 0,
|
||||||
|
wantAllowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doormile staff can ask for one client's slice",
|
||||||
|
own: 0,
|
||||||
|
query: "tenantid=13",
|
||||||
|
wantTenant: 13,
|
||||||
|
wantAllowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a client login is pinned to its own tenant",
|
||||||
|
own: 13,
|
||||||
|
query: "",
|
||||||
|
wantTenant: 13,
|
||||||
|
wantAllowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a client asking for its own tenant is fine",
|
||||||
|
own: 13,
|
||||||
|
query: "tenantid=13",
|
||||||
|
wantTenant: 13,
|
||||||
|
wantAllowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a client asking for another tenant is refused",
|
||||||
|
own: 13,
|
||||||
|
query: "tenantid=14",
|
||||||
|
wantTenant: 0,
|
||||||
|
wantAllowed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, release := newCtx(t, tc.own, tc.query)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
gotTenant, gotAllowed := effectiveTenantID(c)
|
||||||
|
if gotAllowed != tc.wantAllowed {
|
||||||
|
t.Errorf("allowed = %v, want %v", gotAllowed, tc.wantAllowed)
|
||||||
|
}
|
||||||
|
if gotAllowed && gotTenant != tc.wantTenant {
|
||||||
|
t.Errorf("tenant = %d, want %d", gotTenant, tc.wantTenant)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
422
docs/express-console-api.md
Normal file
422
docs/express-console-api.md
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
# Doormile Express Console — API reference
|
||||||
|
|
||||||
|
The console surface only (`/admin/*`). 95 routes: 1 login + 94 authenticated.
|
||||||
|
Everything is under `https://api.doormile.com/api/v1`.
|
||||||
|
|
||||||
|
Verified against the build deployed 2026-08-06 12:41 IST.
|
||||||
|
|
||||||
|
Miler-app, hub-console, customer-app and CRM routes are not in this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/login
|
||||||
|
{ "email": "developer@doormile.com", "password": "admin@123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ success, token, user: { id, name, email, role, tenantid } }`.
|
||||||
|
Send it on every other call as `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
The token is a JWT carrying `userid`, `email`, `roleid`, `tenantid`, `configid`.
|
||||||
|
Roles allowed on this group: **1 admin, 3 manager, 4 executive**. Anything else
|
||||||
|
gets 401.
|
||||||
|
|
||||||
|
### Tenant scoping — read this before wiring any list screen
|
||||||
|
|
||||||
|
`tenantid` in the token decides what the account can see:
|
||||||
|
|
||||||
|
| Token `tenantid` | Who | Sees |
|
||||||
|
|---|---|---|
|
||||||
|
| `0` / null | Doormile's own staff | everything, all tenants |
|
||||||
|
| set (e.g. `13`) | a client's console login | only that tenant's rows |
|
||||||
|
|
||||||
|
Scoping is applied server-side. Build the UI as if the API returns exactly what
|
||||||
|
the account is allowed to see, because it does.
|
||||||
|
|
||||||
|
**`?tenantid=` — the staff-only filter.** Doormile staff pass it to narrow any
|
||||||
|
of the list/summary endpoints to one client:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/milers?tenantid=13 → DailyGrubs' 5 riders
|
||||||
|
GET /admin/dashboard?tenantid=13 → DailyGrubs' counters
|
||||||
|
GET /admin/reports?tenantid=13&from=…&to=…
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported on `dashboard`, `reports`, `milers`, `milers/summary`, `customers`,
|
||||||
|
`bookings`, `consignments`. Omit it for the whole network.
|
||||||
|
|
||||||
|
A **client** login passing another tenant's id gets **403** — not their own data
|
||||||
|
silently relabelled. Passing their own id, or omitting it, works normally.
|
||||||
|
|
||||||
|
Reading one resource by id that belongs to another tenant returns **404**, not
|
||||||
|
403, so ids outside your own fleet aren't probeable. This covers bookings,
|
||||||
|
consignments and riders, on reads *and* writes — `POST /admin/bookings/:id/
|
||||||
|
assign-miler` on someone else's booking is refused before the write, not after.
|
||||||
|
|
||||||
|
Note the group is registered under a stale `// all open, no token required`
|
||||||
|
comment in `routes.go`; the comment is wrong, `AuthMiddleware` is applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard, profile, reports
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/dashboard` | counts + today's numbers |
|
||||||
|
| GET | `/admin/reports` | `?from=&to=&tenantid=&locationid=&hubid=`, defaults to today (IST) |
|
||||||
|
| GET | `/admin/profile` | current account |
|
||||||
|
| GET | `/admin/me` | alias of the above |
|
||||||
|
| PUT | `/admin/profile/password` | `{ "current_password": "...", "new_password": "..." }` — snake_case |
|
||||||
|
|
||||||
|
## App users (staff logins)
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| GET | `/admin/users` |
|
||||||
|
| POST | `/admin/users` |
|
||||||
|
| PUT | `/admin/users/:id` |
|
||||||
|
| DELETE | `/admin/users/:id` |
|
||||||
|
|
||||||
|
## Partners (fleet / rider suppliers)
|
||||||
|
|
||||||
|
Not the same thing as a tenant. A partner supplies vehicles and riders; a tenant
|
||||||
|
is a client Doormile delivers for.
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/partners` | |
|
||||||
|
| POST | `/admin/partners` | `{ partnername, partnertypeid, contactno, status }` |
|
||||||
|
| GET | `/admin/partners/:id` | |
|
||||||
|
| PUT | `/admin/partners/:id` | |
|
||||||
|
| DELETE | `/admin/partners/:id` | hard delete — no soft-delete column |
|
||||||
|
|
||||||
|
## Tenants (client companies)
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/tenants` | |
|
||||||
|
| POST | `/admin/tenants` | `{ tenantname, primaryemail, primarycontact, status, requiredeliveryotp }` |
|
||||||
|
| GET | `/admin/tenants/:id` | |
|
||||||
|
| PUT | `/admin/tenants/:id` | `requiredeliveryotp` is a pointer — omit it to leave the setting alone |
|
||||||
|
| DELETE | `/admin/tenants/:id` | hard delete |
|
||||||
|
| GET | `/admin/tenants/:id/locations` | the client's sites (kitchens, branches, depots) |
|
||||||
|
| GET | `/admin/locations/summary` | **per-site performance** — `?tenantid=&locationid=&from=&to=` |
|
||||||
|
| POST | `/admin/tenants/:id/locations` | `{ locationname, address, city, state, pincode, latitude, longitude, isprimary, status }` |
|
||||||
|
| PUT | `/admin/tenantlocations/:id` | note: **not** nested under the tenant |
|
||||||
|
|
||||||
|
`requiredeliveryotp` is opt-in per tenant and **off by default**. DailyGrubs runs
|
||||||
|
without delivery OTP by decision.
|
||||||
|
|
||||||
|
### Per-site reporting
|
||||||
|
|
||||||
|
`GET /admin/locations/summary?tenantid=13&from=&to=` returns one row per site:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "tenantlocationid": 13, "locationname": "Vidhya kitchen",
|
||||||
|
"address": "…", "pincode": "641015",
|
||||||
|
"bookings": 12, "delivered": 11, "cancelled": 1, "cod_collected": 840 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sites with no orders in the range still appear, with zeros. A trailing
|
||||||
|
`"Unattributed"` row (`tenantlocationid: null`) carries bookings that never
|
||||||
|
named a site, so the rows always add up to the report's summary total.
|
||||||
|
|
||||||
|
Doormile staff **must** pass `?tenantid=` here — per-site rows across all
|
||||||
|
tenants at once aren't a meaningful report, so it 400s without one.
|
||||||
|
|
||||||
|
The same rows appear as `by_location` inside `GET /admin/reports`.
|
||||||
|
|
||||||
|
**Send `tenantlocationid` on bookings.** Attribution depends on it. The server
|
||||||
|
will try to recognise the site from the pickup coordinates (within 150m) or a
|
||||||
|
matching address, but an explicit id is exact and always wins.
|
||||||
|
|
||||||
|
## Tenant customers (a client's own end customers)
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/tenantcustomers` | |
|
||||||
|
| POST | `/admin/tenantcustomers` | `{ firstname, lastname, phone, email }` |
|
||||||
|
| GET | `/admin/tenantcustomers/:id` | |
|
||||||
|
| PUT | `/admin/tenantcustomers/:id` | |
|
||||||
|
| DELETE | `/admin/tenantcustomers/:id` | |
|
||||||
|
|
||||||
|
## B2C app customers
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| GET | `/admin/customers` |
|
||||||
|
| PATCH | `/admin/customers/:id` |
|
||||||
|
|
||||||
|
Tenant-scoped through their bookings — a client login sees only customers who
|
||||||
|
have ordered through them.
|
||||||
|
|
||||||
|
## Hubs
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/hubs` | |
|
||||||
|
| POST | `/admin/hubs` | `{ hubname, hubtype, applocationid, contactno, address, latitude, longitude, pincode, status }` |
|
||||||
|
| GET | `/admin/hubs/:id` | |
|
||||||
|
| PUT | `/admin/hubs/:id` | |
|
||||||
|
| DELETE | `/admin/hubs/:id` | soft delete |
|
||||||
|
|
||||||
|
`hubtype`: `sorting_center` \| `delivery_hub`. `applocationid` is the city —
|
||||||
|
Nagercoil is 5; read the rest from `GET /admin/hubs` rather than hardcoding.
|
||||||
|
|
||||||
|
## Vehicles
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/vehicles` | |
|
||||||
|
| POST | `/admin/vehicles` | `{ vehicleno, vehicletype, maxweight, maxvolume, partnerid, batterypercentage, status }` |
|
||||||
|
| GET | `/admin/vehicles/:id` | |
|
||||||
|
| PUT | `/admin/vehicles/:id` | |
|
||||||
|
| DELETE | `/admin/vehicles/:id` | soft delete |
|
||||||
|
|
||||||
|
## Milers (riders)
|
||||||
|
|
||||||
|
| Method | Path | Body / notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/milers` | tenant-scoped; `?applocationid=&hubid=&tenantid=` |
|
||||||
|
| GET | `/admin/milers/summary` | **the roster table** — see below |
|
||||||
|
| POST | `/admin/milers` | see below |
|
||||||
|
| GET | `/admin/milers/:id` | 404 outside your fleet |
|
||||||
|
| GET | `/admin/milers/:id/logs` | GPS trail — see below |
|
||||||
|
| GET | `/admin/milers/:id/activity` | one rider's detail — see below |
|
||||||
|
| PUT | `/admin/milers/:id` | |
|
||||||
|
| PUT | `/admin/milers/:id/block` | |
|
||||||
|
| PUT | `/admin/milers/:id/assign-vehicle` | `{ vehicleid }` |
|
||||||
|
| POST | `/admin/milers/:id/notify` | `{ title, message }` — `:id` is the **milerprofileid** |
|
||||||
|
|
||||||
|
Every `:id` here is the **milerprofileid**, not the userid.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
POST /admin/milers
|
||||||
|
{
|
||||||
|
"authname": "Murali",
|
||||||
|
"email": "murali@dailygrubs.com",
|
||||||
|
"contactno": "9876543210",
|
||||||
|
"password": "1234",
|
||||||
|
"displayname": "Murali S",
|
||||||
|
"tenantid": 13,
|
||||||
|
"defaultvehicletype": "Bike",
|
||||||
|
"applocationid": 1,
|
||||||
|
"hubid": 4 // optional; without it the rider is invisible to the hub console
|
||||||
|
// configid defaults to 1001 — the partition the miler app logs in against.
|
||||||
|
// Do not override it. Riders created without it could never log in.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The rider screens
|
||||||
|
|
||||||
|
These are the express-console equivalents of jupiter's `getridersummary` and
|
||||||
|
its rider/delivery logs.
|
||||||
|
|
||||||
|
**`GET /admin/milers/summary?from=&to=&applocationid=&hubid=&tenantid=`**
|
||||||
|
|
||||||
|
One row per rider — live state on the left, range totals on the right. Defaults
|
||||||
|
to today. This is the rider list screen.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "milerprofileid": 78, "userid": 41, "displayname": "Murali P",
|
||||||
|
"phone": "…", "availabilitystatus": "Offline", "defaultvehicletype": "Bike",
|
||||||
|
"hubid": null, "hubname": "", "rating": 5,
|
||||||
|
"onduty": false, "dutystartedat": null,
|
||||||
|
"currentlatitude": 11.0163, "currentlongitude": 77.0147,
|
||||||
|
"lastlocationupdatedat": "…", "lastpingat": "2026-08-05T18:10:00Z",
|
||||||
|
"assigned": 0, "accepted": 0, "rejected": 0,
|
||||||
|
"completed": 1, "cancelled": 0, "delivered": 1,
|
||||||
|
"riderkms": 0, "ridercharges": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`lastpingat` comes from Redis telemetry, `lastlocationupdatedat` from the
|
||||||
|
profile — they differ, and the first is the better staleness signal.
|
||||||
|
|
||||||
|
**`GET /admin/milers/:id/logs?from=&to=&limit=`**
|
||||||
|
|
||||||
|
The GPS trail. `limit` defaults to 500, caps at 5000. Returns
|
||||||
|
`{ data: [MilerLog…], total, distancekm, miler: {…}, from, to }` where
|
||||||
|
`distancekm` is the haversine sum over consecutive points. Telemetry coords are
|
||||||
|
**strings**, since that's what the rider app sends.
|
||||||
|
|
||||||
|
**`GET /admin/milers/:id/activity?from=&to=`**
|
||||||
|
|
||||||
|
One rider's detail page: `assignments[]`, the `bookings[]` behind them,
|
||||||
|
`dutylogs[]`, `breaklogs[]`, `lastpingat`, and `totals: { assignments,
|
||||||
|
delivered, riderkms, ridercharges, dutyminutes }`.
|
||||||
|
|
||||||
|
## Bookings
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/bookings` | tenant-scoped list |
|
||||||
|
| POST | `/admin/expressbooking` | create one — passes CityGate |
|
||||||
|
| POST | `/admin/expressbooking/bulk` | `{ "bookings": [ ... ] }`, max 200, per-row results |
|
||||||
|
| GET | `/admin/bookings/:id` | 404 if outside your tenant |
|
||||||
|
| GET | `/admin/bookings/:id/track` | **the tracking screen** — see below |
|
||||||
|
| POST | `/admin/bookings/:id/assign-miler` | |
|
||||||
|
| POST | `/admin/bookings/:id/assign-vehicle` | |
|
||||||
|
| PUT | `/admin/bookings/:id/status` | |
|
||||||
|
| POST | `/admin/bookings/:id/cancel` | |
|
||||||
|
| POST | `/admin/bookings/bulk-cancel` | |
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
POST /admin/expressbooking
|
||||||
|
{
|
||||||
|
"tenantid": 13, // required; forced to your own tenant on client logins
|
||||||
|
"tenantlocationid": 13, // a stored kitchen/branch — fills address, pincode and
|
||||||
|
// coords for you, and is what per-site reporting groups by
|
||||||
|
"customer_phone": "9876543210", // creates a Guest customer if unknown
|
||||||
|
"customer_name": "Ramesh",
|
||||||
|
"deliveryaddress": "12 Cross Cut Road, Gandhipuram",
|
||||||
|
"deliverypincode": "641012",
|
||||||
|
"deliverycity": "Coimbatore",
|
||||||
|
"deliverylatitude": 11.0168,
|
||||||
|
"deliverylongitude": 76.9558,
|
||||||
|
"service_option": "Fast", // Normal | Fast | Superfast
|
||||||
|
"finalprice": 120, // the order amount the tenant pays — passed through
|
||||||
|
"notes": "Ring the bell",
|
||||||
|
"parcels": [
|
||||||
|
{ "itemcategory": "Food", "itemdescription": "2 meal boxes", "declaredvalue": 350 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules worth knowing:
|
||||||
|
- `parcels` must be non-empty and `tenantid` must exist.
|
||||||
|
- Pickup address + pincode are required **unless** `tenantlocationid` supplies them.
|
||||||
|
- A `tenantlocationid` belonging to another tenant is rejected.
|
||||||
|
- `pickuplocationid` is accepted as an alias for `tenantlocationid`, for anything
|
||||||
|
written against the earlier version of this doc. Prefer the new name: the
|
||||||
|
database column called `pickuplocationid` means something else entirely (the
|
||||||
|
B2C customer's saved address) and is not what per-site reporting uses.
|
||||||
|
- **CityGate**: the pickup pincode prefix must be an open city — `641`
|
||||||
|
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
|
||||||
|
Any other prefix is refused at the middleware, before the handler runs.
|
||||||
|
- Matching 3-digit pickup and delivery prefixes = hyperlocal, and the parcel goes
|
||||||
|
straight to `Out_for_Delivery` at pickup instead of routing via a hub.
|
||||||
|
- Auto-assignment fires after commit as a background retry loop (5 attempts,
|
||||||
|
2 min apart). The response returns before a rider is attached.
|
||||||
|
|
||||||
|
### Tracking one booking end to end
|
||||||
|
|
||||||
|
**`GET /admin/bookings/:id/track`** — one call for the whole lifecycle, so the
|
||||||
|
tracking screen doesn't have to stitch five requests together:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"booking": { …with parcels, serviceoptions, payments… },
|
||||||
|
"assignments": [ { mileruserid, assignmentstatus, assignedat, acceptedat,
|
||||||
|
completedat, riderkms, ridercharges } ], // every attempt
|
||||||
|
"riders": [ { userid, displayname, phone, … } ], // one per attempt
|
||||||
|
"livelocation": { "latitude": …, "longitude": … }, // null if no ping in 30 min
|
||||||
|
"lastpingat": "…",
|
||||||
|
"consignment": { … }, // present once picked up
|
||||||
|
"history": [ { eventstatus, remarks, createdat } ],
|
||||||
|
"telemetry": [ ConsignmentLog… ],
|
||||||
|
"deliveryproof": { deliveredtoname, photourl, geolatitude, … }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`assignments` holds **every** attempt, not just the current one — a rejected
|
||||||
|
first assignment is exactly what ops needs when asking why a pickup was slow.
|
||||||
|
|
||||||
|
## Consignments
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/consignments` | `?tenantid=` for staff |
|
||||||
|
| GET | `/admin/consignments/:id` | |
|
||||||
|
| GET | `/admin/consignments/:id/logs` | event history + telemetry + proof |
|
||||||
|
| GET | `/admin/consignments/track/:trackingno` | |
|
||||||
|
| PUT | `/admin/consignments/:id/status` | |
|
||||||
|
|
||||||
|
`/:id/logs` returns `{ consignment, history[], telemetry[], deliveryproof? }` —
|
||||||
|
the durable Postgres event log and the rider's Redis trail in one response,
|
||||||
|
rather than making the console reconcile two stores.
|
||||||
|
|
||||||
|
## Tripsheets (hub-to-hub transport)
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/tripsheets` | |
|
||||||
|
| POST | `/admin/tripsheets` | `{ sourcehubid, destinationhubid, vehicleid, driveruserid }` |
|
||||||
|
| GET | `/admin/tripsheets/:id` | |
|
||||||
|
| POST | `/admin/tripsheets/:id/items` | `{ consignmentid }` |
|
||||||
|
| DELETE | `/admin/tripsheets/:id/items/:itemid` | |
|
||||||
|
| PUT | `/admin/tripsheets/:id/dispatch` | |
|
||||||
|
| PUT | `/admin/tripsheets/:id/arrive` | |
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/pricing` | tenant pricing rules |
|
||||||
|
| POST | `/admin/pricing` | `{ tenantid, applocationid, vehicletype, baseprice, baseweight, priceperkg, basedistance, priceperkm, handlingcharges, effectivefrom, effectiveto, currency, priority, status }` |
|
||||||
|
| PUT | `/admin/pricing/:id` | |
|
||||||
|
| DELETE | `/admin/pricing/:id` | |
|
||||||
|
| POST | `/admin/pricing/simulate` | quote without creating anything |
|
||||||
|
| POST | `/admin/pricing/quote` | same handler as simulate |
|
||||||
|
| GET | `/admin/doormile-pricing` | Doormile's own bands |
|
||||||
|
| POST | `/admin/doormile-pricing` | |
|
||||||
|
| PUT | `/admin/doormile-pricing/:id` | |
|
||||||
|
| DELETE | `/admin/doormile-pricing/:id` | soft delete |
|
||||||
|
|
||||||
|
## Exceptions
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/exceptions` | |
|
||||||
|
| POST | `/admin/exceptions` | `{ consignmentid, tripsheetid, hubid, exceptiontype, severity, description }` |
|
||||||
|
| GET | `/admin/exceptions/:id` | |
|
||||||
|
| PUT | `/admin/exceptions/:id/status` | `{ resolution, status }` — `Resolved` \| `Closed` |
|
||||||
|
|
||||||
|
`exceptiontype`: `Lost`, `Damaged`, `Misrouted`, `Receiver_Refused`,
|
||||||
|
`Missing_Contents`, `Undeliverable`. `severity`: `Low`, `Medium`, `High`,
|
||||||
|
`Critical`.
|
||||||
|
|
||||||
|
## Competitive intel
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| GET/POST | `/admin/competitor-branches` |
|
||||||
|
| PUT/DELETE | `/admin/competitor-branches/:id` |
|
||||||
|
| GET/POST | `/admin/carrier-pricing` |
|
||||||
|
| PUT/DELETE | `/admin/carrier-pricing/:id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions across every endpoint
|
||||||
|
|
||||||
|
- **Envelope**: `{ "success": true, "data": ... }` on success,
|
||||||
|
`{ "success": false, "message": "..." }` on failure. Lists add `total`,
|
||||||
|
paginated lists add `page`.
|
||||||
|
- **Pagination**: `?pageno=1&pagesize=100`. Default 500, cap 1000.
|
||||||
|
- **Rate limits**: 300/min per IP globally, 10/min shared across all credential
|
||||||
|
endpoints. Behind the ingress this keys on the proxy IP unless
|
||||||
|
`TRUSTED_PROXIES` is set.
|
||||||
|
- **Timestamps** are IST (`Asia/Kolkata`) wall-clock in `timestamp without time
|
||||||
|
zone` columns. Send dates as `YYYY-MM-DD`, not epochs.
|
||||||
|
- **Soft delete** exists on Hub, Vehicle, Consignment, Tripsheet, TripsheetItem,
|
||||||
|
ConsignmentException, DoormilePricing, CarrierPricing. Partner and Tenant are
|
||||||
|
hard-deleted.
|
||||||
|
|
||||||
|
## Verified against production, 2026-08-06
|
||||||
|
|
||||||
|
Run as both a client login (`info@dailygrubs.com`, tenant 13) and Doormile
|
||||||
|
staff (`developer@doormile.com`, tenant 0):
|
||||||
|
|
||||||
|
- dashboard, reports, milers, milers/summary, milers/:id, milers/:id/logs,
|
||||||
|
milers/:id/activity, customers, bookings, bookings/:id, bookings/:id/track,
|
||||||
|
consignments, consignments/:id/logs, tenants, tenants/:id/locations
|
||||||
|
- `?tenantid=` narrowing on all of the above, for staff
|
||||||
|
- 403 on a client requesting another tenant; 404 on cross-tenant reads *and*
|
||||||
|
on `assign-miler` / `status` / `cancel` writes, with the target row confirmed
|
||||||
|
unmodified afterwards
|
||||||
|
|
||||||
|
Still unproven: tripsheets, exceptions, vehicles, competitor-branches,
|
||||||
|
carrier-pricing, doormile-pricing, app-users CRUD, partner CRUD, bulk booking
|
||||||
|
create/cancel, `assign-vehicle`, `block`. Written, compiled, never called with
|
||||||
|
a real request.
|
||||||
254
docs/jupiter2doormile.md
Normal file
254
docs/jupiter2doormile.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# jupiter → Doormile
|
||||||
|
|
||||||
|
What the old Nearle/jupiter API did, and what replaces it in Doormile. Two
|
||||||
|
surfaces only — the **express console** and the **miler app**. Hub console, CRM
|
||||||
|
and the B2C customer app are out of scope here.
|
||||||
|
|
||||||
|
Base URLs:
|
||||||
|
|
||||||
|
| | jupiter | Doormile |
|
||||||
|
|---|---|---|
|
||||||
|
| API | `jupiter.nearle.app/live/api/v1` | `api.doormile.com/api/v1` |
|
||||||
|
| Write path | `queue.workolik.com` (TLS verify off, hardcoded IP pin) | same host, no side channel |
|
||||||
|
|
||||||
|
**Confidence marking.** Paths marked ✅ were read off real network logs from the
|
||||||
|
live jupiter console. Paths marked ~ come from the prior-session analysis of the
|
||||||
|
jupiter codebase and have not been re-confirmed against a live request — check
|
||||||
|
the exact spelling before wiring anything to them.
|
||||||
|
|
||||||
|
Status: **Done** = built and hit with a real request · **Built** = written and
|
||||||
|
compiled, never called · **Gap** = nothing replaces it yet · **Dropped** =
|
||||||
|
deliberately not migrated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Auth
|
||||||
|
|
||||||
|
| jupiter | Doormile | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| ~ console login (undocumented in jupiter's own API docs — found only by reading the console source) | `POST /admin/login` → `{email, password}` | **Done** |
|
||||||
|
| ~ rider login | `POST /miler/login` then `POST /miler/verify-pin` | **Done** |
|
||||||
|
|
||||||
|
Two real differences:
|
||||||
|
|
||||||
|
- Doormile splits rider login into **phone → PIN**, two calls. jupiter did it in
|
||||||
|
one.
|
||||||
|
- The Doormile console token carries **`tenantid`**. jupiter had no tenant
|
||||||
|
concept on the login at all; every console user saw everything. This is the
|
||||||
|
single biggest behavioural change for a client account.
|
||||||
|
- `configid` must be **1001** on both miler calls. There is no jupiter
|
||||||
|
equivalent — it's a Doormile login partition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Express console
|
||||||
|
|
||||||
|
### 2.1 Rider screens
|
||||||
|
|
||||||
|
| jupiter | Doormile | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| ✅ `GET /deliveries/getridersummary/?applocationid=&fromdate=&todate=` | `GET /admin/milers/summary?applocationid=&from=&to=&tenantid=&hubid=` | **Done** |
|
||||||
|
| ~ rider list | `GET /admin/milers?applocationid=&hubid=&tenantid=` | **Done** |
|
||||||
|
| ~ rider detail | `GET /admin/milers/:id` | **Done** |
|
||||||
|
| ~ `riderlogs` (the 1.17M-row, zero-index table) | `GET /admin/milers/:id/logs?from=&to=&limit=` | **Done** |
|
||||||
|
| ✅ `getriderlocationsummary` *(name confirmed, path inferred)* | covered by `milers/summary` (`currentlatitude/longitude`, `lastpingat`) and `milers/:id/logs` | **Done** |
|
||||||
|
| — *(no jupiter equivalent)* | `GET /admin/milers/:id/activity?from=&to=` | **Done** |
|
||||||
|
| ~ rider create/edit | `POST /admin/milers`, `PUT /admin/milers/:id` | **Done** |
|
||||||
|
| ~ block rider | `PUT /admin/milers/:id/block` | **Built** |
|
||||||
|
| ~ assign vehicle | `PUT /admin/milers/:id/assign-vehicle` | **Built** |
|
||||||
|
| — | `POST /admin/milers/:id/notify` | **Done** |
|
||||||
|
|
||||||
|
Parameter translation: jupiter used `fromdate`/`todate`, Doormile uses
|
||||||
|
`from`/`to`. Both `YYYY-MM-DD`. jupiter's `applocationid=0` meant "all cities";
|
||||||
|
Doormile means the same by **omitting** the param.
|
||||||
|
|
||||||
|
### 2.2 Orders / deliveries
|
||||||
|
|
||||||
|
| jupiter | Doormile | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| ✅ `GET /deliveries/getdeliveries/` | `GET /admin/bookings` + `GET /admin/consignments` | **Done** |
|
||||||
|
| ~ `getdelivery` / `getorders` | `GET /admin/bookings/:id`, `GET /admin/consignments/:id` | **Done** |
|
||||||
|
| ~ `POST /deliveries/createdeliveries` | `POST /admin/expressbooking` | **Done** |
|
||||||
|
| ~ `createdeliveries` in bulk | `POST /admin/expressbooking/bulk` (max 200, per-row results) | **Built** |
|
||||||
|
| ~ `PUT /deliveries/updatedelivery` | **split into 11 endpoints** — see §4 | **Done / partial** |
|
||||||
|
| — | `GET /admin/bookings/:id/track` | **Done** |
|
||||||
|
| — | `GET /admin/consignments/:id/logs` | **Done** |
|
||||||
|
| — | `GET /admin/consignments/track/:trackingno` | **Built** |
|
||||||
|
|
||||||
|
Two jupiter bugs that do not carry over, by construction:
|
||||||
|
|
||||||
|
- `getdeliveries` returned **every row 21×** (unconstrained `LEFT JOIN
|
||||||
|
tenantpricing`, `DISTINCT` over 87 columns that deduped nothing). Doormile's
|
||||||
|
list endpoints are paginated (`pageno`/`pagesize`, default 500, cap 1000) and
|
||||||
|
return one row per booking.
|
||||||
|
- `createdeliveries` had a quadratic insert bug — a slice declared outside the
|
||||||
|
loop kept accumulating, producing ~2× duplicate `deliveryqueues` rows
|
||||||
|
(66,446 deliveries → 132,826 rows, confirmed live). `createExpressBooking` is
|
||||||
|
a single transaction per booking; `/bulk` loops it and reports per-row.
|
||||||
|
|
||||||
|
### 2.3 Reporting
|
||||||
|
|
||||||
|
| jupiter | Doormile | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| ✅ `GET /deliveries/getreportsummary/?applocationid=&tenantid=&locationid=&fromdate=&todate=` | `GET /admin/reports?from=&to=&tenantid=&locationid=&hubid=` | **Done** |
|
||||||
|
| ~ `getlocationsummary` | `GET /admin/locations/summary?tenantid=&locationid=&from=&to=` | **Done** |
|
||||||
|
| — | `GET /admin/dashboard?tenantid=` | **Done** |
|
||||||
|
|
||||||
|
`locationid` is supported: it narrows every figure to one client site, and
|
||||||
|
`/admin/reports` now carries a `by_location` block alongside `by_hub`,
|
||||||
|
`by_tenant` and `by_rider`.
|
||||||
|
|
||||||
|
**Attribution caveat.** Per-site figures group by `tenantlocationid` on the
|
||||||
|
booking — a column added 2026-08-06. The pre-existing `pickuplocationid` column
|
||||||
|
is *not* it: that one foreign-keys to `appcustomerlocations`, the B2C customer's
|
||||||
|
saved address, so writing a client-site id into it fails the insert. Every
|
||||||
|
booking created before 2026-08-06 has no site at all.
|
||||||
|
|
||||||
|
Since the console sends a kitchen's *address* rather than its id,
|
||||||
|
`createExpressBooking` resolves the site itself — nearest stored location within
|
||||||
|
150m, falling back to an address match. Bookings with no site are reported as
|
||||||
|
their own `"Unattributed"` row rather than dropped, so per-site rows still add
|
||||||
|
up to the summary total. Sending `tenantlocationid` explicitly is exact and
|
||||||
|
always wins.
|
||||||
|
|
||||||
|
**`applocationid` (city) is still not a report parameter.** jupiter had it;
|
||||||
|
Doormile filters by `hubid` instead. Only matters once one client runs in more
|
||||||
|
than one city.
|
||||||
|
|
||||||
|
### 2.4 Tenants and their sites
|
||||||
|
|
||||||
|
| jupiter | Doormile | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| ✅ `GET /tenants/gettenants/` | `GET /admin/tenants` | **Done** |
|
||||||
|
| ✅ `GET /tenants/gettenantlocations/` | `GET /admin/tenants/:id/locations` | **Done** |
|
||||||
|
| ~ `getlocations` / `getlocation` / `getlocationdetails` | same as above | **Done** |
|
||||||
|
| ~ tenant create/edit | `POST /admin/tenants`, `PUT /admin/tenants/:id` | **Done** |
|
||||||
|
| ~ location create/edit | `POST /admin/tenants/:id/locations`, `PUT /admin/tenantlocations/:id` | **Done** |
|
||||||
|
| ~ `getbranches` | `GET /admin/hubs` — *jupiter "branches" ≈ Doormile hubs; verify this is the same concept before relying on it* | **Built** |
|
||||||
|
| ~ `getlocationsummary` | `GET /admin/locations/summary` — see §2.3 | **Done** |
|
||||||
|
|
||||||
|
Doormile adds `locationname` on a tenant location. jupiter identified a site by
|
||||||
|
its address alone, which does not distinguish two branches on one street.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Miler app
|
||||||
|
|
||||||
|
jupiter's rider app drove almost everything through one overloaded endpoint.
|
||||||
|
Doormile gives each action its own route.
|
||||||
|
|
||||||
|
| jupiter action | Doormile | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| ~ rider login | `POST /miler/login` + `POST /miler/verify-pin` | **Done** |
|
||||||
|
| ~ PIN reset | `POST /miler/reset-pin` — **now admin-only**, see §5 | **Done** |
|
||||||
|
| ~ location ping | `PUT /miler/location` | **Done** |
|
||||||
|
| ~ availability toggle | `PUT /miler/availability` | **Done** |
|
||||||
|
| ~ assignment list | `GET /miler/assignments`, `GET /miler/assignments/:id` | **Done** |
|
||||||
|
| ~ accept | `POST /miler/assignments/:id/accept` | **Done** |
|
||||||
|
| ~ reject | `POST /miler/assignments/:id/reject` | **Built** |
|
||||||
|
| ~ rider logs write | `POST /miler/logs`, `POST /miler/status` | **Done** |
|
||||||
|
| ~ per-delivery logs | `POST /miler/consignments/logs` | **Done** |
|
||||||
|
| — | `POST /miler/duty/start`, `PUT /miler/duty/end`, `GET /miler/duty/current` | **Done** |
|
||||||
|
| — | `POST /miler/breaks/start`, `PUT /miler/breaks/end` | **Done** |
|
||||||
|
| — | `GET /miler/earnings` | **Done** |
|
||||||
|
| — | `POST /miler/support`, `GET /miler/support` | **Built** |
|
||||||
|
| — | `GET /miler/notifications` | **Done** |
|
||||||
|
| — | `PATCH /miler/notifications/:id/read` | **Gap** — stub, persists nothing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `PUT /deliveries/updatedelivery` — the 11-way split
|
||||||
|
|
||||||
|
This is the centre of the migration. jupiter overloaded one endpoint for **11
|
||||||
|
distinct real actions**, distinguished only by which JSON fields happened to be
|
||||||
|
non-empty. Each is now its own route with its own validation and its own status
|
||||||
|
transition.
|
||||||
|
|
||||||
|
**Eight rider actions:**
|
||||||
|
|
||||||
|
| Action | Doormile |
|
||||||
|
|---|---|
|
||||||
|
| reached pickup | `POST /miler/bookings/:bookingid/reached` |
|
||||||
|
| confirm parcel + dimensions | `POST /miler/bookings/:bookingid/parcel` |
|
||||||
|
| collect payment | `POST /miler/bookings/:bookingid/payment` |
|
||||||
|
| pickup complete | `POST /miler/bookings/:bookingid/pickup-complete` |
|
||||||
|
| needs a bigger vehicle | `POST /miler/bookings/:bookingid/vehicle-required` |
|
||||||
|
| cancel before pickup | `POST /miler/bookings/:bookingid/cancel` |
|
||||||
|
| deliver | `POST /miler/consignments/:id/deliver` |
|
||||||
|
| skip / failed attempt | `POST /miler/consignments/:id/skip` |
|
||||||
|
|
||||||
|
**Three console actions** that were bundled into the same rider endpoint:
|
||||||
|
|
||||||
|
| Action | Doormile |
|
||||||
|
|---|---|
|
||||||
|
| assign a rider | `POST /admin/bookings/:id/assign-miler` |
|
||||||
|
| change status | `PUT /admin/bookings/:id/status` · `PUT /admin/consignments/:id/status` |
|
||||||
|
| cancel | `POST /admin/bookings/:id/cancel` · `POST /admin/bookings/bulk-cancel` |
|
||||||
|
|
||||||
|
`pickup-complete` is the pivot the old system had no concept of: it converts the
|
||||||
|
booking into a **consignment**, recomputes chargeable weight from the dimensions
|
||||||
|
the rider entered, and decides routing — matching 3-digit pincode prefixes go
|
||||||
|
straight to `Out_for_Delivery` (hyperlocal), everything else routes via a hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Behaviour changes that break a naive repoint
|
||||||
|
|
||||||
|
Response shapes are completely different — flat 87- and 92-column jupiter rows
|
||||||
|
versus nested Doormile JSON. Every screen that parses a response needs
|
||||||
|
rewriting, not repointing. Beyond that:
|
||||||
|
|
||||||
|
1. **Tenant scoping is real now.** A client console login sees only its own
|
||||||
|
tenant. `?tenantid=` narrows for Doormile staff; a client passing another
|
||||||
|
tenant's id gets **403**. Cross-tenant reads of a single resource return
|
||||||
|
**404**, not 403, so ids aren't probeable. jupiter had none of this.
|
||||||
|
2. **`configid` 1001** on every miler auth call. No jupiter equivalent.
|
||||||
|
3. **PIN reset is admin-only.** jupiter let anyone reset a rider PIN with just a
|
||||||
|
phone number, which is the login identifier, not a secret. Two calls took over
|
||||||
|
any account. The rider app must not call `/miler/reset-pin` — route resets
|
||||||
|
through ops.
|
||||||
|
4. **Identity comes from the token, never the body.** jupiter's telemetry
|
||||||
|
endpoints took `userid` from the request body. Doormile ignores it.
|
||||||
|
5. **Telemetry lat/long/speed/battery are strings**, and
|
||||||
|
`POST /miler/consignments/logs` takes a **bare JSON array**.
|
||||||
|
6. **Delivery OTP is opt-in per tenant** (`Tenant.Requiredeliveryotp`), default
|
||||||
|
off. Off for DailyGrubs. When on, it's verified server-side.
|
||||||
|
7. **Dates**: `from`/`to`, not `fromdate`/`todate`. IST wall-clock throughout.
|
||||||
|
8. **CityGate**: a booking's pickup pincode prefix must be an open city — `641`
|
||||||
|
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
|
||||||
|
jupiter had no such gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gaps — jupiter did this, Doormile does not yet
|
||||||
|
|
||||||
|
| What | Detail |
|
||||||
|
|---|---|
|
||||||
|
| `applocationid` on reports | jupiter could filter a report by city. Doormile filters by `hubid`. Only bites when one client operates in several cities. |
|
||||||
|
| **Route optimisation** | jupiter used external paid services (`routes.workolik.com`) for multi-stop sequencing. Nothing in Doormile replaces true stop-ordering. `HubBatchAssign` decides *who* gets a booking, not *what order* to run stops in. |
|
||||||
|
| Notifications read-state | `PATCH /miler/notifications/:id/read` is a stub; no table exists. |
|
||||||
|
| `riderkms` / `ridercharges` backfill | Populated on new deliveries only. Rows completed before 2026-08-06 read 0 and will not backfill themselves. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dropped on purpose
|
||||||
|
|
||||||
|
| What | Why |
|
||||||
|
|---|---|
|
||||||
|
| `/v1/substitutions` CRUD | Rider substitutions. Low traffic in the old system; Suriya's call. Revisit if it turns out to matter. |
|
||||||
|
| jupiter's v2 endpoints | They wrote **only to Redis**, invisible to the v1/v3 Postgres reads — genuine split-brain, with a Redis `INCR` id space that could collide with the Postgres sequence. Doormile keeps Redis for ephemeral telemetry only; durable state is always Postgres. |
|
||||||
|
| `queue.workolik.com` write path | Separate host with TLS verification disabled and a hardcoded IP pin. Not reproduced. |
|
||||||
|
| ~20 never-populated columns on `orders`, 6 lat/lng pairs for 3 real points on `deliveries`, status spread across 6 text+timestamp column pairs | Replaced by a normalised schema with a real event-log table (`consignmenthistory`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. What is not migrated at all
|
||||||
|
|
||||||
|
Nothing on the client side has moved. The rider Flutter app and
|
||||||
|
`doormile_express_console` still call `jupiter.nearle.app`. Doormile having the
|
||||||
|
endpoint does not mean traffic uses it.
|
||||||
|
|
||||||
|
Suggested order: pick one net-new console screen (the rider summary, or
|
||||||
|
reports) and wire it to Doormile first — it replaces nothing live, so it is the
|
||||||
|
cheapest real proof the cutover works. Then the higher-traffic screens
|
||||||
|
(deliveries list, rider status updates), then the rider app.
|
||||||
238
docs/miler-app-api.md
Normal file
238
docs/miler-app-api.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Doormile Miler App — API reference
|
||||||
|
|
||||||
|
The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated.
|
||||||
|
Base URL `https://api.doormile.com/api/v1`.
|
||||||
|
|
||||||
|
This supersedes "Miler App API Contract v1.0" where the two disagree — several
|
||||||
|
shapes in that doc never matched the code. The known mismatches are called out
|
||||||
|
inline below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Two-step: phone → PIN. **`configid` is `1001`** — that's the partition riders
|
||||||
|
live in. It defaults to 1001 if omitted, but a miler *row* created without it
|
||||||
|
can never log in, so the console must set it at creation time.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /miler/login
|
||||||
|
{ "phone": "9876543210", "configid": 1001 }
|
||||||
|
→ { success, message: "PIN verification required", phone }
|
||||||
|
404 if no account, 403 if not role 5 or not Active
|
||||||
|
|
||||||
|
POST /miler/verify-pin
|
||||||
|
{ "phone": "9876543210", "pin": "1234", "configid": 1001, "device_token": "fcm..." }
|
||||||
|
→ { success, token, user: { userid, authname, email, contactno, profile: {…MilerProfile…} } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The verify-pin response has **no `data` key** — the fields the old contract doc
|
||||||
|
listed as flat (`displayname`, `hubid`, `availabilitystatus`, `rating`) live
|
||||||
|
under `user.profile`.
|
||||||
|
|
||||||
|
Send the token as `Authorization: Bearer <token>` on everything else. Role 5 is
|
||||||
|
enforced; an admin token gets 401 here.
|
||||||
|
|
||||||
|
### PIN reset is not self-service
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /miler/reset-pin ← requires an ADMIN token (roles 1/3/4)
|
||||||
|
{ "phone": "9876543210", "new_pin": "1234", "configid": 1001 }
|
||||||
|
```
|
||||||
|
|
||||||
|
It sits under `/miler` but it is a console/ops operation. It was previously
|
||||||
|
open, and reset-pin → verify-pin took over any rider account with nothing but a
|
||||||
|
phone number. The app must not call this; route rider PIN resets through ops.
|
||||||
|
|
||||||
|
Credential endpoints share a **10/min** rate limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profile & device
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/miler/profile` | |
|
||||||
|
| PUT | `/miler/profile` | `{ displayname, profilephotourl, defaultvehicletype, phone }` |
|
||||||
|
| PUT | `/miler/device-token` | `{ "device_token": "..." }` — snake_case |
|
||||||
|
|
||||||
|
## Location & availability
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| PUT | `/miler/location` | `{ latitude, longitude, pincode, speed, heading }` |
|
||||||
|
| PUT | `/miler/availability` | `{ "status": "Available" }` |
|
||||||
|
|
||||||
|
`PUT /location` writes Redis only — a SET plus a `GEOADD` into
|
||||||
|
`milers:locations`, which is the index dispatch searches (10km radius,
|
||||||
|
nearest 10). No NATS publish, despite what the old doc claimed. `speed` and
|
||||||
|
`heading` are accepted and reach the telemetry log; they used to be silently
|
||||||
|
dropped.
|
||||||
|
|
||||||
|
`PUT /availability` accepts **either** `status` or `availabilitystatus` —
|
||||||
|
the doc told the Flutter side to send the second, the code only read the
|
||||||
|
first, so both are honoured now rather than picking a winner.
|
||||||
|
|
||||||
|
Valid statuses: `Offline`, `Available`, `Assigned`, `On_Pickup`, `At_Customer`,
|
||||||
|
`Picked_Up`, `On_Delivery`, `Break`, `Blocked`. Note it's **`Break`**, not
|
||||||
|
`On_Break`.
|
||||||
|
|
||||||
|
## Duty & breaks
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/miler/duty/start` | `{ lat, lon }` |
|
||||||
|
| PUT | `/miler/duty/end` | |
|
||||||
|
| GET | `/miler/duty/current` | |
|
||||||
|
| POST | `/miler/breaks/start` | `{ "breaktype": "Lunch" }` |
|
||||||
|
| PUT | `/miler/breaks/end` | |
|
||||||
|
|
||||||
|
Ordering is enforced: starting duty twice returns "already on duty, end current
|
||||||
|
duty first"; a break without duty returns "not on duty".
|
||||||
|
|
||||||
|
## Assignments
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/miler/assignments` | the rider's own queue |
|
||||||
|
| GET | `/miler/assignments/:id` | |
|
||||||
|
| POST | `/miler/assignments/:id/accept` | |
|
||||||
|
| POST | `/miler/assignments/:id/reject` | `{ "reason": "too far" }` |
|
||||||
|
|
||||||
|
## The pickup flow
|
||||||
|
|
||||||
|
In order, all keyed on `:bookingid`:
|
||||||
|
|
||||||
|
| Step | Method | Path | Body |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | POST | `/miler/bookings/:bookingid/reached` | — |
|
||||||
|
| 2 | POST | `/miler/bookings/:bookingid/parcel` | `{ "parcels": [{ parcel_id, weight, length, width, height }] }` |
|
||||||
|
| 3 | POST | `/miler/bookings/:bookingid/payment` | `{ amount, paymentmode, transactionref }` |
|
||||||
|
| 4 | POST | `/miler/bookings/:bookingid/pickup-complete` | — |
|
||||||
|
|
||||||
|
Escape hatches:
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/miler/bookings/:bookingid/vehicle-required` | params are **query strings**: `?type=truck&reason=...` |
|
||||||
|
| POST | `/miler/bookings/:bookingid/cancel` | `{ "reason": "..." }` — refused once picked up |
|
||||||
|
|
||||||
|
`paymentmode`: `Cash`, `UPI`, `Card`, `Wallet`. Amount must be > 0.
|
||||||
|
|
||||||
|
**`pickup-complete` is the pivot.** It converts the booking into a consignment,
|
||||||
|
recomputes chargeable weight from the parcel dimensions the rider entered in
|
||||||
|
step 2, and decides routing: if the pickup and delivery pincodes share a 3-digit
|
||||||
|
prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery`
|
||||||
|
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
|
||||||
|
the **booking's** tenant, not the rider's.
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/miler/consignments/:id/deliver` | `{ deliveredtoname, otp, photourl, receiversignatureurl, lat, lon }` |
|
||||||
|
| POST | `/miler/consignments/:id/skip` | `{ reason, lat, lon }` |
|
||||||
|
|
||||||
|
- `deliveredtoname` is required. `reason` is required on skip.
|
||||||
|
- The consignment must be `Out_for_Delivery` or both return 400.
|
||||||
|
- **`otp` is only required when the tenant has `requiredeliveryotp` on.**
|
||||||
|
It's off by default, and off for DailyGrubs. When it is on, the OTP is checked
|
||||||
|
server-side — a non-empty string is no longer enough.
|
||||||
|
- `lat`/`lon` should be the actual delivery point: `deliver` computes
|
||||||
|
`riderkms` from the pickup coords by haversine and writes it with
|
||||||
|
`ridercharges` (the tenant's order amount, passed through from the booking's
|
||||||
|
`finalprice`) onto the earnings record.
|
||||||
|
- `skip` bumps `attemptcount` rather than failing the consignment.
|
||||||
|
|
||||||
|
## Bookings & earnings
|
||||||
|
|
||||||
|
| Method | Path | Query |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` |
|
||||||
|
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` |
|
||||||
|
|
||||||
|
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
|
||||||
|
|
||||||
|
## Telemetry (Redis-backed, high frequency)
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/miler/logs` | one `MilerLog` |
|
||||||
|
| GET | `/miler/logs` | |
|
||||||
|
| POST | `/miler/status` | `{ "status": "Available" }` |
|
||||||
|
| GET | `/miler/status` | |
|
||||||
|
| POST | `/miler/consignments/logs` | a **JSON array** of `ConsignmentLog` |
|
||||||
|
| GET | `/miler/consignments/logs/:consignmentid` | |
|
||||||
|
| GET | `/miler/consignments/userlogs/:userid` | must be your own userid |
|
||||||
|
|
||||||
|
**Lat/long/speed/heading/battery on these are strings, not numbers.** Sending
|
||||||
|
numbers fails to parse.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
POST /miler/logs
|
||||||
|
{
|
||||||
|
"logdate": "2026-08-06 14:32:10", // YYYY-MM-DD HH:MM:SS, IST
|
||||||
|
"latitude": "11.0168", "longitude": "76.9558",
|
||||||
|
"speed": "24.5", "heading": "180", "accuracy": "8",
|
||||||
|
"status": "On_Delivery", "orderid": "25",
|
||||||
|
"battery": "72", "is_charging": false,
|
||||||
|
"connection": "4G", "location_service": "enabled", "is_background": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
POST /miler/consignments/logs
|
||||||
|
[ { "consignmentid": 25, "logdate": "2026-08-06 14:32:10",
|
||||||
|
"latitude": "11.0168", "longitude": "76.9558",
|
||||||
|
"speed": "24.5", "heading": "180",
|
||||||
|
"status": "Out_for_Delivery", "remarks": "", "battery": "72",
|
||||||
|
"is_background": true } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not send `userid` in these bodies.** All three used to read the rider
|
||||||
|
identity from the request body, which let any logged-in rider write another
|
||||||
|
rider's GPS into the dispatch index. Identity now comes from the token and a
|
||||||
|
body `userid` is ignored; `/userlogs/:userid` rejects anyone else's id.
|
||||||
|
|
||||||
|
Redis is never the system of record here — a flush loses telemetry, not
|
||||||
|
business state.
|
||||||
|
|
||||||
|
## Notifications & support
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/miler/notifications` | |
|
||||||
|
| PATCH | `/miler/notifications/:id/read` | **stub** — see below |
|
||||||
|
| POST | `/miler/support` | `{ subject, description }` |
|
||||||
|
| GET | `/miler/support` | |
|
||||||
|
|
||||||
|
Notifications are synthesized fresh from `BookingAssignment` rows on every GET,
|
||||||
|
and `id` is just the array index. `PATCH .../read` returns `{success: true}`
|
||||||
|
without persisting anything, because there's no notifications table with read
|
||||||
|
state. Read state cannot stick between calls until that table exists — don't
|
||||||
|
build a UI that depends on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Envelope**: `{ "success": true, "data": ... }`; failures are
|
||||||
|
`{ "success": false, "message": "..." }`.
|
||||||
|
- **Rate limits**: 300/min per IP globally, 10/min across login/verify-pin/
|
||||||
|
reset-pin.
|
||||||
|
- **Timestamps** are IST wall-clock. Send `YYYY-MM-DD HH:MM:SS` on telemetry,
|
||||||
|
`YYYY-MM-DD` on date filters.
|
||||||
|
- **Status enums** — booking: `Pending_Pickup`, `Created`, `Miler_Assigned`,
|
||||||
|
`Pickup_Scheduled`, `Picked_Up`, `Converted_To_Consignment`, `Cancelled`.
|
||||||
|
Consignment: `Created`, `Inwarded_at_Hub`, `Tripsheet_Loaded`, `In_Transit`,
|
||||||
|
`Out_for_Delivery`, `Delivered`, `RTO_Initiated`, `Returned_to_Sender`,
|
||||||
|
`Missing`, `Damaged`. Assignment: `Assigned`, `Accepted`, `Rejected`,
|
||||||
|
`Reassigned`, `Completed`, `Cancelled`.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
1. `PATCH /notifications/:id/read` is a stub — needs a real table, schema not
|
||||||
|
decided.
|
||||||
|
2. `bonuspoints` is never written.
|
||||||
|
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
|
||||||
|
real request against them.
|
||||||
42
dto/admin.go
42
dto/admin.go
@@ -7,17 +7,23 @@ type TenantCreateRequest struct {
|
|||||||
Primaryemail string `json:"primaryemail"`
|
Primaryemail string `json:"primaryemail"`
|
||||||
Primarycontact string `json:"primarycontact"`
|
Primarycontact string `json:"primarycontact"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
// Requiredeliveryotp is a pointer so an update that omits it leaves the
|
||||||
|
// existing setting alone rather than silently switching OTPs off.
|
||||||
|
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TenantLocationCreateRequest struct {
|
type TenantLocationCreateRequest struct {
|
||||||
Address string `json:"address"`
|
// Locationname is the client's own label for the site — "DailyGrubs
|
||||||
City string `json:"city"`
|
// Peelamedu Kitchen" — since an address alone doesn't identify a branch.
|
||||||
State string `json:"state"`
|
Locationname string `json:"locationname"`
|
||||||
Pincode string `json:"pincode"`
|
Address string `json:"address"`
|
||||||
Latitude float64 `json:"latitude"`
|
City string `json:"city"`
|
||||||
Longitude float64 `json:"longitude"`
|
State string `json:"state"`
|
||||||
Isprimary bool `json:"isprimary"`
|
Pincode string `json:"pincode"`
|
||||||
Status string `json:"status"`
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Isprimary bool `json:"isprimary"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TenantCustomerCreateRequest struct {
|
type TenantCustomerCreateRequest struct {
|
||||||
@@ -57,13 +63,23 @@ type VehicleCreateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MilerCreateRequest struct {
|
type MilerCreateRequest struct {
|
||||||
Authname string `json:"authname"`
|
Authname string `json:"authname"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Contactno string `json:"contactno"`
|
Contactno string `json:"contactno"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Displayname string `json:"displayname"`
|
Displayname string `json:"displayname"`
|
||||||
|
// Tenantid attaches a rider to the client they deliver for — riders migrated
|
||||||
|
// from jupiter belong to a specific client (DailyGrubs, Bawa Medicals)
|
||||||
|
// rather than to Doormile's general pool. Zero leaves them unattached.
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
Defaultvehicletype string `json:"defaultvehicletype"`
|
Defaultvehicletype string `json:"defaultvehicletype"`
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
|
// Configid partitions logins; defaults to 1001, which is what the miler app
|
||||||
|
// authenticates against. Only set this if you know why you're changing it.
|
||||||
|
Configid int `json:"configid"`
|
||||||
|
// Hubid is optional: a rider with no hub is still assignable from the
|
||||||
|
// express console, but is invisible to the hub console's miler list.
|
||||||
|
Hubid *int `json:"hubid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingCreateRequest struct {
|
type PricingCreateRequest struct {
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ type ParcelRequest struct {
|
|||||||
Itemcategory string `json:"itemcategory"`
|
Itemcategory string `json:"itemcategory"`
|
||||||
Itemdescription string `json:"itemdescription"`
|
Itemdescription string `json:"itemdescription"`
|
||||||
Declaredvalue float64 `json:"declaredvalue"`
|
Declaredvalue float64 `json:"declaredvalue"`
|
||||||
Weight float64 `json:"weight"` // optional — miler weighs at pickup
|
Weight float64 `json:"weight"` // optional — miler weighs at pickup
|
||||||
Length float64 `json:"length"` // optional
|
Length float64 `json:"length"` // optional
|
||||||
Width float64 `json:"width"` // optional
|
Width float64 `json:"width"` // optional
|
||||||
Height float64 `json:"height"` // optional
|
Height float64 `json:"height"` // optional
|
||||||
Isfragile bool `json:"isfragile"`
|
Isfragile bool `json:"isfragile"`
|
||||||
Needsinsurance bool `json:"needsinsurance"`
|
Needsinsurance bool `json:"needsinsurance"`
|
||||||
Requireslargevehicle bool `json:"requireslargevehicle"`
|
Requireslargevehicle bool `json:"requireslargevehicle"`
|
||||||
@@ -31,8 +31,8 @@ type ParcelRequest struct {
|
|||||||
|
|
||||||
type PickupBookingRequest struct {
|
type PickupBookingRequest struct {
|
||||||
Pickuplocationid *int `json:"pickuplocationid"`
|
Pickuplocationid *int `json:"pickuplocationid"`
|
||||||
Pickupaddress string `json:"pickupaddress"` // required
|
Pickupaddress string `json:"pickupaddress"` // required
|
||||||
Pickuppincode string `json:"pickuppincode"` // required
|
Pickuppincode string `json:"pickuppincode"` // required
|
||||||
Pickuplatitude float64 `json:"pickuplatitude"`
|
Pickuplatitude float64 `json:"pickuplatitude"`
|
||||||
Pickuplongitude float64 `json:"pickuplongitude"`
|
Pickuplongitude float64 `json:"pickuplongitude"`
|
||||||
Deliveryaddress string `json:"deliveryaddress"` // optional — can be filled later
|
Deliveryaddress string `json:"deliveryaddress"` // optional — can be filled later
|
||||||
@@ -55,18 +55,36 @@ type MilerLocationUpdateRequest struct {
|
|||||||
Latitude float64 `json:"latitude"`
|
Latitude float64 `json:"latitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Pincode string `json:"pincode"`
|
Pincode string `json:"pincode"`
|
||||||
|
// Speed and Heading are sent by the rider app and were previously dropped on
|
||||||
|
// the floor, since unknown JSON fields parse silently. Accepted here so the
|
||||||
|
// values at least reach the periodic-log telemetry rather than vanishing.
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
Heading float64 `json:"heading"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MilerAvailabilityRequest struct {
|
type MilerAvailabilityRequest struct {
|
||||||
Status string `json:"status"` // Offline, Available, Break, etc.
|
Status string `json:"status"` // Offline, Available, Break, etc.
|
||||||
|
// Availabilitystatus is the field name the published Miler App API Contract
|
||||||
|
// v1.0 told the Flutter dev to send, while the code only ever read "status"
|
||||||
|
// — so the documented request 400s. Both are accepted rather than picking a
|
||||||
|
// winner, because either side may already be built against either name.
|
||||||
|
Availabilitystatus string `json:"availabilitystatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedStatus returns whichever of the two accepted field names was sent.
|
||||||
|
func (r MilerAvailabilityRequest) ResolvedStatus() string {
|
||||||
|
if r.Status != "" {
|
||||||
|
return r.Status
|
||||||
|
}
|
||||||
|
return r.Availabilitystatus
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingQuoteRequest struct {
|
type PricingQuoteRequest struct {
|
||||||
Pickuppincode string `json:"pickuppincode"`
|
Pickuppincode string `json:"pickuppincode"`
|
||||||
Deliverypincode string `json:"deliverypincode"`
|
Deliverypincode string `json:"deliverypincode"`
|
||||||
Pickuplatitude float64 `json:"pickuplatitude"`
|
Pickuplatitude float64 `json:"pickuplatitude"`
|
||||||
Pickuplongitude float64 `json:"pickuplongitude"`
|
Pickuplongitude float64 `json:"pickuplongitude"`
|
||||||
Deliverylatitude float64 `json:"deliverylatitude"`
|
Deliverylatitude float64 `json:"deliverylatitude"`
|
||||||
Deliverylongitude float64 `json:"deliverylongitude"`
|
Deliverylongitude float64 `json:"deliverylongitude"`
|
||||||
Parcels []ParcelRequest `json:"parcels"`
|
Parcels []ParcelRequest `json:"parcels"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var operatingCityPrefixes = map[string]string{
|
|||||||
"600": "Chennai",
|
"600": "Chennai",
|
||||||
"560": "Bengaluru",
|
"560": "Bengaluru",
|
||||||
"500": "Hyderabad",
|
"500": "Hyderabad",
|
||||||
|
"629": "Nagercoil",
|
||||||
}
|
}
|
||||||
|
|
||||||
// CityGateMiddleware rejects bookings from pincodes outside Doormile's operating cities.
|
// CityGateMiddleware rejects bookings from pincodes outside Doormile's operating cities.
|
||||||
|
|||||||
@@ -32,13 +32,17 @@ func (Pricing) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Consignment struct {
|
type Consignment struct {
|
||||||
Consignmentid int `json:"consignmentid" gorm:"primaryKey;column:consignmentid"`
|
Consignmentid int `json:"consignmentid" gorm:"primaryKey;column:consignmentid"`
|
||||||
Trackingno string `json:"trackingno" gorm:"column:trackingno;unique;not null"`
|
Trackingno string `json:"trackingno" gorm:"column:trackingno;unique;not null"`
|
||||||
Orderheaderid *int `json:"orderheaderid" gorm:"column:orderheaderid"`
|
Orderheaderid *int `json:"orderheaderid" gorm:"column:orderheaderid"`
|
||||||
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
|
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
|
||||||
Senderid *int `json:"senderid" gorm:"column:senderid"`
|
Senderid *int `json:"senderid" gorm:"column:senderid"`
|
||||||
Receiverid *int `json:"receiverid" gorm:"column:receiverid"`
|
Receiverid *int `json:"receiverid" gorm:"column:receiverid"`
|
||||||
|
// See PickupBooking: pickuplocationid is the customer's saved address,
|
||||||
|
// tenantlocationid is the client's own site. Carried over at pickup so a
|
||||||
|
// parcel stays traceable to the kitchen or branch it left.
|
||||||
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
|
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
|
||||||
|
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
|
||||||
Deliverylocationid *int `json:"deliverylocationid" gorm:"column:deliverylocationid"`
|
Deliverylocationid *int `json:"deliverylocationid" gorm:"column:deliverylocationid"`
|
||||||
Originhubid *int `json:"originhubid" gorm:"column:originhubid"`
|
Originhubid *int `json:"originhubid" gorm:"column:originhubid"`
|
||||||
Currenthubid *int `json:"currenthubid" gorm:"column:currenthubid"`
|
Currenthubid *int `json:"currenthubid" gorm:"column:currenthubid"`
|
||||||
@@ -57,9 +61,9 @@ type Consignment struct {
|
|||||||
Chargeableweight float64 `json:"chargeableweight" gorm:"column:chargeableweight;not null"`
|
Chargeableweight float64 `json:"chargeableweight" gorm:"column:chargeableweight;not null"`
|
||||||
Codamount float64 `json:"codamount" gorm:"column:codamount;default:0.00"`
|
Codamount float64 `json:"codamount" gorm:"column:codamount;default:0.00"`
|
||||||
Codcollected float64 `json:"codcollected" gorm:"column:codcollected;default:0.00"`
|
Codcollected float64 `json:"codcollected" gorm:"column:codcollected;default:0.00"`
|
||||||
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Prepaid, COD, To_Pay
|
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Prepaid, COD, To_Pay
|
||||||
Billingstatus string `json:"billingstatus" gorm:"column:billingstatus;default:Unbilled"` // Unbilled, Billed, Paid, Settled
|
Billingstatus string `json:"billingstatus" gorm:"column:billingstatus;default:Unbilled"` // Unbilled, Billed, Paid, Settled
|
||||||
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Inwarded_at_Hub, Tripsheet_Loaded, In_Transit, Out_for_Delivery, Delivered, RTO_Initiated, Returned_to_Sender, Missing, Damaged
|
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Inwarded_at_Hub, Tripsheet_Loaded, In_Transit, Out_for_Delivery, Delivered, RTO_Initiated, Returned_to_Sender, Missing, Damaged
|
||||||
Attemptcount int `json:"attemptcount" gorm:"column:attemptcount;default:0"`
|
Attemptcount int `json:"attemptcount" gorm:"column:attemptcount;default:0"`
|
||||||
Estimateddeliveryat *time.Time `json:"estimateddeliveryat" gorm:"column:estimateddeliveryat"`
|
Estimateddeliveryat *time.Time `json:"estimateddeliveryat" gorm:"column:estimateddeliveryat"`
|
||||||
Sladueat *time.Time `json:"sladueat" gorm:"column:sladueat"`
|
Sladueat *time.Time `json:"sladueat" gorm:"column:sladueat"`
|
||||||
@@ -69,11 +73,16 @@ type Consignment struct {
|
|||||||
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
||||||
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
||||||
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
// Deliveryotp is issued when the consignment goes out for delivery and is
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
// given to the receiver, not the rider — it is the only proof the parcel
|
||||||
Createdby int `json:"createdby" gorm:"column:createdby"`
|
// reached the right person. Never serialised outward: returning it in an API
|
||||||
Updatedby int `json:"updatedby" gorm:"column:updatedby"`
|
// response would hand the rider the code they are supposed to be told.
|
||||||
Deletedat *time.Time `json:"deletedat,omitempty" gorm:"column:deletedat"`
|
Deliveryotp string `json:"-" gorm:"column:deliveryotp;size:6"`
|
||||||
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
|
Createdby int `json:"createdby" gorm:"column:createdby"`
|
||||||
|
Updatedby int `json:"updatedby" gorm:"column:updatedby"`
|
||||||
|
Deletedat *time.Time `json:"deletedat,omitempty" gorm:"column:deletedat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Consignment) TableName() string {
|
func (Consignment) TableName() string {
|
||||||
@@ -121,12 +130,12 @@ func (ConsignmentException) TableName() string {
|
|||||||
// at that hub. One conversation per (hubid, mileruserid) pair.
|
// at that hub. One conversation per (hubid, mileruserid) pair.
|
||||||
type HubConversation struct {
|
type HubConversation struct {
|
||||||
Hubconversationid int `json:"hubconversationid" gorm:"primaryKey;column:hubconversationid"`
|
Hubconversationid int `json:"hubconversationid" gorm:"primaryKey;column:hubconversationid"`
|
||||||
Hubid int `json:"hubid" gorm:"column:hubid;index;not null"`
|
Hubid int `json:"hubid" gorm:"column:hubid;index;not null"`
|
||||||
Mileruserid *int `json:"mileruserid" gorm:"column:mileruserid;index"`
|
Mileruserid *int `json:"mileruserid" gorm:"column:mileruserid;index"`
|
||||||
Participantname string `json:"participantname" gorm:"column:participantname;not null"`
|
Participantname string `json:"participantname" gorm:"column:participantname;not null"`
|
||||||
Participantrole string `json:"participantrole" gorm:"column:participantrole"`
|
Participantrole string `json:"participantrole" gorm:"column:participantrole"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (HubConversation) TableName() string {
|
func (HubConversation) TableName() string {
|
||||||
@@ -137,13 +146,13 @@ func (HubConversation) TableName() string {
|
|||||||
// requesting hub staff) or "them" (the other party), matching the hub
|
// requesting hub staff) or "them" (the other party), matching the hub
|
||||||
// console frontend's bubble-side convention.
|
// console frontend's bubble-side convention.
|
||||||
type HubMessage struct {
|
type HubMessage struct {
|
||||||
Hubmessageid int `json:"hubmessageid" gorm:"primaryKey;column:hubmessageid"`
|
Hubmessageid int `json:"hubmessageid" gorm:"primaryKey;column:hubmessageid"`
|
||||||
Hubconversationid int `json:"hubconversationid" gorm:"column:hubconversationid;index;not null"`
|
Hubconversationid int `json:"hubconversationid" gorm:"column:hubconversationid;index;not null"`
|
||||||
Sender string `json:"sender" gorm:"column:sender;not null"` // me, them
|
Sender string `json:"sender" gorm:"column:sender;not null"` // me, them
|
||||||
Senderstaffid *int `json:"senderstaffid" gorm:"column:senderstaffid"`
|
Senderstaffid *int `json:"senderstaffid" gorm:"column:senderstaffid"`
|
||||||
Messagetext string `json:"messagetext" gorm:"column:messagetext;not null"`
|
Messagetext string `json:"messagetext" gorm:"column:messagetext;not null"`
|
||||||
Isread bool `json:"isread" gorm:"column:isread;default:false"`
|
Isread bool `json:"isread" gorm:"column:isread;default:false"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (HubMessage) TableName() string {
|
func (HubMessage) TableName() string {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PickupBooking struct {
|
type PickupBooking struct {
|
||||||
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
|
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
|
||||||
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
|
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
|
||||||
// Tenantid identifies which client company this booking is for. Nil for
|
// Tenantid identifies which client company this booking is for. Nil for
|
||||||
// direct B2C bookings (Bookingsource "Customer_App") that aren't attributed
|
// direct B2C bookings (Bookingsource "Customer_App") that aren't attributed
|
||||||
// to a tenant yet — see CreateCustomerBooking. Required for CRM bookings
|
// to a tenant yet — see CreateCustomerBooking. Required for CRM bookings
|
||||||
@@ -14,31 +14,39 @@ type PickupBooking struct {
|
|||||||
// a specific tenant. Propagated onto the resulting Consignment at pickup
|
// a specific tenant. Propagated onto the resulting Consignment at pickup
|
||||||
// time in BookingPickupComplete, instead of inferring it from whichever
|
// time in BookingPickupComplete, instead of inferring it from whichever
|
||||||
// miler happens to complete the pickup.
|
// miler happens to complete the pickup.
|
||||||
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
|
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||||
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
|
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
|
||||||
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
|
// Pickuplocationid is the *customer's* saved address the parcel was collected
|
||||||
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
// from — it carries a foreign key to appcustomerlocations. It is a B2C
|
||||||
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
|
// concept and has nothing to do with the client company's own sites.
|
||||||
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
|
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
|
||||||
Pickuplongitude float64 `json:"pickuplongitude" gorm:"column:pickuplongitude;not null"`
|
// Tenantlocationid is the *client's* site: the kitchen, branch or depot the
|
||||||
Deliveryaddress string `json:"deliveryaddress" gorm:"column:deliveryaddress;not null"`
|
// parcel came out of. Separate column because pickuplocationid points at a
|
||||||
Deliverypincode string `json:"deliverypincode" gorm:"column:deliverypincode;not null"`
|
// different table entirely; writing a tenantlocations id into it violates
|
||||||
Deliverylatitude float64 `json:"deliverylatitude" gorm:"column:deliverylatitude;not null"`
|
// that foreign key. This is what per-site reporting groups by.
|
||||||
Deliverylongitude float64 `json:"deliverylongitude" gorm:"column:deliverylongitude;not null"`
|
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
|
||||||
Deliverycity string `json:"deliverycity" gorm:"column:deliverycity"`
|
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
||||||
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
|
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
|
||||||
Bookingsource string `json:"bookingsource" gorm:"column:bookingsource;default:Customer_App"`
|
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
|
||||||
Providercompany string `json:"providercompany" gorm:"column:providercompany"`
|
Pickuplongitude float64 `json:"pickuplongitude" gorm:"column:pickuplongitude;not null"`
|
||||||
Providerlocation string `json:"providerlocation" gorm:"column:providerlocation"`
|
Deliveryaddress string `json:"deliveryaddress" gorm:"column:deliveryaddress;not null"`
|
||||||
Notes string `json:"notes" gorm:"column:notes"`
|
Deliverypincode string `json:"deliverypincode" gorm:"column:deliverypincode;not null"`
|
||||||
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Miler_Assigned, Pickup_Scheduled, Picked_Up, Converted_To_Consignment, Cancelled
|
Deliverylatitude float64 `json:"deliverylatitude" gorm:"column:deliverylatitude;not null"`
|
||||||
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
|
Deliverylongitude float64 `json:"deliverylongitude" gorm:"column:deliverylongitude;not null"`
|
||||||
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
|
Deliverycity string `json:"deliverycity" gorm:"column:deliverycity"`
|
||||||
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
|
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
|
||||||
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
|
Bookingsource string `json:"bookingsource" gorm:"column:bookingsource;default:Customer_App"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Providercompany string `json:"providercompany" gorm:"column:providercompany"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
Providerlocation string `json:"providerlocation" gorm:"column:providerlocation"`
|
||||||
|
Notes string `json:"notes" gorm:"column:notes"`
|
||||||
|
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Miler_Assigned, Pickup_Scheduled, Picked_Up, Converted_To_Consignment, Cancelled
|
||||||
|
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
|
||||||
|
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
|
||||||
|
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
|
||||||
|
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
|
||||||
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
|
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
|
||||||
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
|
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
|
||||||
@@ -91,7 +99,7 @@ type BookingPayment struct {
|
|||||||
Bookingpaymentid int `json:"bookingpaymentid" gorm:"primaryKey;column:bookingpaymentid"`
|
Bookingpaymentid int `json:"bookingpaymentid" gorm:"primaryKey;column:bookingpaymentid"`
|
||||||
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
|
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
|
||||||
Amount float64 `json:"amount" gorm:"column:amount;not null"`
|
Amount float64 `json:"amount" gorm:"column:amount;not null"`
|
||||||
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Cash, UPI, Card, Wallet
|
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Cash, UPI, Card, Wallet
|
||||||
Paymentstatus string `json:"paymentstatus" gorm:"column:paymentstatus;default:Pending"` // Pending, Paid, Failed, Refunded
|
Paymentstatus string `json:"paymentstatus" gorm:"column:paymentstatus;default:Pending"` // Pending, Paid, Failed, Refunded
|
||||||
Collectedbyuserid *int `json:"collectedbyuserid" gorm:"column:collectedbyuserid"`
|
Collectedbyuserid *int `json:"collectedbyuserid" gorm:"column:collectedbyuserid"`
|
||||||
Transactionref string `json:"transactionref" gorm:"column:transactionref"`
|
Transactionref string `json:"transactionref" gorm:"column:transactionref"`
|
||||||
@@ -129,8 +137,8 @@ type BookingVehicleRequirement struct {
|
|||||||
Requiredvehicletype string `json:"requiredvehicletype" gorm:"column:requiredvehicletype;not null"`
|
Requiredvehicletype string `json:"requiredvehicletype" gorm:"column:requiredvehicletype;not null"`
|
||||||
Reason string `json:"reason" gorm:"column:reason"`
|
Reason string `json:"reason" gorm:"column:reason"`
|
||||||
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
|
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
|
||||||
Scheduledpickupfrom *time.Time `json:"scheduledpickupfrom" gorm:"column:scheduledpickupfrom"`
|
Scheduledpickupfrom *time.Time `json:"scheduledpickupfrom" gorm:"column:scheduledpickupfrom"`
|
||||||
Scheduledpickupto *time.Time `json:"scheduledpickupto" gorm:"column:scheduledpickupto"`
|
Scheduledpickupto *time.Time `json:"scheduledpickupto" gorm:"column:scheduledpickupto"`
|
||||||
Assignedvehicleid *int `json:"assignedvehicleid" gorm:"column:assignedvehicleid"`
|
Assignedvehicleid *int `json:"assignedvehicleid" gorm:"column:assignedvehicleid"`
|
||||||
Assigneddriveruserid *int `json:"assigneddriveruserid" gorm:"column:assigneddriveruserid"`
|
Assigneddriveruserid *int `json:"assigneddriveruserid" gorm:"column:assigneddriveruserid"`
|
||||||
Status string `json:"status" gorm:"column:status;default:Required"` // Required, Scheduled, Assigned, Arrived, Picked_Up, Cancelled
|
Status string `json:"status" gorm:"column:status;default:Required"` // Required, Scheduled, Assigned, Arrived, Picked_Up, Cancelled
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ type DoormileClient struct {
|
|||||||
Phone string `gorm:"uniqueIndex;size:20;not null" json:"phone"`
|
Phone string `gorm:"uniqueIndex;size:20;not null" json:"phone"`
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
Address string `gorm:"type:text" json:"address"`
|
Address string `gorm:"type:text" json:"address"`
|
||||||
City string `gorm:"size:100" json:"city"`
|
City string `gorm:"size:100" json:"city"`
|
||||||
State string `gorm:"size:100" json:"state"`
|
State string `gorm:"size:100" json:"state"`
|
||||||
Neighbourhood string `gorm:"size:100" json:"neighbourhood"`
|
Neighbourhood string `gorm:"size:100" json:"neighbourhood"`
|
||||||
Pincode string `gorm:"size:20" json:"pincode"`
|
Pincode string `gorm:"size:20" json:"pincode"`
|
||||||
|
|
||||||
// GPS survey data
|
// GPS survey data
|
||||||
SurveyLat float64 `gorm:"column:surveylat" json:"survey_lat"`
|
SurveyLat float64 `gorm:"column:surveylat" json:"survey_lat"`
|
||||||
@@ -41,10 +41,10 @@ type DoormileClient struct {
|
|||||||
|
|
||||||
// Full-consent-only fields (zeroed for basicOnly)
|
// Full-consent-only fields (zeroed for basicOnly)
|
||||||
ParcelVolume float64 `json:"parcel_volume"`
|
ParcelVolume float64 `json:"parcel_volume"`
|
||||||
ActiveContracts int `json:"active_contracts"`
|
ActiveContracts int `json:"active_contracts"`
|
||||||
LogisticsProvider string `gorm:"size:100" json:"logistics_provider"`
|
LogisticsProvider string `gorm:"size:100" json:"logistics_provider"`
|
||||||
ProviderEfficiency string `gorm:"size:100" json:"provider_efficiency"`
|
ProviderEfficiency string `gorm:"size:100" json:"provider_efficiency"`
|
||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
|
|
||||||
// Consent & registration tracking
|
// Consent & registration tracking
|
||||||
DataConsent string `gorm:"size:20;default:'full'" json:"data_consent"`
|
DataConsent string `gorm:"size:20;default:'full'" json:"data_consent"`
|
||||||
@@ -63,8 +63,13 @@ type DoormileAuth struct {
|
|||||||
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
||||||
PasswordHash string `gorm:"not null" json:"-"`
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
Role string `gorm:"default:'user'" json:"role"`
|
Role string `gorm:"default:'user'" json:"role"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// Tenantid scopes an express-console login to one client, using the same
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// convention as HubStaffAccount.Tenantid: null = Doormile's own staff, who
|
||||||
|
// see every tenant's data; set = a client's own login, restricted to their
|
||||||
|
// tenant. Without this every console login sees all tenants.
|
||||||
|
Tenantid *int `gorm:"column:tenantid;index" json:"tenantid"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DoormileAuth) TableName() string {
|
func (DoormileAuth) TableName() string {
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import "time"
|
|||||||
|
|
||||||
// Tenant represents the pre-existing 'tenants' table
|
// Tenant represents the pre-existing 'tenants' table
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
Tenantid int `json:"tenantid" gorm:"primaryKey;column:tenantid"`
|
Tenantid int `json:"tenantid" gorm:"primaryKey;column:tenantid"`
|
||||||
Tenantname string `json:"tenantname" gorm:"column:tenantname"`
|
Tenantname string `json:"tenantname" gorm:"column:tenantname"`
|
||||||
Primaryemail string `json:"primaryemail" gorm:"column:primaryemail"`
|
Primaryemail string `json:"primaryemail" gorm:"column:primaryemail"`
|
||||||
Primarycontact string `json:"primarycontact" gorm:"column:primarycontact"`
|
Primarycontact string `json:"primarycontact" gorm:"column:primarycontact"`
|
||||||
Status string `json:"status" gorm:"column:status;default:Active"`
|
Status string `json:"status" gorm:"column:status;default:Active"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
// Requiredeliveryotp decides whether the receiver must read a code back to
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
// the rider. Worth the friction for a courier handing over a valuable
|
||||||
|
// parcel; not for a food order, where it just slows every drop down.
|
||||||
|
// Defaults off: turning it on platform-wide would block deliveries for
|
||||||
|
// clients whose customer app has no way to show the code yet.
|
||||||
|
Requiredeliveryotp bool `json:"requiredeliveryotp" gorm:"column:requiredeliveryotp;default:false"`
|
||||||
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Tenant) TableName() string {
|
func (Tenant) TableName() string {
|
||||||
@@ -19,14 +25,14 @@ func (Tenant) TableName() string {
|
|||||||
|
|
||||||
// Customer represents the pre-existing 'customers' table
|
// Customer represents the pre-existing 'customers' table
|
||||||
type Customer struct {
|
type Customer struct {
|
||||||
Customerid int `json:"customerid" gorm:"primaryKey;column:customerid"`
|
Customerid int `json:"customerid" gorm:"primaryKey;column:customerid"`
|
||||||
Firstname string `json:"firstname" gorm:"column:firstname"`
|
Firstname string `json:"firstname" gorm:"column:firstname"`
|
||||||
Lastname string `json:"lastname" gorm:"column:lastname"`
|
Lastname string `json:"lastname" gorm:"column:lastname"`
|
||||||
Contactno string `json:"contactno" gorm:"column:contactno"`
|
Contactno string `json:"contactno" gorm:"column:contactno"`
|
||||||
Email string `json:"email" gorm:"column:email"`
|
Email string `json:"email" gorm:"column:email"`
|
||||||
Status int `json:"status" gorm:"column:status"`
|
Status int `json:"status" gorm:"column:status"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat"`
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Customer) TableName() string {
|
func (Customer) TableName() string {
|
||||||
@@ -35,15 +41,15 @@ func (Customer) TableName() string {
|
|||||||
|
|
||||||
// CustomerLocation represents the pre-existing 'customerlocations' table
|
// CustomerLocation represents the pre-existing 'customerlocations' table
|
||||||
type CustomerLocation struct {
|
type CustomerLocation struct {
|
||||||
Locationid int `json:"locationid" gorm:"primaryKey;column:locationid"`
|
Locationid int `json:"locationid" gorm:"primaryKey;column:locationid"`
|
||||||
Customerid int `json:"customerid" gorm:"column:customerid"`
|
Customerid int `json:"customerid" gorm:"column:customerid"`
|
||||||
Address string `json:"address" gorm:"column:address"`
|
Address string `json:"address" gorm:"column:address"`
|
||||||
City string `json:"city" gorm:"column:city"`
|
City string `json:"city" gorm:"column:city"`
|
||||||
State string `json:"state" gorm:"column:state"`
|
State string `json:"state" gorm:"column:state"`
|
||||||
Postcode string `json:"postcode" gorm:"column:postcode"`
|
Postcode string `json:"postcode" gorm:"column:postcode"`
|
||||||
Latitude string `json:"latitude" gorm:"column:latitude"`
|
Latitude string `json:"latitude" gorm:"column:latitude"`
|
||||||
Longitude string `json:"longitude" gorm:"column:longitude"`
|
Longitude string `json:"longitude" gorm:"column:longitude"`
|
||||||
Status int `json:"status" gorm:"column:status"`
|
Status int `json:"status" gorm:"column:status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (CustomerLocation) TableName() string {
|
func (CustomerLocation) TableName() string {
|
||||||
|
|||||||
@@ -84,26 +84,26 @@ func (AppUser) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MilerProfile struct {
|
type MilerProfile struct {
|
||||||
Milerprofileid int `json:"milerprofileid" gorm:"primaryKey;column:milerprofileid"`
|
Milerprofileid int `json:"milerprofileid" gorm:"primaryKey;column:milerprofileid"`
|
||||||
Userid int `json:"userid" gorm:"column:userid;unique;not null"`
|
Userid int `json:"userid" gorm:"column:userid;unique;not null"`
|
||||||
Applocationid int `json:"applocationid" gorm:"column:applocationid;default:1"`
|
Applocationid int `json:"applocationid" gorm:"column:applocationid;default:1"`
|
||||||
Displayname string `json:"displayname" gorm:"column:displayname;not null"`
|
Displayname string `json:"displayname" gorm:"column:displayname;not null"`
|
||||||
Phone string `json:"phone" gorm:"column:phone;not null"`
|
Phone string `json:"phone" gorm:"column:phone;not null"`
|
||||||
Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"`
|
Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"`
|
||||||
Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"`
|
Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"`
|
||||||
Hubid *int `json:"hubid" gorm:"column:hubid"`
|
Hubid *int `json:"hubid" gorm:"column:hubid"`
|
||||||
Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"`
|
Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"`
|
||||||
Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"`
|
Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"`
|
||||||
Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"`
|
Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"`
|
||||||
Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"`
|
Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"`
|
||||||
Availabilitystatus string `json:"availabilitystatus" gorm:"column:availabilitystatus;default:Offline"` // Offline, Available, Assigned, On_Pickup, At_Customer, Picked_Up, On_Delivery, Break, Blocked
|
Availabilitystatus string `json:"availabilitystatus" gorm:"column:availabilitystatus;default:Offline"` // Offline, Available, Assigned, On_Pickup, At_Customer, Picked_Up, On_Delivery, Break, Blocked
|
||||||
Rating float64 `json:"rating" gorm:"column:rating;default:5.00"`
|
Rating float64 `json:"rating" gorm:"column:rating;default:5.00"`
|
||||||
Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"`
|
Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"`
|
||||||
Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"`
|
Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"`
|
||||||
Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"`
|
Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"`
|
||||||
Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"`
|
Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (MilerProfile) TableName() string {
|
func (MilerProfile) TableName() string {
|
||||||
@@ -158,6 +158,7 @@ func (AppCustomerLocation) TableName() string {
|
|||||||
type TenantLocation struct {
|
type TenantLocation struct {
|
||||||
Tenantlocationid int `json:"tenantlocationid" gorm:"primaryKey;column:tenantlocationid"`
|
Tenantlocationid int `json:"tenantlocationid" gorm:"primaryKey;column:tenantlocationid"`
|
||||||
Tenantid int `json:"tenantid" gorm:"column:tenantid;not null"`
|
Tenantid int `json:"tenantid" gorm:"column:tenantid;not null"`
|
||||||
|
Locationname string `json:"locationname" gorm:"column:locationname"`
|
||||||
Address string `json:"address" gorm:"column:address;not null"`
|
Address string `json:"address" gorm:"column:address;not null"`
|
||||||
City string `json:"city" gorm:"column:city;not null"`
|
City string `json:"city" gorm:"column:city;not null"`
|
||||||
State string `json:"state" gorm:"column:state;not null"`
|
State string `json:"state" gorm:"column:state;not null"`
|
||||||
|
|||||||
@@ -113,7 +113,15 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
miler := api.Group("/miler")
|
miler := api.Group("/miler")
|
||||||
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
|
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
|
||||||
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
|
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
|
||||||
miler.Post("/reset-pin", authThrottle, controllers.ResetMilerPin)
|
// PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites
|
||||||
|
// the PIN given only a phone number, and phone numbers are the miler login
|
||||||
|
// identifier rather than a secret. Left unauthenticated, two calls
|
||||||
|
// (reset-pin then verify-pin) take over any miler account. Ops resets a
|
||||||
|
// rider's PIN on request instead, so this carries admin/manager/executive
|
||||||
|
// auth even though it sits under the /miler prefix.
|
||||||
|
miler.Post("/reset-pin", authThrottle,
|
||||||
|
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4),
|
||||||
|
controllers.ResetMilerPin)
|
||||||
|
|
||||||
// Authenticated Miler App routes
|
// Authenticated Miler App routes
|
||||||
milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5))
|
milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5))
|
||||||
@@ -214,6 +222,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
|
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
|
||||||
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
|
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
|
||||||
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
|
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
|
||||||
|
// Per-site performance — jupiter's getlocationsummary. Needs ?tenantid= for
|
||||||
|
// Doormile staff; a client login is already pinned to its own sites.
|
||||||
|
adminAuth.Get("/locations/summary", controllers.GetLocationSummary)
|
||||||
|
|
||||||
// Tenant customers
|
// Tenant customers
|
||||||
adminAuth.Get("/tenantcustomers", controllers.GetTenantCustomers)
|
adminAuth.Get("/tenantcustomers", controllers.GetTenantCustomers)
|
||||||
@@ -242,8 +253,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
|
|
||||||
// Milers
|
// Milers
|
||||||
adminAuth.Get("/milers", controllers.GetMilers)
|
adminAuth.Get("/milers", controllers.GetMilers)
|
||||||
|
// Registered before /milers/:id — Fiber matches in registration order, so
|
||||||
|
// the literal path has to come first or ":id" swallows "summary".
|
||||||
|
adminAuth.Get("/milers/summary", controllers.GetMilerSummary)
|
||||||
adminAuth.Post("/milers", controllers.CreateMiler)
|
adminAuth.Post("/milers", controllers.CreateMiler)
|
||||||
adminAuth.Get("/milers/:id", controllers.GetMilerDetails)
|
adminAuth.Get("/milers/:id", controllers.GetMilerDetails)
|
||||||
|
adminAuth.Get("/milers/:id/logs", controllers.GetMilerLogs)
|
||||||
|
adminAuth.Get("/milers/:id/activity", controllers.GetMilerActivity)
|
||||||
adminAuth.Put("/milers/:id", controllers.UpdateMiler)
|
adminAuth.Put("/milers/:id", controllers.UpdateMiler)
|
||||||
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
|
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
|
||||||
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
|
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
|
||||||
@@ -254,6 +270,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
adminAuth.Post("/expressbooking", middlewares.CityGateMiddleware, controllers.CreateExpressBooking)
|
adminAuth.Post("/expressbooking", middlewares.CityGateMiddleware, controllers.CreateExpressBooking)
|
||||||
adminAuth.Post("/expressbooking/bulk", middlewares.CityGateMiddleware, controllers.AdminBulkCreateBookings)
|
adminAuth.Post("/expressbooking/bulk", middlewares.CityGateMiddleware, controllers.AdminBulkCreateBookings)
|
||||||
adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails)
|
adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails)
|
||||||
|
adminAuth.Get("/bookings/:id/track", controllers.GetAdminBookingTrack)
|
||||||
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
|
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
|
||||||
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
|
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
|
||||||
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
|
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
|
||||||
@@ -263,6 +280,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
// Consignments
|
// Consignments
|
||||||
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
|
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
|
||||||
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
|
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
|
||||||
|
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
|
||||||
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
|
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
|
||||||
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
|
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
|
||||||
|
|
||||||
@@ -351,8 +369,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
|
|
||||||
hubAuth.Get("/report", controllers.GetHubReport)
|
hubAuth.Get("/report", controllers.GetHubReport)
|
||||||
|
|
||||||
// Redis active user caching utilities
|
// Redis active user caching utilities — console/ops only. Open CRUD on a
|
||||||
redisUsers := api.Group("/utils/users/redis")
|
// user cache with no authentication has no legitimate anonymous caller;
|
||||||
|
// the store is currently empty, so closing it breaks nothing.
|
||||||
|
redisUsers := api.Group("/utils/users/redis",
|
||||||
|
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
|
||||||
redisUsers.Post("/", controllers.CreateUserRedis)
|
redisUsers.Post("/", controllers.CreateUserRedis)
|
||||||
redisUsers.Get("/", controllers.GetUserRedis)
|
redisUsers.Get("/", controllers.GetUserRedis)
|
||||||
redisUsers.Put("/:userid", controllers.UpdateUserRedis)
|
redisUsers.Put("/:userid", controllers.UpdateUserRedis)
|
||||||
@@ -365,9 +386,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
api.Post("/pricing/check", controllers.CheckPrice)
|
api.Post("/pricing/check", controllers.CheckPrice)
|
||||||
|
|
||||||
// --------------------
|
// --------------------
|
||||||
// BOOKING CACHE APIS — no auth required (testing)
|
// BOOKING CACHE APIS — console/ops only
|
||||||
// --------------------
|
// --------------------
|
||||||
bookingCache := api.Group("/bookings/cache")
|
// Previously open "for testing", but live: listing the cache returns real
|
||||||
|
// bookings including customer delivery addresses, and the per-customer route
|
||||||
|
// takes a customer id straight from the URL. Behind console auth now.
|
||||||
|
bookingCache := api.Group("/bookings/cache",
|
||||||
|
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
|
||||||
bookingCache.Get("/", controllers.ListAllBookingsFromCache)
|
bookingCache.Get("/", controllers.ListAllBookingsFromCache)
|
||||||
bookingCache.Get("/customer/:customer_id", controllers.GetCustomerBookingsFromCache)
|
bookingCache.Get("/customer/:customer_id", controllers.GetCustomerBookingsFromCache)
|
||||||
bookingCache.Get("/:booking_id", controllers.GetBookingFromCache)
|
bookingCache.Get("/:booking_id", controllers.GetBookingFromCache)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -13,6 +15,55 @@ func HashPassword(password string) (string, error) {
|
|||||||
return string(bytes), err
|
return string(bytes), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbLocation is the timezone the database records wall-clock timestamps in: the
|
||||||
|
// connection DSN sets TimeZone=Asia/Kolkata, so CURRENT_TIMESTAMP column
|
||||||
|
// defaults write IST wall-clock into timestamp-without-timezone columns.
|
||||||
|
var dbLocation = func() *time.Location {
|
||||||
|
if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
// A container without tzdata can't load the database; IST observes no DST,
|
||||||
|
// so a fixed +05:30 offset is exact rather than an approximation.
|
||||||
|
return time.FixedZone("IST", 5*3600+30*60)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// DBNow returns the current moment expressed as the wall-clock the database
|
||||||
|
// stores, tagged UTC so the driver sends exactly those digits. Use it for any
|
||||||
|
// comparison against a stored timestamp: comparing the container's UTC clock
|
||||||
|
// against IST-stamped rows is what made date-range reports undercount.
|
||||||
|
//
|
||||||
|
// Deliberately independent of the container's own TZ, so it stays correct
|
||||||
|
// whether or not TZ=Asia/Kolkata is set.
|
||||||
|
func DBNow() time.Time {
|
||||||
|
n := time.Now().In(dbLocation)
|
||||||
|
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), n.Minute(), n.Second(), n.Nanosecond(), time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBToday returns midnight at the start of the current database-local day.
|
||||||
|
func DBToday() time.Time {
|
||||||
|
n := DBNow()
|
||||||
|
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateNumericOTP returns a random n-digit code, leading zeros preserved.
|
||||||
|
// crypto/rand rather than math/rand: this is the only thing standing between a
|
||||||
|
// parcel and someone claiming it was delivered, so a predictable sequence would
|
||||||
|
// defeat the point.
|
||||||
|
func GenerateNumericOTP(n int) string {
|
||||||
|
const digits = "0123456789"
|
||||||
|
out := make([]byte, n)
|
||||||
|
for i := range out {
|
||||||
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
|
||||||
|
if err != nil {
|
||||||
|
// A failing system RNG must not silently downgrade to a guessable
|
||||||
|
// code; the caller treats an empty OTP as "not issued".
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out[i] = digits[idx.Int64()]
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
func CheckPasswordHash(password, hash string) bool {
|
func CheckPasswordHash(password, hash string) bool {
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
|
|||||||
Reference in New Issue
Block a user