Compare commits

...

13 Commits

Author SHA1 Message Date
Suriya
90fa4fbb74 fix: per-site attribution needs its own column, not pickuplocationid
Caught by testing the previous commit against production: creating a booking
with a resolved site failed with

  pickupbookings_pickuplocationid_fkey
  FOREIGN KEY (pickuplocationid) REFERENCES appcustomerlocations(...)

pickuplocationid is the *customer's* saved address, a B2C concept. It never
referred to the client company's own kitchens or branches. The pre-existing
code that validated an incoming pickuplocationid against TenantLocation was
wrong on the same point and would have 500'd for any caller that used it — it
had simply never been called with a value.

Adds tenantlocationid to pickupbookings and consignments (nullable, indexed,
additive via AutoMigrate), carried across at pickup, and points the reporting
filter, the by_location breakdown and the Unattributed bucket at it.

The booking request accepts tenantlocationid, and still accepts
pickuplocationid as an alias so anything written against the earlier docs
starts working instead of failing.

Also gofmt on the two model files touched; booking.go was already failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:08:57 +05:30
Suriya
0c407e5b27 feat: per-site reporting, and actually populate the site on a booking
jupiter's getreportsummary took a locationid — per kitchen, per branch. That
was the one report parameter with no Doormile equivalent, and for a food
client with 23 kitchens it is the difference between one number and a usable
report.

  GET /admin/reports?locationid=      narrows every figure to one site
  GET /admin/reports                  now carries a by_location block
  GET /admin/locations/summary        the standalone per-site table

The filter alone would have been useless: pickuplocationid was null on every
booking in the system, because the console sends a kitchen's address rather
than its id. createExpressBooking now resolves the site itself — nearest
stored location within 150m, falling back to an address match, nil when
nothing matches confidently, since a wrong attribution silently moves orders
between kitchens. An explicit pickuplocationid still wins.

Bookings that named no site are reported as their own "Unattributed" row
rather than dropped, so per-site rows add up to the summary total.

Two fixes found while in here:
- the payments join in the per-site query fanned out, counting a booking once
  per payment row; payments are now pre-aggregated per booking
- by_rider was empty for every client login, which reads as "your riders did
  nothing". Riders are tenant-scoped now, so a client sees its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:56:56 +05:30
Suriya
cf488b3d76 docs: jupiter to Doormile API migration map
Maps every jupiter endpoint we have replaced to its Doormile equivalent, for
the express console and the miler app only. Marks which jupiter paths were
confirmed from live network logs versus taken from the prior codebase
analysis, and states per row whether the Doormile side has been hit with a
real request or only compiles.

Includes the 11-way decomposition of PUT /deliveries/updatedelivery, the
behaviour changes that break a naive repoint, and the gaps jupiter covered
that Doormile does not yet — per-site reporting being the notable one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:49:40 +05:30
Suriya
511d369d7f docs: refresh the express-console API reference
Adds the five rider/tracking endpoints, the ?tenantid= staff filter and the
403-vs-404 refusal rules, and replaces the guesswork coverage note with what
was actually run against production on 2026-08-06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:46:21 +05:30
Suriya
f44e8fa3b4 fix: access checks that returned utils.Forbidden never blocked anything
utils.Forbidden and utils.NotFound write the response and return c.JSON's
nil. Any helper that signalled refusal by returning one of them handed its
caller a nil error, so every `if err != nil { return err }` guard passed and
the handler carried straight on.

The observable result: GET /admin/milers?tenantid=14 as a DailyGrubs login
returned HTTP 403 with all 30 of the network's riders in the body. Status
line correct, payload leaked.

Three helpers were affected:
  effectiveTenantID    (yesterday, mine) — cross-tenant read returned the
                       unfiltered list under a 403
  canAccessBooking     (was assertBookingAccess, shipped in 6d9232f) — four
                       mutating booking handlers were unguarded
  findMilerForConsole  (was assertMilerAccess) — worse, callers went on to
                       dereference the nil profile

All three now return a bool and the caller writes the refusal itself, so the
control flow is visible at the call site instead of hiding in a helper.

Adds a test that pins utils.Forbidden/NotFound returning nil, so if that ever
changes the assumption breaks loudly rather than silently, plus table tests
for effectiveTenantID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:32:13 +05:30
Suriya
42f2a41ff6 feat: filter console reads by tenant, for clients and for Doormile staff
Two halves of the same thing. A client login was already pinned to its own
tenant on most reads, but the roster, the B2C customer list and the dashboard
counters were not — a DailyGrubs login listed the whole network's riders.

The other half was missing entirely: Doormile's own staff had no way to look
at one client's slice. Reports accepted ?tenantid= but applied it only to the
consignment count, and bookings accepted it while milers, customers,
consignments and the dashboard ignored it.

effectiveTenantID(c) now resolves both cases in one place — the caller's own
tenant for a client login, the requested one for Doormile staff, 0 for the
whole network. A client asking for someone else's tenantid is refused rather
than silently handed their own data back under the wrong label.

Applied to: milers, customers, bookings, consignments, dashboard, reports and
the rider summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:13 +05:30
Suriya
d85d5571b8 feat: rider visibility and tracking for the express console
A client login could list its own bookings but had no way to see what its
riders were actually doing. jupiter's console gave them getridersummary and
the rider/delivery logs; Doormile records all of it and exposed none of it.

New console endpoints, all tenant-scoped:
  GET /admin/milers/summary        roster with live state + range totals
  GET /admin/milers/:id/logs       GPS trail from the Redis telemetry index
  GET /admin/milers/:id/activity   one rider's assignments, duty and breaks
  GET /admin/consignments/:id/logs event history + telemetry + proof
  GET /admin/bookings/:id/track    booking -> assignments -> parcel -> proof

Also closes a rider IDOR: GetMilers scoped the roster to the caller's own
fleet, but reading, editing, blocking, notifying or assigning a vehicle to a
single rider by id did not, so a client login could walk the whole network's
riders by incrementing the id. All five now go through assertMilerAccess.

And the client dashboard no longer reports milers/customers/exceptions as
zero — those have no tenant column, so they are counted through appusers,
bookings and consignments respectively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:41:02 +05:30
Suriya
6d9232f3b7 fix: close the remaining express-console tenant leaks
The first scoping pass covered tables carrying a tenantid column. These five
have no such column and were still returning every client's data to a client
login, which is what made creating info@dailygrubs.com unsafe:

- GET /admin/customers — scoped through the bookings placed for them, since a
  customer carries no tenant of their own (the same person can order from two
  clients).
- GET /admin/exceptions — scoped through the consignment the exception was
  raised against.
- GET /admin/tenantcustomers — the legacy customers table predates tenant
  attribution entirely, so no row can be proven to belong to a client. Returns
  empty for client logins rather than handing over the whole list.
- GET /admin/tripsheets — a vehicle run routinely carries several clients'
  parcels on one manifest, so there is no honest per-client view. Doormile
  staff only.
- GET /admin/milers — scoped through appusers.tenantid, so a client sees their
  own riders rather than the whole roster.

Also guards PUT /admin/consignments/:id/status, which took the id straight from
the path and would have let a client move another client's parcel through the
network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:39:34 +05:30
Suriya
9b3ec886ce fix: populate riderkms and ridercharges on delivery completion
riderkms, ridercharges and bonuspoints appeared in exactly three places in the
codebase — all three GET /miler/earnings reading them. Nothing ever wrote them,
so every completed job reported zero distance and zero value and the earnings
screen was permanently empty.

On delivery completion:
- riderkms is the distance ridden for the booking, pickup point to where the
  rider confirmed delivery, falling back to the booking's delivery coordinates
  when the app sends no position.
- ridercharges is the order amount the tenant is billed, supplied at creation
  as finalprice and already stored on the booking service option. Doormile does
  not compute it; this is pass-through.
- bonuspoints deliberately left at zero pending a decision on what earns them.

Also moves the monthly earnings window onto utils.DBNow, so it doesn't put a
rider in the wrong month for 5h30m either side of a month boundary.

Note historical rows keep whatever completedat they were written with before
the image gained TZ=Asia/Kolkata, so figures spanning today are mixed; not
backfilled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:33:01 +05:30
Suriya
39dcba3d80 fix: put the Redis user-cache utilities behind console auth
/api/v1/utils/users/redis exposed open CRUD — create, read, update and delete
on the cached-user store — to anonymous callers. The store is currently empty
and nothing in the console or apps calls it, so closing it breaks no client.

Third of the three route groups flagged in the 2026-07-27 audit. /crm/* stays
open by explicit decision: the field-sales Flutter app sends no credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:23:59 +05:30
Suriya
4d03676e60 fix: put the booking cache endpoints behind console auth
/api/v1/bookings/cache/* was open "for testing" but is live in production:
listing it returns real bookings including customer delivery addresses, and
/customer/:customer_id takes the customer straight from the URL, so anyone
could enumerate a given customer's bookings without credentials.

Now requires a console token (roles 1/3/4), matching the rest of the admin
surface. The /crm/* group stays open by deliberate decision — the field-sales
Flutter app authenticates with nothing and would break; revisit when that app
can send a key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:22:42 +05:30
Suriya
c8a9b5d797 fix: customer PIN-reset takeover, booking-quote and consignment-log IDORs
- POST /customer/reset-pin was unauthenticated and overwrote a customer's PIN
  given only their phone number — which is the login identifier, not a secret —
  so reset-pin followed by verify-pin took over any customer account. Exactly
  the miler flaw fixed in fd7cf3e, on the B2C side. It now requires the account's
  registered email to have been verified through the existing
  send-email-otp/verify-email-otp flow; the verification is recorded in Redis
  for 10 minutes and consumed on use, so one verification authorises one reset.
  Accounts with no email on file are directed to support rather than left open.

- GET /customer/bookings/:id/price had no ownership check, unlike every other
  customer booking route, so any signed-in customer could read the price quoted
  on anyone else's booking by walking the id.

- GET /miler/consignments/userlogs/:userid took the rider from the URL and never
  compared it to the caller, letting any miler read another miler's movement
  history.

Verified as already correct while sweeping: miler assignment and booking-flow
handlers all scope by mileruserid/assignedmileruserid, customer booking detail
and cancel scope by appcustomerid, /internal sits behind InternalKeyAuth, and
CreateHubStaffAccount already refuses non-Doormile staff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:20:47 +05:30
Suriya
fd7cf3e35e fix: console tenant scoping, miler identity spoofing, delivery proof, timezone
Security
- Express console had no tenant scoping at all: LoginAdmin hardcoded tenantid 0
  into every JWT and none of the 85 admin handlers filtered by tenant, so any
  client given a console login would read every other client's bookings,
  customers, pricing and reports. Adds DoormileAuth.Tenantid (nil = Doormile
  staff, unrestricted; set = client, scoped), emits it in the token, and scopes
  reads, guards writes and pins tenantid on create.
- Miler telemetry (/miler/logs, /miler/status, /miler/consignments/logs) took
  userid from the request body, letting any authenticated rider write another
  rider's status and GPS trail — data the dispatch layer reasons over. Identity
  now comes from the token.
- POST /miler/reset-pin was unauthenticated and overwrote a PIN given only a
  phone number, so reset-pin + verify-pin took over any rider account. Now
  requires admin/manager/executive auth.

Correctness
- Date ranges compared the container's UTC clock against timestamps the DB
  writes as IST wall-clock (DSN sets TimeZone=Asia/Kolkata), so "today so far"
  ended 5h30m in the past and silently dropped everything created after noon
  IST from every report. Sets TZ in the image and adds utils.DBNow/DBToday,
  which stay correct regardless of container timezone.
- CreateMiler never set Configid, so console-created riders got the column
  default of 1 while LoginMiler looks up configid 1001 — every such rider was
  unable to log in, reported as "no miler account found".
- Delivery wrote no consignment history row, so a tracking timeline never
  showed the parcel arriving.

Features
- Delivery OTP is now real (crypto/rand, issued to the receiver, verified and
  cleared on delivery) but opt-in per client via Tenant.Requiredeliveryotp,
  defaulting off — friction worth it for a courier parcel, not a food order.
- Express bookings accept pickuplocationid, so the console can name a client
  site (a DailyGrubs kitchen) instead of retyping its address; validated
  against the tenant and carried through to the consignment.
- TenantLocation.Locationname, miler tenantid/hubid, Nagercoil (629) opened.
- PUT /miler/availability accepts both "status" and "availabilitystatus", and
  /miler/location no longer drops speed/heading — both were contract
  mismatches against the doc the Flutter dev was given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:16:56 +05:30
22 changed files with 2875 additions and 257 deletions

View File

@@ -9,8 +9,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Second Stage: Run the compiled binary inside alpine
FROM alpine
# Fix: Alpine needs ca-certificates to verify SSL certificates
RUN apk add --no-cache ca-certificates
# Fix: Alpine needs ca-certificates to verify SSL 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=0 /app/server /app/server

File diff suppressed because it is too large Load Diff

View 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}
}

View File

@@ -189,6 +189,19 @@ func ResetCustomerPin(c *fiber.Ctx) error {
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)
if err != nil {
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 {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
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
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")

View File

@@ -96,9 +96,11 @@ func humanizeRelativeTime(t time.Time) string {
// todayMidnight returns the start of the current day in server local time,
// 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 {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return utils.DBToday()
}
// 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")
if fromStr == "" && toStr == "" {
return todayMidnight(), time.Now(), nil
return todayMidnight(), utils.DBNow(), nil
}
if fromStr == "" || toStr == "" {
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 {
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 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD")
}

View File

@@ -274,9 +274,6 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Otp == "" {
return utils.BadRequest(c, "otp is required")
}
if req.Deliveredtoname == "" {
return utils.BadRequest(c, "deliveredtoname is required")
}
@@ -296,17 +293,28 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
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()
// OTP generation/storage is Phase 2 — no delivery_otp field exists yet on
// consignments, so acceptance of a non-empty OTP is treated as verified.
proof := models.DeliveryProof{
Consignmentid: consignment.Consignmentid,
Deliveredat: time.Now(),
Deliveredtoname: req.Deliveredtoname,
Receiversignatureurl: req.Receiversignatureurl,
Photourl: req.Photourl,
Otpverified: true,
Otpverified: consignment.Deliveryotp != "",
Geolatitude: req.Lat,
Geolongitude: req.Lon,
Createdby: milerUserID,
@@ -317,17 +325,57 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
}
consignment.Status = constants.ConsignmentDelivered
// Cleared once redeemed so the same code can't close out a second attempt.
consignment.Deliveryotp = ""
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {
tx.Rollback()
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{}).
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": time.Now(),
"riderkms": riderKms,
"ridercharges": orderAmount,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
@@ -477,7 +525,10 @@ func MilerGetEarnings(c *fiber.Ctx) error {
period := c.Query("period", "daily")
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 {
case "weekly":

View File

@@ -293,7 +293,8 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
if req.Status == "" {
status := req.ResolvedStatus()
if status == "" {
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")
}
profile.Availabilitystatus = req.Status
profile.Availabilitystatus = status
profile.Updatedat = time.Now()
if err := db.DB.Save(&profile).Error; err != nil {
return utils.Internal(c, "failed to update availability")
@@ -796,9 +797,14 @@ func BookingPickupComplete(c *fiber.Ctx) error {
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{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
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 {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
@@ -865,15 +882,18 @@ func BookingPickupComplete(c *fiber.Ctx) error {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Parcel Picked Up",
fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo),
map[string]string{
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
},
); notifyErr != nil {
}
// The OTP goes to the receiver and only the receiver — the rider has to
// be told it at the door, which is what makes it proof of handover.
if consignment.Deliveryotp != "" {
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)
}
}
@@ -919,6 +939,11 @@ func CreateMilerPeriodicLog(c *fiber.Ctx) error {
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)
if err != nil {
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")
}
if status.UserID == 0 || status.Status == "" {
return utils.BadRequest(c, "userid and status are required")
// Identity from the token, not the body — otherwise one rider can set
// 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)
@@ -1088,10 +1117,16 @@ func PublishConsignmentLogs(c *fiber.Ctx) error {
return utils.Internal(c, "cache service unavailable")
}
milerUserID := c.Locals("userid").(int)
pipe := db.Rdb.TxPipeline()
tx := db.DB.Begin()
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)
if err != nil {
logTime = time.Now()
@@ -1170,6 +1205,13 @@ func GetUserConsignmentLogs(c *fiber.Ctx) error {
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 {
return utils.Internal(c, "cache service unavailable")
}

View File

@@ -20,6 +20,11 @@ import (
const (
otpTTL = 5 * time.Minute
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 {
@@ -33,6 +38,22 @@ func generateOtpCode() string {
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) }
// 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 {
return func(c *fiber.Ctx) error {
req := new(dto.SendEmailOtpRequest)
@@ -100,6 +121,8 @@ func VerifyCustomerEmailOtp() fiber.Handler {
}
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")
}
}

View 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
View 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
View 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
View 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.

View File

@@ -7,9 +7,15 @@ type TenantCreateRequest struct {
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
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 {
// Locationname is the client's own label for the site — "DailyGrubs
// Peelamedu Kitchen" — since an address alone doesn't identify a branch.
Locationname string `json:"locationname"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
@@ -62,8 +68,18 @@ type MilerCreateRequest struct {
Contactno string `json:"contactno"`
Password string `json:"password"`
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"`
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 {

View File

@@ -55,10 +55,28 @@ type MilerLocationUpdateRequest struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
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 {
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 {

View File

@@ -13,6 +13,7 @@ var operatingCityPrefixes = map[string]string{
"600": "Chennai",
"560": "Bengaluru",
"500": "Hyderabad",
"629": "Nagercoil",
}
// CityGateMiddleware rejects bookings from pincodes outside Doormile's operating cities.

View File

@@ -38,7 +38,11 @@ type Consignment struct {
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Senderid *int `json:"senderid" gorm:"column:senderid"`
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"`
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
Deliverylocationid *int `json:"deliverylocationid" gorm:"column:deliverylocationid"`
Originhubid *int `json:"originhubid" gorm:"column:originhubid"`
Currenthubid *int `json:"currenthubid" gorm:"column:currenthubid"`
@@ -69,6 +73,11 @@ type Consignment struct {
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
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
// Deliveryotp is issued when the consignment goes out for delivery and is
// given to the receiver, not the rider — it is the only proof the parcel
// reached the right person. Never serialised outward: returning it in an API
// response would hand the rider the code they are supposed to be told.
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"`

View File

@@ -16,7 +16,15 @@ type PickupBooking struct {
// miler happens to complete the pickup.
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
// Pickuplocationid is the *customer's* saved address the parcel was collected
// from — it carries a foreign key to appcustomerlocations. It is a B2C
// concept and has nothing to do with the client company's own sites.
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
// Tenantlocationid is the *client's* site: the kitchen, branch or depot the
// parcel came out of. Separate column because pickuplocationid points at a
// different table entirely; writing a tenantlocations id into it violates
// that foreign key. This is what per-site reporting groups by.
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`

View File

@@ -63,6 +63,11 @@ type DoormileAuth struct {
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
Role string `gorm:"default:'user'" json:"role"`
// Tenantid scopes an express-console login to one client, using the same
// 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"`
}

View File

@@ -9,6 +9,12 @@ type Tenant struct {
Primaryemail string `json:"primaryemail" gorm:"column:primaryemail"`
Primarycontact string `json:"primarycontact" gorm:"column:primarycontact"`
Status string `json:"status" gorm:"column:status;default:Active"`
// Requiredeliveryotp decides whether the receiver must read a code back to
// 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"`
}

View File

@@ -158,6 +158,7 @@ func (AppCustomerLocation) TableName() string {
type TenantLocation struct {
Tenantlocationid int `json:"tenantlocationid" gorm:"primaryKey;column:tenantlocationid"`
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"`
City string `json:"city" gorm:"column:city;not null"`
State string `json:"state" gorm:"column:state;not null"`

View File

@@ -113,7 +113,15 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
miler := api.Group("/miler")
miler.Post("/login", authThrottle, controllers.LoginMiler(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
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.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
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
adminAuth.Get("/tenantcustomers", controllers.GetTenantCustomers)
@@ -242,8 +253,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Milers
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.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/block", controllers.BlockMiler)
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/bulk", middlewares.CityGateMiddleware, controllers.AdminBulkCreateBookings)
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-vehicle", controllers.AdminAssignVehicle)
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
@@ -263,6 +280,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Consignments
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
@@ -351,8 +369,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
hubAuth.Get("/report", controllers.GetHubReport)
// Redis active user caching utilities
redisUsers := api.Group("/utils/users/redis")
// Redis active user caching utilities — console/ops only. Open CRUD on a
// 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.Get("/", controllers.GetUserRedis)
redisUsers.Put("/:userid", controllers.UpdateUserRedis)
@@ -365,9 +386,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
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("/customer/:customer_id", controllers.GetCustomerBookingsFromCache)
bookingCache.Get("/:booking_id", controllers.GetBookingFromCache)

View File

@@ -1,7 +1,9 @@
package utils
import (
"crypto/rand"
"errors"
"math/big"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -13,6 +15,55 @@ func HashPassword(password string) (string, error) {
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 {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil