Compare commits

...

11 Commits

79 changed files with 14169 additions and 172 deletions

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"strings"
)
type Config struct {
@@ -49,6 +50,20 @@ type Config struct {
SMTPUser string
SMTPPassword string
SMTPFrom string
// ClientOnboardingOwners are the console logins allowed to onboard a new
// client (tenant + its console login). Comma-separated emails, compared
// case-insensitively. Deliberately a short allow-list rather than a role:
// every Doormile admin has roleid 1, and onboarding mints credentials.
ClientOnboardingOwners []string
// The Agent Studio Test playground's model: any OpenAI-compatible chat
// completions API (Groq by default; xAI works too). Empty API key leaves
// the playground off — the endpoint answers 503 PLAYGROUND_NOT_CONFIGURED.
// Set the key as a secret in the deployment, never in a tracked file.
PlaygroundLLMBaseURL string
PlaygroundLLMAPIKey string
PlaygroundLLMModel string
}
func Load() *Config {
@@ -79,12 +94,52 @@ func Load() *Config {
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", ""),
ClientOnboardingOwners: splitEmails(getEnv("CLIENT_ONBOARDING_OWNERS", "admin@doormile.com")),
PlaygroundLLMBaseURL: getEnv("PLAYGROUND_LLM_BASE_URL", "https://api.groq.com/openai/v1"),
PlaygroundLLMAPIKey: getEnv("PLAYGROUND_LLM_API_KEY", ""),
PlaygroundLLMModel: getEnv("PLAYGROUND_LLM_MODEL", "openai/gpt-oss-120b"),
}
}
// requiredInProduction are the secrets whose development fallback above is a
// literal committed to this repository. In production a missing one must stop
// the boot: falling back would sign every token with a JWT secret anyone with
// the source can read, and connect with a published password.
var requiredInProduction = []string{"JWT_SECRET_KEY", "DB_PASSWORD", "NATS_PASSWORD"}
// MissingProductionSecrets names each required secret that is unset when
// ENV=production. Always empty in any other environment, so local development
// keeps running on the fallbacks.
func (c *Config) MissingProductionSecrets() []string {
if !strings.EqualFold(c.Env, "production") {
return nil
}
var missing []string
for _, key := range requiredInProduction {
if os.Getenv(key) == "" {
missing = append(missing, key)
}
}
return missing
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// splitEmails parses a comma-separated email list: trimmed, lower-cased,
// blanks dropped.
func splitEmails(v string) []string {
var out []string
for _, e := range strings.Split(v, ",") {
if e = strings.ToLower(strings.TrimSpace(e)); e != "" {
out = append(out, e)
}
}
return out
}

View File

@@ -82,6 +82,39 @@ func TestEnvironmentOverridesAreRead(t *testing.T) {
}
}
// Production must not boot on a secret whose fallback is committed to the repo.
func TestMissingProductionSecrets(t *testing.T) {
setEnv(t, "ENV", "production")
setEnv(t, "JWT_SECRET_KEY", "")
setEnv(t, "DB_PASSWORD", "set")
setEnv(t, "NATS_PASSWORD", "")
got := Load().MissingProductionSecrets()
if len(got) != 2 || got[0] != "JWT_SECRET_KEY" || got[1] != "NATS_PASSWORD" {
t.Fatalf("missing = %v, want [JWT_SECRET_KEY NATS_PASSWORD]", got)
}
setEnv(t, "JWT_SECRET_KEY", "set")
setEnv(t, "NATS_PASSWORD", "set")
if got := Load().MissingProductionSecrets(); len(got) != 0 {
t.Errorf("all secrets set, still reported missing: %v", got)
}
}
// Outside production the fallbacks are allowed, so local development runs
// with no .env at all.
func TestMissingProductionSecretsIgnoredOutsideProduction(t *testing.T) {
setEnv(t, "JWT_SECRET_KEY", "")
setEnv(t, "DB_PASSWORD", "")
setEnv(t, "NATS_PASSWORD", "")
for _, env := range []string{"", "development", "staging"} {
setEnv(t, "ENV", env)
if got := Load().MissingProductionSecrets(); len(got) != 0 {
t.Errorf("ENV=%q reported missing secrets %v; only production should", env, got)
}
}
}
// An empty env var must fall through to the default rather than blanking the
// setting — an empty DB host is a service that cannot start with no clue why.
func TestEmptyEnvFallsBackToTheDefault(t *testing.T) {

View File

@@ -13,6 +13,40 @@ const (
MilerBlocked = "Blocked"
)
// MilerWorkingStatuses are the states in which a miler may be GIVEN more work.
//
// A miler carrying an order is still a miler on the road. Courier rounds are
// multi-stop by nature, and every candidate query used to test
// `availabilitystatus = 'Available'`, which treats the first booking as a
// lock: the moment a rider took one order they vanished from every assignment
// path, and the per-rider load caps that exist precisely to govern this never
// got a chance to run. Whether a rider can take another job is a question
// about how much they are already carrying — counted from their open
// assignments — not about whether they are carrying anything at all.
//
// Offline, Break and Blocked are the only states that take a miler out. They
// are excluded by naming the ones that are in, so a status added later is
// off-duty until someone decides otherwise.
var MilerWorkingStatuses = []string{
MilerAvailable,
MilerAssigned,
MilerOnPickup,
MilerAtCustomer,
MilerPickedUp,
MilerOnDelivery,
}
// MilerCanTakeWork reports whether a miler in this state may receive another
// booking. Load is capped separately, by counting open assignments.
func MilerCanTakeWork(status string) bool {
for _, s := range MilerWorkingStatuses {
if s == status {
return true
}
}
return false
}
// Booking sources — where a booking originated. Left as their stored literals:
// "CRM_Console" predates the outward express rename and is an existing DB value.
const (
@@ -106,6 +140,10 @@ const (
NextActionInwardAtHub = "inward_at_hub" // carry it to a base and hand it over
NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider
NextActionNone = "none" // terminal (delivered, cancelled, returned)
// NextActionReturnToSender: the parcel is being returned (RTO) — carry it
// back to the sender's pickup point. Only emitted when
// MILER_RTO_FLOW_ENABLED=true (the deployed rider app does not know it yet).
NextActionReturnToSender = "return_to_sender"
)
// Payment Modes

View File

@@ -0,0 +1,57 @@
package constants
import "testing"
// Who may be handed another booking.
//
// The rule these lock down replaced `availabilitystatus == "Available"`, which
// treated a rider's first order as a lock: from then on they were invisible to
// every assignment path, and the per-rider load caps that exist to decide this
// never ran. A courier round is multi-stop; carrying something is the normal
// state of a working miler, not a reason to be skipped.
func TestMilerCanTakeWork(t *testing.T) {
cases := []struct {
status string
want bool
why string
}{
{MilerAvailable, true, "idle and on duty"},
{MilerAssigned, true, "holding stops — the case the old rule wrongly excluded"},
{MilerOnPickup, true, "mid-collection, still adding to the round"},
{MilerAtCustomer, true, "at a door, next stop can still be planned"},
{MilerPickedUp, true, "parcels in hand"},
{MilerOnDelivery, true, "running the round"},
{MilerOffline, false, "not on duty"},
{MilerBreak, false, "on a break — do not pile work on"},
{MilerBlocked, false, "blocked by ops"},
{"", false, "unknown status is off duty, never a default yes"},
{"available", false, "the stored values are capitalised; a case slip must not silently pass"},
{"Retired", false, "a status nobody has taught this rule about is off duty"},
}
for _, tc := range cases {
if got := MilerCanTakeWork(tc.status); got != tc.want {
t.Errorf("MilerCanTakeWork(%q) = %v, want %v (%s)", tc.status, got, tc.want, tc.why)
}
}
}
// The off-duty states are excluded by omission, so a status added to the enum
// later is off duty until somebody decides otherwise. This fails if a new
// constant is added to the list without being considered here.
func TestMilerWorkingStatusesExcludesOffDuty(t *testing.T) {
offDuty := []string{MilerOffline, MilerBreak, MilerBlocked}
for _, bad := range offDuty {
for _, s := range MilerWorkingStatuses {
if s == bad {
t.Errorf("%q must not be in MilerWorkingStatuses", bad)
}
}
}
if len(MilerWorkingStatuses) != 6 {
t.Errorf("MilerWorkingStatuses has %d entries, expected 6 — a status was added or removed; "+
"confirm it should receive work before updating this count", len(MilerWorkingStatuses))
}
}

View File

@@ -37,6 +37,22 @@ func consoleTenantID(c *fiber.Ctx) int {
return tenantID
}
// clientCityID is a client login's operating city: the applocationid of the
// token's appusers row. isClient is false for Doormile staff. A client whose
// user row has no city gets city 0, and callers show them nothing rather than
// everything.
func clientCityID(c *fiber.Ctx) (city int, isClient bool) {
if isDoormileConsoleStaff(c) {
return 0, false
}
uid, _ := c.Locals("userid").(int)
var u models.AppUser
if uid == 0 || db.DB.Select("applocationid").Where("userid = ?", uid).First(&u).Error != nil {
return 0, true
}
return u.Applocationid, true
}
// isDoormileConsoleStaff reports whether the caller sees every tenant's data.
func isDoormileConsoleStaff(c *fiber.Ctx) bool {
return consoleTenantID(c) == 0
@@ -263,6 +279,9 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
"email": auth.Email,
"role": auth.Role,
"tenantid": auth.Tenantid,
// The login's operating city, so the console can offer a client
// the Doormile hubs of their own city as zones.
"applocationid": appUser.Applocationid,
},
})
}
@@ -1610,6 +1629,16 @@ func GetHubs(c *fiber.Ctx) error {
var hubs []models.Hub
query := db.DB.Where("deletedat IS NULL")
// A client login sees only the hubs of its own city. Every page that
// lists hubs (zones, order form, Fleet Ops) reads this endpoint, and it
// used to hand clients every hub in every city.
if city, isClient := clientCityID(c); isClient {
if city == 0 {
return utils.List(c, []models.Hub{}, 0)
}
query = query.Where("applocationid = ?", city)
}
if appLocationID := c.Query("applocationid"); appLocationID != "" {
query = query.Where("applocationid = ?", appLocationID)
}
@@ -1623,9 +1652,57 @@ func GetHubs(c *fiber.Ctx) error {
if err := query.Find(&hubs).Error; err != nil {
return utils.Internal(c, "failed to fetch hubs")
}
attachHubCities(hubs)
return utils.List(c, hubs, int64(len(hubs)))
}
// attachHubCities fills Hub.City from applocations, in ONE query for the whole
// page rather than one per hub — this list is read on every console page load
// through ZoneContext, so a per-row lookup would be twenty round trips for a
// field that comes from a five-row table.
//
// A hub whose applocationid matches nothing keeps an empty City. That is the
// honest answer, and callers already treat "" as "unknown": ZoneContext skips
// its city comparison rather than matching everything.
func attachHubCities(hubs []models.Hub) {
if len(hubs) == 0 {
return
}
ids := make([]int, 0, len(hubs))
seen := map[int]bool{}
for _, h := range hubs {
if h.Applocationid != 0 && !seen[h.Applocationid] {
seen[h.Applocationid] = true
ids = append(ids, h.Applocationid)
}
}
if len(ids) == 0 {
return
}
var locs []models.AppLocation
if err := db.DB.Where("applocationid IN ?", ids).Find(&locs).Error; err != nil {
// A failed lookup leaves every City empty, which is the same state the
// response had before this existed. Refusing the whole hub list because
// one derived label could not be resolved would be worse.
return
}
byID := make(map[int]string, len(locs))
for _, l := range locs {
byID[l.Applocationid] = l.Applocationname
}
applyHubCities(hubs, byID)
}
// applyHubCities writes the resolved city onto each hub. Split from the query so
// the mapping — including what happens to a hub whose applocation is missing —
// can be tested without a database.
func applyHubCities(hubs []models.Hub, byID map[int]string) {
for i := range hubs {
hubs[i].City = byID[hubs[i].Applocationid]
}
}
func CreateHub(c *fiber.Ctx) error {
req := new(dto.HubCreateRequest)
if err := c.BodyParser(req); err != nil {
@@ -1659,7 +1736,16 @@ func GetHubDetails(c *fiber.Ctx) error {
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
return utils.NotFound(c, "hub not found")
}
return utils.OK(c, hub)
// Same city rule as GetHubs; another city's hub reads as not found.
if city, isClient := clientCityID(c); isClient && hub.Applocationid != city {
return utils.NotFound(c, "hub not found")
}
// A one-element slice, because attachHubCities writes THROUGH the slice —
// handing it `[]models.Hub{hub}` would fill a copy and return the original
// with City still empty.
one := []models.Hub{hub}
attachHubCities(one)
return utils.OK(c, one[0])
}
func UpdateHub(c *fiber.Ctx) error {
@@ -2914,6 +3000,8 @@ func AdminCancelBooking(c *fiber.Ctx) error {
"booking_id", booking.Bookingid, "error", err)
}
closeOpenAssignments(booking.Bookingid, "Order cancelled by Doormile operations")
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
@@ -3002,6 +3090,8 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
"booking_id", booking.Bookingid, "error", err)
}
closeOpenAssignments(booking.Bookingid, "Order cancelled by Doormile operations (bulk)")
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
@@ -3113,6 +3203,13 @@ func AdminUpdateConsignmentStatus(c *fiber.Ctx) error {
return utils.NotFound(c, "consignment not found")
}
// Guard the move (reverse logistics plan, B1): this used to write any
// string onto any parcel, including Delivered onto a cancelled one.
if msg := checkGenericStatusChange(consignment.Status, req.Status); msg != "" {
tx.Rollback()
return utils.BadRequest(c, msg)
}
consignment.Status = req.Status
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {
@@ -3614,7 +3711,10 @@ func CreateException(c *fiber.Ctx) error {
func GetExceptionDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var exception models.ConsignmentException
if err := db.DB.Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
// Scoped like GetExceptions: a client could otherwise read any other
// client's exception by guessing its id.
if err := scopeViaConsignments(c, db.DB, "consignmentid").
Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
return utils.NotFound(c, "exception not found")
}
return utils.OK(c, exception)
@@ -4058,3 +4158,20 @@ func opsActorID(c *fiber.Ctx) *int {
}
return nil
}
// closeOpenAssignments closes the rider's open assignment on a booking ops
// cancelled. Without it the record stayed Assigned/Accepted for good, and
// auto-assignment counted it against the rider's cap — enough cancelled
// orders and the rider was never offered another. Best effort: the
// cancellation itself has already been saved.
func closeOpenAssignments(bookingID int, remark string) {
if err := db.DB.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND assignmentstatus IN ?", bookingID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCancelled,
"remarks": remark,
}).Error; err != nil {
utils.Error("cancel: could not close the rider's assignment", "booking_id", bookingID, "error", err)
}
}

View File

@@ -0,0 +1,64 @@
package controllers
import (
"time"
"doormile/db"
"doormile/internal/ai/telemetry"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// GetAIInsights — GET /admin/ai/insights?days=7
//
// What AI_engine's agents did over the window: runs and failures per agent
// (aiagentruns, from telemetry.task), decisions by type and outcome
// (agent_decisions), and each agent's latest heartbeat (Redis). `receiving`
// says whether this backend is subscribed to the telemetry at all, so an
// empty page can tell "not connected" from "nothing happened".
func GetAIInsights(c *fiber.Ctx) error {
days := telemetry.ClampDays(c.QueryInt("days", 7))
// A real instant: aiagentruns.receivedat and agent_decisions.created_at are
// both timestamptz (see telemetry.NewRecorder on why not utils.DBNow).
since := time.Now().AddDate(0, 0, -days)
runs, err := telemetry.RunStats(db.DB, since)
if err != nil {
utils.Error("ai insights: runs", "error", err.Error())
return utils.Internal(c, "failed to read agent runs")
}
decisions, err := telemetry.DecisionCounts(db.DB, since)
if err != nil {
utils.Error("ai insights: decisions", "error", err.Error())
return utils.Internal(c, "failed to read agent decisions")
}
var engineAgents []string
if err := db.DB.Model(&models.AIAgent{}).Where("runtime = ?", "engine").Order("sortorder").Pluck("agentid", &engineAgents).Error; err != nil {
utils.Error("ai insights: agents", "error", err.Error())
}
return utils.OK(c, telemetry.Insights{
Days: days,
Since: since,
Receiving: telemetry.Receiving.Load(),
Runs: telemetry.SummariseRuns(runs),
Decisions: telemetry.SummariseDecisions(decisions),
Live: telemetry.LiveStates(db.Rdb, engineAgents),
})
}
// GetAIDecisions — GET /admin/ai/decisions?type=&before=&limit=
//
// Recent agent decisions, newest first, keyset-paged by id. Reasoning is
// trimmed and the context column is left out (it can hold rider data).
func GetAIDecisions(c *fiber.Ctx) error {
rows, err := telemetry.RecentDecisions(db.DB, c.Query("type"), uint64(c.QueryInt("before", 0)), c.QueryInt("limit", 25))
if err != nil {
utils.Error("ai insights: recent decisions", "error", err.Error())
return utils.Internal(c, "failed to read agent decisions")
}
return utils.List(c, rows, int64(len(rows)))
}

View File

@@ -0,0 +1,127 @@
package controllers
import (
"context"
"errors"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"doormile/db"
"doormile/internal/ai/playground"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// POST /admin/ai/playground/run — Agent Studio's Test tab. Runs one prompt
// through the configured model with a registry skill's tools; see internal/ai/playground for
// what executes and what only becomes a proposal. Staff only, roleid 1 only,
// and rate-limited per user because every run is a paid API call.
// PlaygroundModel is the model client the playground uses (an OpenAI-compatible
// provider such as Groq, see main.go). Nil until PLAYGROUND_LLM_API_KEY is set; the endpoint then answers 503 and the console keeps the
// Test tab labelled as unavailable.
var PlaygroundModel playground.Model
const (
playgroundRunTimeout = 120 * time.Second
playgroundRunsPerWin = 10
playgroundWindow = 10 * time.Minute
)
type playgroundLimiter struct {
mu sync.Mutex
runs map[string][]time.Time
}
var playgroundRuns = &playgroundLimiter{runs: map[string][]time.Time{}}
// allow records a run for key and reports whether it is within the limit.
func (l *playgroundLimiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
kept := l.runs[key][:0]
for _, t := range l.runs[key] {
if now.Sub(t) < playgroundWindow {
kept = append(kept, t)
}
}
if len(kept) >= playgroundRunsPerWin {
l.runs[key] = kept
return false
}
l.runs[key] = append(kept, now)
return true
}
// RunAIPlayground — POST /admin/ai/playground/run {agentid, skillid?, prompt}
func RunAIPlayground(c *fiber.Ctx) error {
if PlaygroundModel == nil {
return utils.Fail(c, fiber.StatusServiceUnavailable, "PLAYGROUND_NOT_CONFIGURED",
"The Test playground has no model configured on this server.")
}
var body struct {
Agentid string `json:"agentid"`
Skillid string `json:"skillid"`
Prompt string `json:"prompt"`
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
body.Prompt = strings.TrimSpace(body.Prompt)
if body.Agentid == "" || body.Prompt == "" {
return utils.BadRequest(c, "agentid and prompt are required")
}
if utf8.RuneCountInString(body.Prompt) > playground.MaxPromptChars {
return utils.BadRequest(c, "prompt is too long (at most 2000 characters)")
}
actor := actorOf(c)
key := actor.Email
if key == "" {
key = "user:" + strconv.Itoa(actor.UserID)
}
if !playgroundRuns.allow(key, time.Now()) {
return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_RATE_LIMITED",
"Playground limit reached: 10 runs per 10 minutes. Try again shortly.")
}
snap, ok := loadRegistry(c)
if !ok {
return nil
}
plan, err := playground.Prepare(snap, body.Agentid, body.Skillid)
if errors.Is(err, playground.ErrNotFound) {
return utils.NotFound(c, err.Error())
}
if err != nil {
return utils.BadRequest(c, err.Error())
}
// An OpenAI-compatible provider serves its own configured model, not the
// agent's registry pin (a Claude id AI_engine uses); report the real one.
if named, ok := PlaygroundModel.(interface{ ModelName() string }); ok {
plan.Model = named.ModelName()
}
ctx, cancel := context.WithTimeout(context.Background(), playgroundRunTimeout)
defer cancel()
trace, err := playground.Run(ctx, PlaygroundModel, plan, body.Prompt, playground.Executors(db.DB, db.Rdb))
utils.Info("ai playground run", "email", actor.Email, "agent", plan.AgentID, "skill", plan.SkillID,
"model", plan.Model, "turns", trace.Turns, "ms", trace.Ms, "failed", err != nil)
if err != nil {
utils.Error("ai playground: model call failed", "error", err.Error())
var pe *playground.ProviderError
if errors.As(err, &pe) && pe.Status == fiber.StatusTooManyRequests {
return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_PROVIDER_RATE_LIMITED",
"The model provider's rate limit was reached (common on free plans). Wait a minute and try again.")
}
return utils.Fail(c, fiber.StatusBadGateway, "PLAYGROUND_MODEL_FAILED",
"The model request failed; nothing was changed. Try again.")
}
return utils.OK(c, trace)
}

View File

@@ -0,0 +1,218 @@
package controllers
import (
"errors"
"doormile/db"
"doormile/internal/ai/registry"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// The AI agent registry: /admin/ai/* for the console's Agent Studio and
// /internal/ai/registry for AI_engine. Every route here sits behind
// DoormileStaffOnly (admin) or InternalKeyAuth (internal); writes additionally
// require roleid 1. Logic lives in internal/ai/registry — these handlers only
// translate HTTP.
// registryError maps a registry error to a response. Validation messages are
// written for operators and returned as-is; anything else is logged, not leaked.
func registryError(c *fiber.Ctx, err error, what string) error {
var v *registry.ValidationError
switch {
case errors.As(err, &v):
return utils.BadRequest(c, v.Msg)
case errors.Is(err, registry.ErrNotFound):
return utils.NotFound(c, what+" not found")
default:
utils.Error("ai registry: "+what, "error", err.Error())
return utils.Internal(c, "failed to update the agent registry")
}
}
// actorOf is the caller as the registry audit records it: the user id and the
// email from the token (set by AuthMiddleware).
func actorOf(c *fiber.Ctx) registry.Actor {
userID, _ := c.Locals("userid").(int)
email, _ := c.Locals("email").(string)
return registry.Actor{UserID: userID, Email: email}
}
func loadRegistry(c *fiber.Ctx) (*registry.Snapshot, bool) {
snap, err := registry.Load(db.DB)
if err != nil {
utils.Error("ai registry: load", "error", err.Error())
_ = utils.Internal(c, "failed to read the agent registry")
return nil, false
}
return snap, true
}
// GetAIAgents — GET /admin/ai/agents
func GetAIAgents(c *fiber.Ctx) error {
snap, ok := loadRegistry(c)
if !ok {
return nil
}
return utils.List(c, snap.Agents, int64(len(snap.Agents)))
}
// GetAIAgent — GET /admin/ai/agents/:id, the agent with its skills and tools.
func GetAIAgent(c *fiber.Ctx) error {
snap, ok := loadRegistry(c)
if !ok {
return nil
}
id := c.Params("id")
for _, a := range snap.Agents {
if a.Agentid != id {
continue
}
skills := []registry.SkillView{}
used := map[string]bool{}
for _, s := range snap.Skills {
if s.Agentid == id {
skills = append(skills, s)
for _, t := range s.Tools {
used[t] = true
}
}
}
tools := []registry.ToolView{}
for _, t := range snap.Tools {
if used[t.Toolname] {
tools = append(tools, t)
}
}
return utils.OK(c, fiber.Map{"agent": a, "skills": skills, "tools": tools})
}
return utils.NotFound(c, "agent not found")
}
// GetAISkills — GET /admin/ai/skills[?agent=]
func GetAISkills(c *fiber.Ctx) error {
snap, ok := loadRegistry(c)
if !ok {
return nil
}
agent := c.Query("agent")
out := []registry.SkillView{}
for _, s := range snap.Skills {
if agent == "" || s.Agentid == agent {
out = append(out, s)
}
}
return utils.List(c, out, int64(len(out)))
}
// GetAITools — GET /admin/ai/tools[?kind=]
func GetAITools(c *fiber.Ctx) error {
snap, ok := loadRegistry(c)
if !ok {
return nil
}
kind := c.Query("kind")
out := []registry.ToolView{}
for _, t := range snap.Tools {
if kind == "" || t.Kind == kind {
out = append(out, t)
}
}
return utils.List(c, out, int64(len(out)))
}
// PatchAISkill — PATCH /admin/ai/skills/:id {enabled?, thresholds?}
func PatchAISkill(c *fiber.Ctx) error {
var p registry.SkillPatch
if err := c.BodyParser(&p); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if err := registry.UpdateSkill(db.DB, c.Params("id"), p, actorOf(c)); err != nil {
return registryError(c, err, "skill")
}
snap, ok := loadRegistry(c)
if !ok {
return nil
}
for _, s := range snap.Skills {
if s.Skillid == c.Params("id") {
return utils.OK(c, s)
}
}
return utils.NotFound(c, "skill not found")
}
// CreateAISkill — POST /admin/ai/skills
func CreateAISkill(c *fiber.Ctx) error {
var n registry.NewSkill
if err := c.BodyParser(&n); err != nil {
return utils.BadRequest(c, "invalid request body")
}
id, err := registry.CreateSkill(db.DB, n, actorOf(c))
if err != nil {
return registryError(c, err, "skill")
}
snap, ok := loadRegistry(c)
if !ok {
return nil
}
for _, s := range snap.Skills {
if s.Skillid == id {
return utils.Created(c, s)
}
}
return utils.Internal(c, "skill was created but could not be read back")
}
// PatchAIAgent — PATCH /admin/ai/agents/:id {autonomous?, model?, confirm?}
func PatchAIAgent(c *fiber.Ctx) error {
var p registry.AgentPatch
if err := c.BodyParser(&p); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if err := registry.UpdateAgent(db.DB, c.Params("id"), p, actorOf(c)); err != nil {
return registryError(c, err, "agent")
}
snap, ok := loadRegistry(c)
if !ok {
return nil
}
for _, a := range snap.Agents {
if a.Agentid == c.Params("id") {
return utils.OK(c, a)
}
}
return utils.NotFound(c, "agent not found")
}
// GetAIRegistryAudit — GET /admin/ai/audit[?limit=]
func GetAIRegistryAudit(c *fiber.Ctx) error {
rows, err := registry.ListAudit(db.DB, c.QueryInt("limit", 100))
if err != nil {
utils.Error("ai registry: audit", "error", err.Error())
return utils.Internal(c, "failed to read the registry audit")
}
return utils.List(c, rows, int64(len(rows)))
}
// GetInternalAIRegistry — GET /internal/ai/registry, for AI_engine.
//
// Sends an ETag and honours If-None-Match, so the engine can poll every few
// seconds and receive a 304 with no body until something actually changes.
func GetInternalAIRegistry(c *fiber.Ctx) error {
snap, ok := loadRegistry(c)
if !ok {
return nil
}
// Proof the engine is following the registry — see GetAIStatus. Counted
// for 304s too: an unchanged registry is still a successful read.
markRegistryRead()
tag := registry.ETag(snap)
c.Set(fiber.HeaderETag, tag)
c.Set(fiber.HeaderCacheControl, "no-cache")
if c.Get(fiber.HeaderIfNoneMatch) == tag {
return c.SendStatus(fiber.StatusNotModified)
}
return utils.OK(c, snap)
}

View File

@@ -0,0 +1,100 @@
package controllers
import (
"context"
"strconv"
"sync/atomic"
"time"
"doormile/db"
"doormile/internal/ai/telemetry"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// GET /admin/ai/status — what is actually wired, for the banner on Settings →
// Skills & Tools. Replaces a fixed "Half wired" note that stayed on screen
// after everything was deployed, because nothing on the page checked.
//
// "AI_engine reads these settings" is observed, not assumed: every poll of
// GET /internal/ai/registry (a 200 or a 304) stamps the time in Redis, shared by
// every backend replica. The engine polls about every 30 seconds, so a stamp
// younger than engineReadFreshFor means it is following the registry now.
const (
registryReadKey = "ai:registry:lastread"
engineReadFreshFor = 5 * time.Minute
)
// lastRegistryRead is the in-process copy, used when Redis is unavailable.
var lastRegistryRead atomic.Int64
// markRegistryRead records an AI_engine registry poll. Best effort: a Redis
// failure must never fail the poll itself.
func markRegistryRead() {
now := time.Now().Unix()
lastRegistryRead.Store(now)
if db.Rdb == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = db.Rdb.Set(ctx, registryReadKey, now, 30*time.Minute).Err()
}
// registryLastRead returns the latest poll time seen by any replica, or nil.
func registryLastRead() *time.Time {
secs := lastRegistryRead.Load()
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if v, err := db.Rdb.Get(ctx, registryReadKey).Result(); err == nil {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > secs {
secs = n
}
}
}
if secs == 0 {
return nil
}
t := time.Unix(secs, 0)
return &t
}
type aiStatus struct {
Engine struct {
ReadingSettings bool `json:"readingsettings"`
LastReadAt *time.Time `json:"lastreadat"`
Telemetry bool `json:"telemetry"`
LiveAgents int `json:"liveagents"`
} `json:"engine"`
Playground struct {
Configured bool `json:"configured"`
Model string `json:"model,omitempty"`
} `json:"playground"`
}
// GetAIStatus — GET /admin/ai/status
func GetAIStatus(c *fiber.Ctx) error {
var s aiStatus
s.Engine.LastReadAt = registryLastRead()
s.Engine.ReadingSettings = s.Engine.LastReadAt != nil && time.Since(*s.Engine.LastReadAt) < engineReadFreshFor
s.Engine.Telemetry = telemetry.Receiving.Load()
var engineAgents []string
if db.DB != nil {
db.DB.Model(&models.AIAgent{}).Where("runtime = ?", "engine").Pluck("agentid", &engineAgents)
}
s.Engine.LiveAgents = len(telemetry.LiveStates(db.Rdb, engineAgents))
if PlaygroundModel != nil {
s.Playground.Configured = true
if named, ok := PlaygroundModel.(interface{ ModelName() string }); ok {
s.Playground.Model = named.ModelName()
}
}
return utils.OK(c, s)
}

View File

@@ -0,0 +1,20 @@
package controllers
import (
"testing"
"time"
)
// Without Redis (as in this test binary) the in-process stamp still works, so
// a single replica reports the engine's reads correctly.
func TestRegistryReadStampWithoutRedis(t *testing.T) {
lastRegistryRead.Store(0)
if registryLastRead() != nil {
t.Fatal("no read yet must report nil")
}
markRegistryRead()
got := registryLastRead()
if got == nil || time.Since(*got) > 5*time.Second {
t.Fatalf("stamp not recorded: %v", got)
}
}

View File

@@ -0,0 +1,35 @@
package controllers
import (
"testing"
"doormile/constants"
"doormile/models"
)
// Cancelling an order from the console must close the rider's assignment on
// it. It used to stay Assigned/Accepted for good, and auto-assignment counted
// it against the rider's cap — enough cancelled orders and the rider was never
// offered another. Uses rtoTestDB (consignmentReturn_pg_test.go): skipped
// unless REGISTRY_TEST_DSN points at a throwaway database.
func TestCancelClosesTheRidersAssignment(t *testing.T) {
gdb := rtoTestDB(t)
rider := 38
must(t, gdb.Create(&models.PickupBooking{Bookingid: 71, Bookingno: "DM-T71", Status: constants.BookingMilerAssigned,
Assignedmileruserid: &rider}).Error)
must(t, gdb.Create(&models.BookingAssignment{Bookingid: 71, Mileruserid: rider, Assignmentstatus: constants.AssignmentAccepted}).Error)
// A closed assignment on the same booking must be left as it is.
must(t, gdb.Create(&models.BookingAssignment{Bookingid: 71, Mileruserid: 21, Assignmentstatus: constants.AssignmentRejected}).Error)
closeOpenAssignments(71, "Order cancelled by Doormile operations")
var rows []models.BookingAssignment
must(t, gdb.Where("bookingid = ?", 71).Order("mileruserid").Find(&rows).Error)
got := map[int]string{}
for _, r := range rows {
got[r.Mileruserid] = r.Assignmentstatus
}
if got[38] != constants.AssignmentCancelled || got[21] != constants.AssignmentRejected {
t.Fatalf("assignments after cancel = %v", got)
}
}

View File

@@ -0,0 +1,716 @@
package controllers
import (
"errors"
"net/mail"
"regexp"
"strings"
"time"
"unicode/utf8"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Client onboarding: one call creates everything a new client needs to sign in
// to the console, in one transaction —
//
// tenants the client company (the tenant every booking is scoped to)
// doormile_auth the console login LoginAdmin checks: email, bcrypt hash,
// role "manager", tenantid = the new tenant
// appusers the user row LoginAdmin reads the userid and name from,
// roleid 3, tenantid = the new tenant
//
// A client needs all three: an appusers row alone cannot log in (LoginAdmin
// authenticates against doormile_auth), and a doormile_auth row without a
// tenantid would be Doormile STAFF — unscoped, seeing every client's data.
//
// The client login gets role "manager" (roleid 3), not "admin": nothing a
// client does needs roleid 1, and roleid 1 is what gates the agent-registry
// writes. Their data scope comes from the tenantid in the token.
//
// Routes sit behind ClientOnboardingOwnerOnly (see routes.go).
const clientLoginRole = "manager"
const clientLoginRoleID = 3
var indianMobile = regexp.MustCompile(`^[6-9]\d{9}$`)
type onboardClientRequest struct {
Companyname string `json:"companyname"`
Contactname string `json:"contactname"`
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
Applocationid int `json:"applocationid"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
// The client's main address (flat in the JSON). Saved as their primary
// tenantlocations row, which is what a client login's zone list and the
// order form's pickup "Business Hub" read. A client onboarded without one
// had an empty zone list and no pickup point to start from.
clientAddress
}
// clientAddress is one client location as the onboarding form sends it, after
// the operator picked it from the address search (which supplies the map
// coordinates).
type clientAddress struct {
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Pincode string `json:"pincode"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
var indianPincode = regexp.MustCompile(`^[1-9]\d{5}$`)
// validate normalises the address in place and returns an operator-readable
// message for the first problem, or "". Kept apart from the request's own
// validate because an edit may leave the address alone.
func (a *clientAddress) validate() string {
a.Address = strings.Join(strings.Fields(a.Address), " ")
a.City = strings.Join(strings.Fields(a.City), " ")
a.State = strings.Join(strings.Fields(a.State), " ")
a.Pincode = strings.ReplaceAll(strings.TrimSpace(a.Pincode), " ", "")
switch n := utf8.RuneCountInString(a.Address); {
case n < 5:
return "enter the client's address"
case n > 300:
return "address is too long (at most 300 characters)"
}
if !indianPincode.MatchString(a.Pincode) {
return "enter a valid 6-digit pincode"
}
if utf8.RuneCountInString(a.City) > 80 || utf8.RuneCountInString(a.State) > 80 {
return "city or state is too long (at most 80 characters)"
}
// Roughly India's bounding box. Zero (no pick) and swapped lat/lon both
// land outside it, and a location without real coordinates would match no
// zone and give the rider nowhere to go.
if a.Latitude < 6 || a.Latitude > 37.5 || a.Longitude < 68 || a.Longitude > 97.5 {
return "pick the address from the suggestions so it has a map location"
}
return ""
}
// normalisePhone strips spaces, dashes and a +91/91/0 prefix.
func normalisePhone(p string) string {
p = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(strings.TrimSpace(p))
p = strings.TrimPrefix(p, "+91")
if len(p) == 12 && strings.HasPrefix(p, "91") {
p = p[2:]
}
if len(p) == 11 && strings.HasPrefix(p, "0") {
p = p[1:]
}
return p
}
// validate normalises the request in place and returns an operator-readable
// message for the first problem, or "".
func (r *onboardClientRequest) validate() string {
r.Companyname = strings.Join(strings.Fields(r.Companyname), " ")
r.Contactname = strings.Join(strings.Fields(r.Contactname), " ")
r.Email = strings.ToLower(strings.TrimSpace(r.Email))
r.Phone = normalisePhone(r.Phone)
switch n := utf8.RuneCountInString(r.Companyname); {
case n < 2:
return "company name is required"
case n > 120:
return "company name is too long (at most 120 characters)"
}
if utf8.RuneCountInString(r.Contactname) < 2 || utf8.RuneCountInString(r.Contactname) > 80 {
return "contact person's name is required (at most 80 characters)"
}
if addr, err := mail.ParseAddress(r.Email); err != nil || addr.Address != r.Email || !strings.Contains(r.Email[strings.LastIndex(r.Email, "@"):], ".") {
return "enter a valid email address"
}
if !indianMobile.MatchString(r.Phone) {
return "enter a valid 10-digit mobile number"
}
switch n := utf8.RuneCountInString(r.Password); {
case n < 8:
return "password must be at least 8 characters"
case n > 72: // bcrypt ignores everything past 72 bytes
return "password is too long (at most 72 characters)"
}
if strings.EqualFold(r.Password, r.Email) || strings.EqualFold(r.Password, r.Phone) {
return "password must not be the email or the phone number"
}
if r.Applocationid <= 0 {
return "choose the client's operating city"
}
return ""
}
// errOnboardingConflict carries a 409 message out of the transaction.
type errOnboardingConflict struct{ msg string }
func (e errOnboardingConflict) Error() string { return e.msg }
func isUniqueViolation(err error) bool {
s := err.Error()
return strings.Contains(s, "23505") || strings.Contains(strings.ToLower(s), "duplicate key")
}
// onboardingOwnerStillValid re-reads the caller's doormile_auth row: still an
// admin, still Doormile staff. The middleware checked the token; this checks
// the account behind it has not been removed or demoted since it was issued.
func onboardingOwnerStillValid(email string) bool {
var n int64
db.DB.Model(&models.DoormileAuth{}).
Where("LOWER(email) = ? AND role = ? AND tenantid IS NULL", strings.ToLower(email), "admin").
Count(&n)
return n == 1
}
// OnboardClient — POST /admin/clients/onboard
func OnboardClient(c *fiber.Ctx) error {
actor := actorOf(c)
if !onboardingOwnerStillValid(actor.Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
req := new(onboardClientRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if msg := req.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
if msg := req.clientAddress.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
hash, err := utils.HashPassword(req.Password)
if err != nil {
return utils.Internal(c, "failed to process the password")
}
var tenant models.Tenant
var user models.AppUser
var auth models.DoormileAuth
var location models.TenantLocation
err = db.DB.Transaction(func(tx *gorm.DB) error {
var city models.AppLocation
if err := tx.Where("applocationid = ?", req.Applocationid).First(&city).Error; err != nil {
return errOnboardingConflict{"that operating city does not exist"}
}
var n int64
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?)", req.Companyname).Count(&n)
if n > 0 {
return errOnboardingConflict{"a client with this company name already exists"}
}
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ?", req.Email).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email already has a console login"}
}
tx.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email is already used by another user"}
}
tenant = models.Tenant{
Tenantname: req.Companyname,
Primaryemail: req.Email,
Primarycontact: req.Phone,
Status: "Active",
Requiredeliveryotp: req.Requiredeliveryotp,
}
if err := tx.Create(&tenant).Error; err != nil {
return err
}
tenantID := tenant.Tenantid
// The client's main address, as their primary location, in the same
// transaction: a client is never created without it.
cityName := req.City
if cityName == "" {
cityName = city.Applocationname
}
location = models.TenantLocation{
Tenantid: tenantID,
Locationname: req.Companyname,
Address: req.Address,
City: cityName,
State: req.State,
Pincode: req.Pincode,
Latitude: req.Latitude,
Longitude: req.Longitude,
Isprimary: true,
Status: "Active",
}
if err := tx.Create(&location).Error; err != nil {
return err
}
auth = models.DoormileAuth{Email: req.Email, PasswordHash: hash, Role: clientLoginRole, Tenantid: &tenantID}
if err := tx.Create(&auth).Error; err != nil {
return err
}
user = models.AppUser{
Authname: req.Contactname,
Email: req.Email,
Contactno: req.Phone,
Password: hash,
Roleid: clientLoginRoleID,
Tenantid: tenantID,
Applocationid: req.Applocationid,
Status: "Active",
}
return tx.Create(&user).Error
})
var conflict errOnboardingConflict
switch {
case errors.As(err, &conflict):
if conflict.msg == "that operating city does not exist" {
return utils.BadRequest(c, conflict.msg)
}
return utils.Conflict(c, conflict.msg)
case err != nil && isUniqueViolation(err):
// Lost a race with a concurrent onboarding of the same email.
return utils.Conflict(c, "this email already has a console login")
case err != nil:
utils.Error("client onboarding failed", "error", err.Error(), "by", actor.Email)
return utils.Internal(c, "failed to onboard the client; nothing was created")
}
utils.Info("client onboarded", "by", actor.Email, "tenantid", tenant.Tenantid, "login", auth.Email, "userid", user.Userid)
return utils.Created(c, fiber.Map{
"tenant": fiber.Map{
"tenantid": tenant.Tenantid,
"tenantname": tenant.Tenantname,
"primaryemail": tenant.Primaryemail,
"primarycontact": tenant.Primarycontact,
"status": tenant.Status,
"requiredeliveryotp": tenant.Requiredeliveryotp,
},
"location": fiber.Map{
"tenantlocationid": location.Tenantlocationid,
"address": location.Address,
"city": location.City,
"state": location.State,
"pincode": location.Pincode,
},
"login": fiber.Map{
"email": auth.Email,
"role": auth.Role,
"userid": user.Userid,
"name": user.Authname,
"tenantid": tenant.Tenantid,
},
})
}
type onboardedClient struct {
Authid uint64 `json:"authid"`
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Status string `json:"status"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
Contactname string `json:"contactname"`
Loginemail string `json:"loginemail"`
Loginrole string `json:"loginrole"`
Logincreatedat *time.Time `json:"logincreatedat"`
// The client's main address (primary location); empty for a client
// onboarded before addresses were collected.
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Pincode string `json:"pincode"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// realTime drops the zero/placeholder timestamps some older logins carry (they
// render as "1 Jan 0001"), so the console shows "—" instead of a fake date.
func realTime(t *time.Time) *time.Time {
if t == nil || t.Year() < 2000 {
return nil
}
return t
}
// GetOnboardedClients — GET /admin/clients/onboarded: the clients that have a
// console login, newest first. One row per login. Never returns a password hash.
func GetOnboardedClients(c *fiber.Ctx) error {
if !onboardingOwnerStillValid(actorOf(c).Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
var rows []onboardedClientRow
err := db.DB.Table("doormile_auth AS a").
Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status,
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
a.email AS loginemail, a.role AS loginrole,
a.created_at AS authcreatedat, t.createdat AS tenantcreatedat,
COALESCE(l.address, '') AS address, COALESCE(l.city, '') AS city, COALESCE(l.state, '') AS state,
COALESCE(l.pincode, '') AS pincode, COALESCE(l.latitude, 0) AS latitude, COALESCE(l.longitude, 0) AS longitude`).
Joins("JOIN tenants t ON t.tenantid = a.tenantid").
Joins("LEFT JOIN appusers u ON LOWER(u.email) = LOWER(a.email) AND u.tenantid = a.tenantid").
Joins(`LEFT JOIN LATERAL (
SELECT address, city, state, pincode, latitude, longitude FROM tenantlocations
WHERE tenantid = t.tenantid AND (status IS NULL OR status = '' OR LOWER(status) = 'active')
ORDER BY isprimary DESC, tenantlocationid LIMIT 1) l ON TRUE`).
Where("a.tenantid IS NOT NULL").
Order("a.id DESC").
Limit(200).
Scan(&rows).Error
if err != nil {
utils.Error("list onboarded clients", "error", err.Error())
return utils.Internal(c, "failed to list clients")
}
out := make([]onboardedClient, 0, len(rows))
for _, r := range rows {
out = append(out, r.toClient())
}
return utils.List(c, out, int64(len(out)))
}
// onboardedClientRow is what the list query scans into. It is deliberately
// FLAT with every column named: GORM silently skips an embedded struct of an
// unexported type, which once left every field but the dates empty (and every
// authid 0). TestOnboardedClientRowMapsEveryColumn guards this.
type onboardedClientRow struct {
Authid uint64 `gorm:"column:authid"`
Tenantid int `gorm:"column:tenantid"`
Tenantname string `gorm:"column:tenantname"`
Primaryemail string `gorm:"column:primaryemail"`
Primarycontact string `gorm:"column:primarycontact"`
Status string `gorm:"column:status"`
Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"`
Contactname string `gorm:"column:contactname"`
Loginemail string `gorm:"column:loginemail"`
Loginrole string `gorm:"column:loginrole"`
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
Address string `gorm:"column:address"`
City string `gorm:"column:city"`
State string `gorm:"column:state"`
Pincode string `gorm:"column:pincode"`
Latitude float64 `gorm:"column:latitude"`
Longitude float64 `gorm:"column:longitude"`
}
func (r onboardedClientRow) toClient() onboardedClient {
created := realTime(r.Authcreatedat) // timestamptz: already the right instant
if created == nil {
// tenants.createdat is a legacy timestamp WITHOUT zone holding IST
// digits; read as UTC it shows 5h30m late. utils.IST puts it right.
if t := realTime(r.Tenantcreatedat); t != nil {
ist := utils.IST(*t)
created = &ist
}
}
return onboardedClient{
Authid: r.Authid, Tenantid: r.Tenantid, Tenantname: r.Tenantname,
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
Address: r.Address, City: r.City, State: r.State, Pincode: r.Pincode,
Latitude: r.Latitude, Longitude: r.Longitude,
}
}
// loadClientLogin finds a CLIENT login by doormile_auth id. A Doormile staff
// login (tenantid NULL) is reported as not found: these routes never touch one.
func loadClientLogin(authID string) (*models.DoormileAuth, error) {
var auth models.DoormileAuth
if err := db.DB.Where("id = ? AND tenantid IS NOT NULL", authID).First(&auth).Error; err != nil {
return nil, err
}
return &auth, nil
}
type updateClientRequest struct {
Companyname *string `json:"companyname"`
Contactname *string `json:"contactname"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Status *string `json:"status"`
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
Password *string `json:"password"` // optional reset; empty = unchanged
// Location replaces the client's main address (primary location), or
// creates it for a client onboarded before addresses were collected.
Location *clientAddress `json:"location"`
}
var clientStatuses = map[string]string{"active": "Active", "pending": "Pending", "inactive": "Inactive"}
// UpdateOnboardedClient — PUT /admin/clients/:id (id = the login's authid).
// Edits the client company (tenants) and that login (doormile_auth + appusers)
// in one transaction. Only fields sent are changed.
func UpdateOnboardedClient(c *fiber.Ctx) error {
actor := actorOf(c)
if !onboardingOwnerStillValid(actor.Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
auth, err := loadClientLogin(c.Params("id"))
if err != nil {
return utils.NotFound(c, "client login not found")
}
req := new(updateClientRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// Validate by reusing the onboarding rules on a filled-in copy.
var tenant models.Tenant
if err := db.DB.First(&tenant, *auth.Tenantid).Error; err != nil {
return utils.NotFound(c, "client not found")
}
check := onboardClientRequest{
Companyname: tenant.Tenantname, Contactname: "xx", Email: auth.Email,
Phone: tenant.Primarycontact, Password: "unchanged-ok", Applocationid: 1,
}
if req.Companyname != nil {
check.Companyname = *req.Companyname
}
if req.Contactname != nil {
check.Contactname = *req.Contactname
}
if req.Email != nil {
check.Email = *req.Email
} else {
check.Email = "unchanged@doormile.example" // as with the phone: only a changed email is validated
}
if req.Phone != nil {
check.Phone = *req.Phone
} else {
// An older client may carry a phone that fails today's rule; only a
// phone the caller is actually changing is validated.
check.Phone = "9000000000"
}
newPassword := ""
if req.Password != nil && *req.Password != "" {
newPassword = *req.Password
check.Password = newPassword
}
if msg := check.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
if req.Location != nil {
if msg := req.Location.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
}
status := tenant.Status
if req.Status != nil {
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
if !ok {
return utils.BadRequest(c, "status must be Active, Pending or Inactive")
}
status = s
}
var hash string
if newPassword != "" {
if hash, err = utils.HashPassword(newPassword); err != nil {
return utils.Internal(c, "failed to process the password")
}
}
oldEmail := auth.Email
err = db.DB.Transaction(func(tx *gorm.DB) error {
var n int64
if req.Companyname != nil && !strings.EqualFold(check.Companyname, tenant.Tenantname) {
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?) AND tenantid <> ?", check.Companyname, tenant.Tenantid).Count(&n)
if n > 0 {
return errOnboardingConflict{"a client with this company name already exists"}
}
}
emailChanged := req.Email != nil && check.Email != strings.ToLower(oldEmail)
if emailChanged {
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ? AND id <> ?", check.Email, auth.ID).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email already has a console login"}
}
tx.Model(&models.AppUser{}).Where("LOWER(email) = ? AND LOWER(email) <> LOWER(?)", check.Email, oldEmail).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email is already used by another user"}
}
}
tenantUpdates := map[string]any{"status": status, "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}
if req.Companyname != nil {
tenantUpdates["tenantname"] = check.Companyname
}
if req.Phone != nil {
tenantUpdates["primarycontact"] = check.Phone
}
if emailChanged && strings.EqualFold(tenant.Primaryemail, oldEmail) {
tenantUpdates["primaryemail"] = check.Email
}
if req.Requiredeliveryotp != nil {
tenantUpdates["requiredeliveryotp"] = *req.Requiredeliveryotp
}
if err := tx.Model(&models.Tenant{}).Where("tenantid = ?", tenant.Tenantid).Updates(tenantUpdates).Error; err != nil {
return err
}
authUpdates := map[string]any{"updated_at": time.Now()}
if emailChanged {
authUpdates["email"] = check.Email
}
if hash != "" {
authUpdates["password_hash"] = hash
}
if err := tx.Model(&models.DoormileAuth{}).Where("id = ?", auth.ID).Updates(authUpdates).Error; err != nil {
return err
}
userUpdates := map[string]any{}
if emailChanged {
userUpdates["email"] = check.Email
}
if req.Contactname != nil {
userUpdates["authname"] = check.Contactname
}
if req.Phone != nil {
userUpdates["contactno"] = check.Phone
}
if hash != "" {
userUpdates["password"] = hash
}
if len(userUpdates) > 0 {
userUpdates["updatedat"] = gorm.Expr("CURRENT_TIMESTAMP")
if err := tx.Model(&models.AppUser{}).
Where("LOWER(email) = LOWER(?) AND tenantid = ?", oldEmail, tenant.Tenantid).
Updates(userUpdates).Error; err != nil {
return err
}
}
if req.Location != nil {
name := tenant.Tenantname
if req.Companyname != nil {
name = check.Companyname
}
return saveMainAddress(tx, tenant.Tenantid, name, *req.Location)
}
return nil
})
var conflict errOnboardingConflict
switch {
case errors.As(err, &conflict):
return utils.Conflict(c, conflict.msg)
case err != nil && isUniqueViolation(err):
return utils.Conflict(c, "this email already has a console login")
case err != nil:
utils.Error("client update failed", "error", err.Error(), "by", actor.Email)
return utils.Internal(c, "failed to update the client; nothing was changed")
}
utils.Info("client updated", "by", actor.Email, "tenantid", tenant.Tenantid, "authid", auth.ID,
"password_reset", hash != "", "email_changed", req.Email != nil && check.Email != strings.ToLower(oldEmail),
"address_changed", req.Location != nil)
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenant.Tenantid, "status": status, "password_reset": hash != ""})
}
// saveMainAddress updates the client's main address (the primary location,
// else its first active one) or, when it has none, creates it as primary.
// City falls back to the existing one when the form sent none.
func saveMainAddress(tx *gorm.DB, tenantID int, name string, a clientAddress) error {
var loc models.TenantLocation
err := tx.Where("tenantid = ? AND (status IS NULL OR status = '' OR LOWER(status) = 'active')", tenantID).
Order("isprimary DESC, tenantlocationid").First(&loc).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&models.TenantLocation{
Tenantid: tenantID, Locationname: name, Address: a.Address, City: a.City, State: a.State,
Pincode: a.Pincode, Latitude: a.Latitude, Longitude: a.Longitude, Isprimary: true, Status: "Active",
}).Error
}
if err != nil {
return err
}
updates := map[string]any{
"address": a.Address, "pincode": a.Pincode, "latitude": a.Latitude, "longitude": a.Longitude,
"isprimary": true, "updatedat": gorm.Expr("CURRENT_TIMESTAMP"),
}
if a.City != "" {
updates["city"] = a.City
}
if a.State != "" {
updates["state"] = a.State
}
return tx.Model(&models.TenantLocation{}).Where("tenantlocationid = ?", loc.Tenantlocationid).Updates(updates).Error
}
// DeleteOnboardedClient — DELETE /admin/clients/:id (id = the login's authid).
//
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row
// and the matching appusers row are deleted, so the client can no longer sign
// in, and the client is marked Inactive when this was its last login. The
// tenants row and every booking, consignment and price attached to it stay —
// deleting them would break past orders and reports.
//
// A token already issued keeps working until it expires (JWTs are stateless);
// the login cannot be used to sign in again.
func DeleteOnboardedClient(c *fiber.Ctx) error {
actor := actorOf(c)
if !onboardingOwnerStillValid(actor.Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
auth, err := loadClientLogin(c.Params("id"))
if err != nil {
return utils.NotFound(c, "client login not found")
}
tenantID := *auth.Tenantid
deactivated := false
err = db.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", auth.ID).Delete(&models.DoormileAuth{}).Error; err != nil {
return err
}
if err := tx.Where("LOWER(email) = LOWER(?) AND tenantid = ?", auth.Email, tenantID).
Delete(&models.AppUser{}).Error; err != nil {
return err
}
var remaining int64
tx.Model(&models.DoormileAuth{}).Where("tenantid = ?", tenantID).Count(&remaining)
if remaining == 0 {
deactivated = true
return tx.Model(&models.Tenant{}).Where("tenantid = ?", tenantID).
Updates(map[string]any{"status": "Inactive", "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}).Error
}
return nil
})
if err != nil {
utils.Error("client login delete failed", "error", err.Error(), "by", actor.Email)
return utils.Internal(c, "failed to remove the client login; nothing was changed")
}
utils.Info("client login removed", "by", actor.Email, "tenantid", tenantID, "login", auth.Email, "client_deactivated", deactivated)
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenantID, "login_removed": true, "client_deactivated": deactivated})
}
// GetOnboardingCities — GET /admin/clients/cities: the operating cities a new
// client can be placed in, straight from applocations (the table OnboardClient
// validates against). The console's usual city picker derives cities from
// hubs, which would hide a city that has no hub yet.
func GetOnboardingCities(c *fiber.Ctx) error {
var cities []models.AppLocation
if err := db.DB.Where("status IS NULL OR status = '' OR LOWER(status) = 'active'").
Order("applocationid").Find(&cities).Error; err != nil {
utils.Error("list onboarding cities", "error", err.Error())
return utils.Internal(c, "failed to list cities")
}
if cities == nil {
cities = []models.AppLocation{}
}
return utils.List(c, cities, int64(len(cities)))
}

View File

@@ -0,0 +1,138 @@
package controllers
import (
"strings"
"sync"
"testing"
"time"
"gorm.io/gorm/schema"
)
func validOnboarding() onboardClientRequest {
return onboardClientRequest{
Companyname: " Acme Foods ",
Contactname: "Priya Raman",
Email: " Ops@Acme.Example ",
Phone: "+91 98765-43210",
Password: "s3cure-pass",
Applocationid: 1,
}
}
func TestOnboardingValidateNormalises(t *testing.T) {
r := validOnboarding()
if msg := r.validate(); msg != "" {
t.Fatalf("valid request refused: %s", msg)
}
if r.Companyname != "Acme Foods" || r.Contactname != "Priya Raman" || r.Email != "ops@acme.example" || r.Phone != "9876543210" {
t.Fatalf("not normalised: %+v", r)
}
}
func TestOnboardingValidateRefuses(t *testing.T) {
cases := map[string]func(*onboardClientRequest){
"company name is required": func(r *onboardClientRequest) { r.Companyname = " " },
"company name is too long": func(r *onboardClientRequest) { r.Companyname = strings.Repeat("a", 121) },
"contact person's name": func(r *onboardClientRequest) { r.Contactname = "" },
"valid email": func(r *onboardClientRequest) { r.Email = "not-an-email" },
"valid email ": func(r *onboardClientRequest) { r.Email = "Ops <ops@acme.example>" },
"valid email ": func(r *onboardClientRequest) { r.Email = "ops@localhost" },
"10-digit mobile": func(r *onboardClientRequest) { r.Phone = "12345" },
"10-digit mobile ": func(r *onboardClientRequest) { r.Phone = "5876543210" }, // must start 6-9
"at least 8 characters": func(r *onboardClientRequest) { r.Password = "short" },
"at most 72 characters": func(r *onboardClientRequest) { r.Password = strings.Repeat("x", 73) },
"must not be the email": func(r *onboardClientRequest) { r.Password = "OPS@acme.example" },
"must not be the email or the ": func(r *onboardClientRequest) { r.Password = "9876543210" },
"operating city": func(r *onboardClientRequest) { r.Applocationid = 0 },
}
for want, mutate := range cases {
r := validOnboarding()
mutate(&r)
if msg := r.validate(); !strings.Contains(msg, strings.TrimSpace(want)) {
t.Errorf("%q: got %q", want, msg)
}
}
}
func TestNormalisePhone(t *testing.T) {
for in, want := range map[string]string{
"9876543210": "9876543210",
"+919876543210": "9876543210",
"919876543210": "9876543210",
"09876543210": "9876543210",
" 98765 43210 ": "9876543210",
"(987) 654-3210": "9876543210",
} {
if got := normalisePhone(in); got != want {
t.Errorf("normalisePhone(%q) = %q, want %q", in, got, want)
}
}
}
// The list query selects these column aliases; every one must land in a field.
// GORM maps silently — a field it cannot see stays empty with no error — so
// this parses the scan struct exactly as GORM does and checks each alias.
func TestOnboardedClientRowMapsEveryColumn(t *testing.T) {
s, err := schema.Parse(&onboardedClientRow{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
for _, col := range []string{
"authid", "tenantid", "tenantname", "primaryemail", "primarycontact", "status",
"requiredeliveryotp", "contactname", "loginemail", "loginrole", "authcreatedat", "tenantcreatedat",
"address", "city", "state", "pincode", "latitude", "longitude",
} {
if s.LookUpField(col) == nil {
t.Errorf("column %q selected by the list query maps to no field", col)
}
}
}
func TestOnboardedClientRowToClient(t *testing.T) {
zero := time.Time{}
// As the driver hands back a timestamp-without-zone column: IST digits tagged UTC.
tenant := time.Date(2026, 6, 24, 16, 14, 0, 0, time.UTC)
c := onboardedClientRow{Authid: 7, Tenantname: "Acme", Loginemail: "a@b.co", Authcreatedat: &zero, Tenantcreatedat: &tenant}.toClient()
if c.Authid != 7 || c.Tenantname != "Acme" || c.Loginemail != "a@b.co" {
t.Fatalf("fields lost: %+v", c)
}
// 16:14 IST, i.e. 10:44 UTC — not 16:14 UTC (which would show as 21:44 in India).
if c.Logincreatedat == nil || !c.Logincreatedat.Equal(time.Date(2026, 6, 24, 10, 44, 0, 0, time.UTC)) {
t.Fatalf("a zero login date must fall back to the tenant's, read as IST: %v", c.Logincreatedat)
}
if (onboardedClientRow{}).toClient().Logincreatedat != nil {
t.Fatal("no real date must give null, not year 1")
}
}
func TestClientAddressValidate(t *testing.T) {
ok := clientAddress{Address: " 14 DB Road, RS Puram ", City: " Coimbatore ", State: "Tamil Nadu",
Pincode: " 641 002", Latitude: 11.009, Longitude: 76.95}
if msg := ok.validate(); msg != "" {
t.Fatalf("valid address refused: %s", msg)
}
if ok.Address != "14 DB Road, RS Puram" || ok.City != "Coimbatore" || ok.State != "Tamil Nadu" || ok.Pincode != "641002" {
t.Fatalf("not normalised: %+v", ok)
}
cases := []struct {
name string
a clientAddress
want string
}{
{"empty", clientAddress{}, "enter the client's address"},
{"too short", clientAddress{Address: "abc", Pincode: "641002", Latitude: 11, Longitude: 77}, "enter the client's address"},
{"too long", clientAddress{Address: strings.Repeat("a", 301), Pincode: "641002", Latitude: 11, Longitude: 77}, "too long"},
{"pincode 5 digits", clientAddress{Address: "14 DB Road", Pincode: "64100", Latitude: 11, Longitude: 77}, "6-digit pincode"},
{"pincode starts 0", clientAddress{Address: "14 DB Road", Pincode: "041002", Latitude: 11, Longitude: 77}, "6-digit pincode"},
{"no map location", clientAddress{Address: "14 DB Road", Pincode: "641002"}, "pick the address"},
{"swapped lat/lon", clientAddress{Address: "14 DB Road", Pincode: "641002", Latitude: 76.95, Longitude: 11.0}, "pick the address"},
{"outside India", clientAddress{Address: "14 DB Road", Pincode: "641002", Latitude: 51.5, Longitude: -0.1}, "pick the address"},
}
for _, c := range cases {
a := c.a
if msg := a.validate(); !strings.Contains(msg, c.want) {
t.Errorf("%s: %q, want %q", c.name, msg, c.want)
}
}
}

View File

@@ -0,0 +1,698 @@
package controllers
import (
"errors"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Reverse logistics, phase 1–3: RTO (return to origin).
// Plan: krow_talent_app/docs/reverse-logistics-plan.md.
//
// Collected_By_Miler / Out_for_Delivery / Created / Inwarded_at_Hub
// │ ops "Initiate RTO", or automatically after N failed attempts
// ▼
// RTO_Initiated ──── ops "Re-attempt" ────▶ back to the status it came from
// │
// │ rider returns it (flagged), or ops "Mark returned"
// ▼
// Returned_to_Sender (terminal)
//
// The statuses and the consignment's return columns (returnreason,
// returninitiatedat, returndeliveredat) already existed and were never written;
// this file is the first thing that writes them. Every transition writes a
// consignmenthistory row. Returns go back to the SENDER (the consignment's
// pickup point) — a return-to-hub option is phase 4 of the plan.
// rtoReasons are the reasons ops may pick; the label is what is stored in
// consignments.returnreason (with the free-text note appended).
var rtoReasons = map[string]string{
"receiver_refused": "Receiver refused",
"address_not_found": "Address not found",
"customer_unavailable": "Customer unavailable",
"attempts_exhausted": "Delivery attempts exhausted",
"damaged": "Damaged in transit",
"other": "Other",
}
// rtoStartable is every status a parcel can be returned from: in a rider's
// hands, or waiting at a base. Not from Delivered, Cancelled, Missing,
// Damaged, or anything already in a return.
var rtoStartable = map[string]bool{
constants.ConsignmentCreated: true,
constants.ConsignmentInwardedAtHub: true,
constants.ConsignmentCollectedByMiler: true,
constants.ConsignmentOutForDelivery: true,
}
// consignmentTerminal statuses never change again through the generic status
// endpoint.
var consignmentTerminal = map[string]bool{
constants.ConsignmentDelivered: true,
constants.ConsignmentReturnedToSender: true,
"Cancelled": true,
}
// knownConsignmentStatuses mirrors the consignments_status_check constraint
// (migrations/migrate.go), so a typo is a 400 rather than a 500 from Postgres.
var knownConsignmentStatuses = map[string]bool{
constants.ConsignmentCreated: true, constants.ConsignmentInwardedAtHub: true,
constants.ConsignmentCollectedByMiler: true, constants.ConsignmentTripsheetLoaded: true,
constants.ConsignmentInTransit: true, constants.ConsignmentOutForDelivery: true,
constants.ConsignmentDelivered: true, constants.ConsignmentRTOInitiated: true,
constants.ConsignmentReturnedToSender: true, constants.ConsignmentMissing: true,
constants.ConsignmentDamaged: true, "Cancelled": true,
}
// checkGenericStatusChange is the guard on PUT /admin/consignments/:id/status.
// That endpoint used to write any string onto any parcel — a cancelled parcel
// could be marked Delivered. It now refuses unknown statuses, leaving a
// terminal status, and the two RTO statuses (which must go through the RTO
// endpoints so the return columns and history are written consistently).
func checkGenericStatusChange(from, to string) string {
switch {
case !knownConsignmentStatuses[to]:
return "unknown consignment status"
case to == constants.ConsignmentRTOInitiated || to == constants.ConsignmentReturnedToSender:
return "use the return (RTO) actions to start or complete a return"
case from == constants.ConsignmentRTOInitiated:
return "this parcel is being returned: re-attempt delivery or mark it returned instead"
case consignmentTerminal[from] && from != to:
return "this parcel is already " + strings.ReplaceAll(strings.ToLower(from), "_", " ") + " and cannot change"
}
return ""
}
// rtoAutoAfterAttempts is how many failed delivery attempts start a return
// automatically (env RTO_AUTO_AFTER_ATTEMPTS, default 3; 0 turns it off).
// Read per call, like the other operational knobs.
func rtoAutoAfterAttempts() int {
if v := strings.TrimSpace(os.Getenv("RTO_AUTO_AFTER_ATTEMPTS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return 3
}
// rtoRiderFlowEnabled gates the rider-app side (next action return_to_sender
// and POST /miler/consignments/:id/return-complete). Off by default: the
// deployed rider app does not know the new action — same rollout pattern as
// MILER_HUB_HANDOVER_ENABLED. With it off, ops close returns from the console.
func rtoRiderFlowEnabled() bool {
return strings.EqualFold(os.Getenv("MILER_RTO_FLOW_ENABLED"), "true")
}
var rtoFromPrefix = regexp.MustCompile(`^\[from:([A-Za-z_]+)\]`)
// rtoHistoryRemark records where the parcel was when the return started, so a
// "re-attempt" can put it back exactly there.
func rtoHistoryRemark(from, reason string) string {
return fmt.Sprintf("[from:%s] %s", from, reason)
}
// statusBeforeRTO reads that back from the RTO_Initiated history remark.
func statusBeforeRTO(remark string) string {
if m := rtoFromPrefix.FindStringSubmatch(remark); m != nil && rtoStartable[m[1]] {
return m[1]
}
return constants.ConsignmentOutForDelivery
}
// errRTO carries an operator-readable refusal out of a transaction.
type errRTO struct{ msg string }
func (e errRTO) Error() string { return e.msg }
// moveConsignment writes a status change only if the parcel is still in the
// status it was read in (compare-and-set). Two writers racing on one parcel —
// ops and the rider, or a double-clicked button — would otherwise both pass
// their status check and the later full-row save would overwrite the earlier
// one. It returns the status the row has now when the move did not happen.
func moveConsignment(tx *gorm.DB, id int, from string, fields map[string]interface{}) (moved bool, current string, err error) {
res := tx.Model(&models.Consignment{}).Where("consignmentid = ? AND status = ?", id, from).Updates(fields)
if res.Error != nil {
return false, "", res.Error
}
if res.RowsAffected == 1 {
return true, from, nil
}
var now models.Consignment
if err := tx.Select("status").First(&now, id).Error; err != nil {
return false, "", err
}
return false, now.Status, nil
}
var errRTORaced = errRTO{"this parcel changed while you were working on it; refresh and try again"}
// startRTO moves one consignment into RTO_Initiated inside tx. It writes the
// return columns and history, and resolves the parcel's open Undeliverable /
// Receiver_Refused exceptions — the RTO is their resolution. Idempotent: a
// parcel already in a return is left as it is.
func startRTO(tx *gorm.DB, cn *models.Consignment, reasonText string, actorID *int) (started bool, err error) {
if cn.Status == constants.ConsignmentRTOInitiated {
return false, nil
}
if !rtoStartable[cn.Status] {
return false, errRTO{fmt.Sprintf("a parcel that is %s cannot be returned",
strings.ReplaceAll(strings.ToLower(cn.Status), "_", " "))}
}
from := cn.Status
now := time.Now()
moved, current, err := moveConsignment(tx, cn.Consignmentid, from, map[string]interface{}{
"status": constants.ConsignmentRTOInitiated,
"returnreason": reasonText,
"returninitiatedat": now,
"returndeliveredat": nil,
"updatedat": now,
})
if err != nil {
return false, err
}
if !moved {
if current == constants.ConsignmentRTOInitiated {
return false, nil // someone else started it first: same outcome
}
return false, errRTORaced
}
cn.Status = constants.ConsignmentRTOInitiated
cn.Returnreason = reasonText
cn.Returninitiatedat = &now
cn.Returndeliveredat = nil
cn.Updatedat = now
if err := tx.Create(&models.ConsignmentHistory{
Consignmentid: cn.Consignmentid,
Hubid: cn.Currenthubid,
Userid: actorID,
Eventstatus: constants.ConsignmentRTOInitiated,
Remarks: rtoHistoryRemark(from, reasonText),
}).Error; err != nil {
return false, err
}
if err := tx.Model(&models.ConsignmentException{}).
Where("consignmentid = ? AND exceptiontype IN ? AND status IN ?", cn.Consignmentid,
[]string{constants.ExceptionUndeliverable, constants.ExceptionReceiverRefused},
[]string{constants.ExceptionOpen, constants.ExceptionUnderInvestigation}).
Updates(map[string]interface{}{
"status": constants.ExceptionResolved,
"resolution": "Return to sender (RTO) initiated: " + reasonText,
"updatedat": now,
}).Error; err != nil {
return false, err
}
return true, nil
}
// completeRTO moves an RTO_Initiated consignment to Returned_to_Sender and
// closes the rider's open assignment on its booking (exactly as a delivery
// does), so the rider is not left holding a stop and can go off duty.
func completeRTO(tx *gorm.DB, cn *models.Consignment, remark string, actorID *int) error {
if cn.Status == constants.ConsignmentReturnedToSender {
return nil
}
if cn.Status != constants.ConsignmentRTOInitiated {
return errRTO{"only a parcel that is being returned can be marked returned"}
}
now := time.Now()
moved, current, err := moveConsignment(tx, cn.Consignmentid, constants.ConsignmentRTOInitiated, map[string]interface{}{
"status": constants.ConsignmentReturnedToSender,
"returndeliveredat": now,
"updatedat": now,
})
if err != nil {
return err
}
if !moved {
if current == constants.ConsignmentReturnedToSender {
cn.Status = current
return nil // closed by someone else first (ops and rider together)
}
return errRTORaced
}
cn.Status = constants.ConsignmentReturnedToSender
cn.Returndeliveredat = &now
cn.Updatedat = now
if err := tx.Create(&models.ConsignmentHistory{
Consignmentid: cn.Consignmentid,
Hubid: cn.Currenthubid,
Userid: actorID,
Eventstatus: constants.ConsignmentReturnedToSender,
Remarks: remark,
}).Error; err != nil {
return err
}
if _, booking, ok := cxDestinationForConsignment(cn.Consignmentid); ok && booking != nil && booking.Assignedmileruserid != nil {
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", booking.Bookingid,
*booking.Assignedmileruserid, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": now,
"remarks": "Returned to sender",
}).Error; err != nil {
return err
}
}
return nil
}
// riderHoldsParcel: the statuses in which the booking's rider has the parcel.
// Created counts: with MILER_HUB_HANDOVER_ENABLED on, a hub-routed parcel is
// Created while the rider carries it to the base.
func riderHoldsParcel(status string) bool {
switch status {
case constants.ConsignmentCreated, constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery:
return true
}
return false
}
// notifyRiderOfReturn tells the rider holding the parcel to bring it back.
// Best effort, after commit: a push failure never undoes the RTO.
func notifyRiderOfReturn(cn *models.Consignment) {
_, booking, ok := cxDestinationForConsignment(cn.Consignmentid)
if !ok || booking == nil || booking.Assignedmileruserid == nil {
return
}
var profile models.MilerProfile
if db.DB.Where("userid = ?", *booking.Assignedmileruserid).First(&profile).Error != nil || profile.Devicetoken == "" {
return
}
if err := notify.SendToDevice(profile.Devicetoken, "Return parcel to sender",
fmt.Sprintf("Parcel %s is being returned to the sender. Do not attempt delivery.", cn.Trackingno),
map[string]string{"type": "rto", "consignmentid": strconv.Itoa(cn.Consignmentid)}); err != nil {
utils.Warn("RTO: rider push failed", "consignment_id", cn.Consignmentid, "error", err)
}
}
func rtoActor(c *fiber.Ctx) *int {
if id, ok := c.Locals("userid").(int); ok {
return &id
}
return nil
}
// loadConsignmentForAdmin applies the caller's tenant scope.
func loadConsignmentForAdmin(c *fiber.Ctx, tx *gorm.DB) (*models.Consignment, error) {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return nil, errRTO{"invalid consignment id"}
}
var cn models.Consignment
if err := scopeToOwnTenant(c, tx, "tenantid").First(&cn, id).Error; err != nil {
return nil, gorm.ErrRecordNotFound
}
return &cn, nil
}
func rtoResult(c *fiber.Ctx, err error, what string) error {
var refusal errRTO
switch {
case errors.As(err, &refusal):
return utils.BadRequest(c, refusal.msg)
case errors.Is(err, gorm.ErrRecordNotFound):
return utils.NotFound(c, "consignment not found")
default:
utils.Error("RTO: "+what, "error", err.Error())
return utils.Internal(c, "failed to "+what+"; nothing was changed")
}
}
// rtoReasonText validates a start-return request and builds the text stored in
// returnreason ("Label: note"). A non-empty refusal is the 400 message.
func rtoReasonText(reason, note string) (text, refusal string) {
reason = strings.ToLower(strings.TrimSpace(reason))
label, ok := rtoReasons[reason]
if !ok {
return "", "choose a return reason"
}
note = strings.TrimSpace(note)
if reason == "other" && note == "" {
return "", "describe the reason when choosing Other"
}
// Characters, not bytes: the console allows 500 characters, and a note
// in Tamil or Hindi is two to three bytes per character.
if utf8.RuneCountInString(note) > 500 {
return "", "note is too long (at most 500 characters)"
}
if note == "" {
return label, ""
}
return label + ": " + note, ""
}
// InitiateConsignmentRTO — POST /admin/consignments/:id/rto {reason, note}
func InitiateConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Reason string `json:"reason"`
Note string `json:"note"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
reasonText, refusal := rtoReasonText(req.Reason, req.Note)
if refusal != "" {
return utils.BadRequest(c, refusal)
}
var cn *models.Consignment
started, from := false, ""
err := db.DB.Transaction(func(tx *gorm.DB) error {
var err error
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
from = cn.Status
started, err = startRTO(tx, cn, reasonText, rtoActor(c))
return err
})
if err != nil {
return rtoResult(c, err, "start the return")
}
if started {
// Only the rider carrying it. A parcel already handed over at a base
// (Inwarded_at_Hub) is no longer with its pickup rider, who must not be
// told "do not attempt delivery" about it.
if riderHoldsParcel(from) {
notifyRiderOfReturn(cn)
}
utils.Info("RTO initiated", "consignment_id", cn.Consignmentid, "by", c.Locals("email"), "reason", reasonText)
}
return utils.OK(c, fiber.Map{"consignment": cn, "started": started})
}
// CancelConsignmentRTO — POST /admin/consignments/:id/rto/cancel {note}
// Ops decide to try delivering again: the parcel goes back to the status it
// had when the return started.
func CancelConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Note string `json:"note"`
}
_ = c.BodyParser(&req)
var cn *models.Consignment
err := db.DB.Transaction(func(tx *gorm.DB) error {
var err error
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
if cn.Status != constants.ConsignmentRTOInitiated {
return errRTO{"this parcel is not being returned"}
}
var last models.ConsignmentHistory
tx.Where("consignmentid = ? AND eventstatus = ?", cn.Consignmentid, constants.ConsignmentRTOInitiated).
Order("historyid DESC").First(&last)
back := statusBeforeRTO(last.Remarks)
// The return is off: clear its reason and start time too, so a parcel
// that is then delivered does not carry a stale return reason. The
// history keeps both.
now := time.Now()
moved, _, err := moveConsignment(tx, cn.Consignmentid, constants.ConsignmentRTOInitiated, map[string]interface{}{
"status": back,
"returnreason": "",
"returninitiatedat": nil,
"updatedat": now,
})
if err != nil {
return err
}
if !moved {
return errRTORaced
}
cn.Status = back
cn.Returnreason = ""
cn.Returninitiatedat = nil
cn.Updatedat = now
remark := "Return cancelled — re-attempting delivery"
if n := strings.TrimSpace(req.Note); n != "" {
remark += ": " + n
}
return tx.Create(&models.ConsignmentHistory{
Consignmentid: cn.Consignmentid, Hubid: cn.Currenthubid, Userid: rtoActor(c),
Eventstatus: back, Remarks: remark,
}).Error
})
if err != nil {
return rtoResult(c, err, "cancel the return")
}
return utils.OK(c, fiber.Map{"consignment": cn})
}
// CompleteConsignmentRTO — POST /admin/consignments/:id/rto/complete {note}
// Ops confirm the parcel is back with the sender (until the rider-app flow is
// on, this is how every return is closed).
func CompleteConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Note string `json:"note"`
}
_ = c.BodyParser(&req)
remark := "Returned to sender (confirmed by ops)"
if n := strings.TrimSpace(req.Note); n != "" {
remark += ": " + n
}
var cn *models.Consignment
err := db.DB.Transaction(func(tx *gorm.DB) error {
var err error
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
return completeRTO(tx, cn, remark, rtoActor(c))
})
if err != nil {
return rtoResult(c, err, "mark the parcel returned")
}
return utils.OK(c, fiber.Map{"consignment": cn})
}
// MilerCompleteReturn — POST /miler/consignments/:id/return-complete
// {lat, lon, receivedby, photourl}. The rider hands the parcel back to the
// sender. Behind MILER_RTO_FLOW_ENABLED.
func MilerCompleteReturn(c *fiber.Ctx) error {
if !rtoRiderFlowEnabled() {
return utils.Fail(c, fiber.StatusForbidden, "RTO_FLOW_DISABLED", "returns are closed by ops for now")
}
milerUserID := c.Locals("userid").(int)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid consignment ID")
}
var req struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Receivedby string `json:"receivedby"`
Photourl string `json:"photourl"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
cn, code, err := milerConsignmentForRider(milerUserID, id)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.NotFound(c, "consignment not found")
}
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found")
}
remark := fmt.Sprintf("Returned to sender by rider at (%.5f, %.5f)", req.Lat, req.Lon)
if r := strings.TrimSpace(req.Receivedby); r != "" {
remark += ", received by " + r
}
if p := strings.TrimSpace(req.Photourl); p != "" {
remark += ", photo " + p
}
err = db.DB.Transaction(func(tx *gorm.DB) error {
return completeRTO(tx, cn, remark, &milerUserID)
})
var refusal errRTO
if errors.As(err, &refusal) {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, refusal.msg)
}
if err != nil {
utils.Error("RTO: rider return-complete", "error", err.Error())
return utils.Internal(c, "failed to record the return")
}
return utils.OK(c, fiber.Map{
"consignmentid": cn.Consignmentid,
"status": cn.Status,
"next_action": nextActionForConsignment(cn.Status),
})
}
// returnRow is one line of GET /admin/returns.
type returnRow struct {
Consignmentid int `json:"consignmentid"`
Trackingno string `json:"trackingno"`
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Status string `json:"status"`
Returnreason string `json:"returnreason"`
Attemptcount int `json:"attemptcount"`
Returninitiatedat *time.Time `json:"returninitiatedat"`
Returndeliveredat *time.Time `json:"returndeliveredat"`
Pickuppincode string `json:"pickuppincode"`
Deliverypincode string `json:"deliverypincode"`
Codamount float64 `json:"codamount"`
Bookingid *int `json:"bookingid"`
Mileruserid *int `json:"mileruserid"`
Milername string `json:"milername"`
}
// returnsDateRange parses ?from=&to= (YYYY-MM-DD, inclusive) as India dates.
func returnsDateRange(from, to string) (*time.Time, *time.Time, error) {
var start, end *time.Time
if from != "" {
t, err := time.ParseInLocation("2006-01-02", from, utils.ISTLocation())
if err != nil {
return nil, nil, errRTO{"from must be YYYY-MM-DD"}
}
start = &t
}
if to != "" {
t, err := time.ParseInLocation("2006-01-02", to, utils.ISTLocation())
if err != nil {
return nil, nil, errRTO{"to must be YYYY-MM-DD"}
}
t = t.AddDate(0, 0, 1)
end = &t
}
return start, end, nil
}
// GetReturns — GET /admin/returns?status=initiated|returned|all&from&to&tenantid&pageno&pagesize
// Every parcel in or through a return, newest first. A client login sees only
// its own (same tenant scoping as the other admin lists).
func GetReturns(c *fiber.Ctx) error {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
page := utils.ParsePage(c)
var statuses []string
switch strings.ToLower(c.Query("status", "all")) {
case "initiated":
statuses = []string{constants.ConsignmentRTOInitiated}
case "returned":
statuses = []string{constants.ConsignmentReturnedToSender}
case "all", "":
statuses = []string{constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender}
default:
return utils.BadRequest(c, "status must be initiated, returned or all")
}
start, end, err := returnsDateRange(c.Query("from"), c.Query("to"))
if err != nil {
return utils.BadRequest(c, err.Error())
}
q := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.status IN ? AND cn.deletedat IS NULL", statuses)
if start != nil {
q = q.Where("cn.returninitiatedat >= ?", *start)
}
if end != nil {
q = q.Where("cn.returninitiatedat < ?", *end)
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
utils.Error("returns: count", "error", err.Error())
return utils.Internal(c, "failed to count returns")
}
rows := []returnRow{}
if err := page.Apply(q.Session(&gorm.Session{}).
Select(`cn.consignmentid, cn.trackingno, cn.tenantid, COALESCE(t.tenantname, '') AS tenantname,
cn.status, cn.returnreason, cn.attemptcount, cn.returninitiatedat, cn.returndeliveredat,
cn.pickuppincode, cn.deliverypincode, cn.codamount`).
Joins("LEFT JOIN tenants t ON t.tenantid = cn.tenantid").
Order("cn.returninitiatedat DESC NULLS LAST, cn.consignmentid DESC")).
Scan(&rows).Error; err != nil {
utils.Error("returns: list", "error", err.Error())
return utils.Internal(c, "failed to list returns")
}
// The rider and booking behind each parcel — one lookup per row through
// the helper that understands multi-destination pickups (pages are capped).
riderNames := map[int]string{}
for i := range rows {
if _, booking, ok := cxDestinationForConsignment(rows[i].Consignmentid); ok && booking != nil {
bid := booking.Bookingid
rows[i].Bookingid = &bid
rows[i].Mileruserid = booking.Assignedmileruserid
if booking.Assignedmileruserid != nil {
uid := *booking.Assignedmileruserid
if _, seen := riderNames[uid]; !seen {
var p models.MilerProfile
if db.DB.Select("displayname").Where("userid = ?", uid).First(&p).Error == nil {
riderNames[uid] = p.Displayname
} else {
riderNames[uid] = ""
}
}
rows[i].Milername = riderNames[uid]
}
}
}
return utils.Paginated(c, rows, total, page)
}
// autoRTOAfterSkip runs after a failed delivery attempt is recorded. At the
// configured attempt count the parcel is returned automatically instead of
// being retried forever.
func autoRTOAfterSkip(cn *models.Consignment, milerUserID int, lastReason string) {
n := rtoAutoAfterAttempts()
if n == 0 || cn.Attemptcount < n {
return
}
reason := fmt.Sprintf("%s: %d delivery attempts failed (last: %s)", rtoReasons["attempts_exhausted"], cn.Attemptcount, lastReason)
started := false
err := db.DB.Transaction(func(tx *gorm.DB) error {
var fresh models.Consignment
if err := tx.First(&fresh, cn.Consignmentid).Error; err != nil {
return err
}
var err error
started, err = startRTO(tx, &fresh, reason, &milerUserID)
if err == nil {
*cn = fresh
}
return err
})
if err != nil {
utils.Warn("RTO: automatic return not started", "consignment_id", cn.Consignmentid, "error", err.Error())
return
}
if started {
notifyRiderOfReturn(cn)
utils.Info("RTO initiated automatically", "consignment_id", cn.Consignmentid, "attempts", cn.Attemptcount)
}
}
// returnDestination is where a returned parcel goes: the sender's pickup
// point (phase 1–3 of the plan). nil unless the parcel is being returned.
func returnDestination(cn *models.Consignment) fiber.Map {
if cn == nil || cn.Status != constants.ConsignmentRTOInitiated {
return nil
}
return fiber.Map{
"type": "sender",
"latitude": cn.Pickuplatitude,
"longitude": cn.Pickuplongitude,
"pincode": cn.Pickuppincode,
}
}

View File

@@ -0,0 +1,249 @@
package controllers
import (
"fmt"
"os"
"strings"
"sync"
"testing"
"doormile/constants"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// The return (RTO) state machine against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN is set; the DSN must be a THROWAWAY database — the tables
// below are dropped and recreated in their own schema. See
// internal/ai/registry/store_integration_test.go for how to start one.
const rtoRider = 9003
func rtoTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres RTO test")
}
gdb := testpg.Open(t, dsn, "rto_controllers_test")
all := []any{&models.Consignment{}, &models.ConsignmentHistory{}, &models.ConsignmentException{},
&models.PickupBooking{}, &models.BookingAssignment{}, &models.BookingDestination{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
return gdb
}
// seedParcel creates one parcel out with the rider: consignment, its booking,
// the rider's accepted assignment and an open Undeliverable exception.
func seedParcel(t *testing.T, gdb *gorm.DB, id int, status string) *models.Consignment {
t.Helper()
cn := &models.Consignment{Consignmentid: id, Trackingno: fmt.Sprintf("DMXT%04d", id),
Tenantid: 901, Status: status, Pickuppincode: "641001", Pickuplatitude: 11.0168, Pickuplongitude: 76.9558}
rider := rtoRider
cid := id
must(t, gdb.Create(cn).Error)
must(t, gdb.Create(&models.PickupBooking{Bookingid: id, Bookingno: "DM-T" + cn.Trackingno, Status: "Converted_To_Consignment",
Assignedmileruserid: &rider, Consignmentid: &cid}).Error)
must(t, gdb.Create(&models.BookingAssignment{Bookingid: id, Mileruserid: rtoRider, Assignmentstatus: constants.AssignmentAccepted}).Error)
must(t, gdb.Create(&models.ConsignmentException{Consignmentid: id, Exceptiontype: constants.ExceptionUndeliverable,
Status: constants.ExceptionOpen, Description: "gate locked"}).Error)
return cn
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func reload(t *testing.T, gdb *gorm.DB, id int) models.Consignment {
t.Helper()
var cn models.Consignment
must(t, gdb.First(&cn, id).Error)
return cn
}
func historyOf(t *testing.T, gdb *gorm.DB, id int) []string {
t.Helper()
var rows []models.ConsignmentHistory
must(t, gdb.Where("consignmentid = ?", id).Order("historyid").Find(&rows).Error)
out := make([]string, len(rows))
for i, r := range rows {
out[i] = r.Eventstatus
}
return out
}
func TestRTOLifecycleOnPostgres(t *testing.T) {
gdb := rtoTestDB(t)
cn := seedParcel(t, gdb, 11, constants.ConsignmentOutForDelivery)
actor := 1
// Start: status, return columns, history, exception resolved.
must(t, gdb.Transaction(func(tx *gorm.DB) error {
started, err := startRTO(tx, cn, "Receiver refused: gate locked", &actor)
if !started {
t.Error("first start must report started")
}
return err
}))
got := reload(t, gdb, 11)
if got.Status != constants.ConsignmentRTOInitiated || got.Returnreason != "Receiver refused: gate locked" || got.Returninitiatedat == nil {
t.Fatalf("after start: %+v", got)
}
var exc models.ConsignmentException
must(t, gdb.Where("consignmentid = ?", 11).First(&exc).Error)
if exc.Status != constants.ExceptionResolved || !strings.Contains(exc.Resolution, "Return to sender") {
t.Fatalf("exception not resolved: %+v", exc)
}
// Starting again is a no-op, not a second history row.
stale := *cn
must(t, gdb.Transaction(func(tx *gorm.DB) error {
started, err := startRTO(tx, &stale, "again", &actor)
if started {
t.Error("second start must not report started")
}
return err
}))
// Complete: terminal status, return time, rider's assignment closed.
fresh := reload(t, gdb, 11)
must(t, gdb.Transaction(func(tx *gorm.DB) error { return completeRTO(tx, &fresh, "Returned to sender", &actor) }))
got = reload(t, gdb, 11)
if got.Status != constants.ConsignmentReturnedToSender || got.Returndeliveredat == nil {
t.Fatalf("after complete: %+v", got)
}
var asg models.BookingAssignment
must(t, gdb.Where("bookingid = ?", 11).First(&asg).Error)
if asg.Assignmentstatus != constants.AssignmentCompleted || asg.Completedat == nil {
t.Fatalf("assignment not closed: %+v", asg)
}
// Completing again is a no-op too (rider and ops both confirm).
again := reload(t, gdb, 11)
must(t, gdb.Transaction(func(tx *gorm.DB) error { return completeRTO(tx, &again, "dup", &actor) }))
if h := historyOf(t, gdb, 11); strings.Join(h, ",") != "RTO_Initiated,Returned_to_Sender" {
t.Fatalf("history = %v", h)
}
}
func TestRTORefusalsOnPostgres(t *testing.T) {
gdb := rtoTestDB(t)
delivered := seedParcel(t, gdb, 21, constants.ConsignmentDelivered)
out := seedParcel(t, gdb, 22, constants.ConsignmentOutForDelivery)
err := gdb.Transaction(func(tx *gorm.DB) error { _, err := startRTO(tx, delivered, "x", nil); return err })
if _, ok := err.(errRTO); !ok || !strings.Contains(err.Error(), "delivered cannot be returned") {
t.Fatalf("delivered parcel: %v", err)
}
err = gdb.Transaction(func(tx *gorm.DB) error { return completeRTO(tx, out, "x", nil) })
if _, ok := err.(errRTO); !ok {
t.Fatalf("completing a parcel not in return must be refused: %v", err)
}
if reload(t, gdb, 21).Status != constants.ConsignmentDelivered || reload(t, gdb, 22).Status != constants.ConsignmentOutForDelivery {
t.Fatal("a refusal must change nothing")
}
if len(historyOf(t, gdb, 21))+len(historyOf(t, gdb, 22)) != 0 {
t.Fatal("a refusal must write no history")
}
}
// The race the compare-and-set closes: the parcel was read as Out_for_Delivery,
// then the rider delivered it before ops pressed "Return to sender". The stale
// read must not overwrite Delivered.
func TestRTODoesNotOverwriteAConcurrentDelivery(t *testing.T) {
gdb := rtoTestDB(t)
cn := seedParcel(t, gdb, 31, constants.ConsignmentOutForDelivery)
must(t, gdb.Model(&models.Consignment{}).Where("consignmentid = ?", 31).Update("status", constants.ConsignmentDelivered).Error)
err := gdb.Transaction(func(tx *gorm.DB) error { _, err := startRTO(tx, cn, "Receiver refused", nil); return err })
if err != errRTORaced {
t.Fatalf("want the 'changed, refresh' refusal, got %v", err)
}
got := reload(t, gdb, 31)
if got.Status != constants.ConsignmentDelivered || got.Returnreason != "" {
t.Fatalf("delivery was overwritten: %+v", got)
}
}
// Ten simultaneous "Return to sender" clicks: exactly one return, one history
// row, and every caller gets a non-error answer.
func TestRTOConcurrentStartsWriteOnce(t *testing.T) {
gdb := rtoTestDB(t)
seedParcel(t, gdb, 41, constants.ConsignmentOutForDelivery)
var wg sync.WaitGroup
var mu sync.Mutex
startedCount, errs := 0, 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
var started bool
err := gdb.Transaction(func(tx *gorm.DB) error {
var cn models.Consignment
if err := tx.First(&cn, 41).Error; err != nil {
return err
}
var err error
started, err = startRTO(tx, &cn, "Receiver refused", nil)
return err
})
mu.Lock()
defer mu.Unlock()
if err != nil {
errs++
}
if started {
startedCount++
}
}()
}
wg.Wait()
if startedCount != 1 || errs != 0 {
t.Fatalf("started=%d errors=%d, want 1 and 0", startedCount, errs)
}
if h := historyOf(t, gdb, 41); len(h) != 1 {
t.Fatalf("history rows = %v, want exactly one RTO_Initiated", h)
}
}
// Re-attempt puts the parcel back where it was and clears the return fields.
func TestRTOCancelRestoresAndClears(t *testing.T) {
gdb := rtoTestDB(t)
cn := seedParcel(t, gdb, 51, constants.ConsignmentCollectedByMiler)
must(t, gdb.Transaction(func(tx *gorm.DB) error { _, err := startRTO(tx, cn, "Address not found", nil); return err }))
// The cancel handler's core, run directly: read the [from:] remark, move back.
var last models.ConsignmentHistory
must(t, gdb.Where("consignmentid = ? AND eventstatus = ?", 51, constants.ConsignmentRTOInitiated).First(&last).Error)
back := statusBeforeRTO(last.Remarks)
if back != constants.ConsignmentCollectedByMiler {
t.Fatalf("back = %s", back)
}
moved, _, err := moveConsignment(gdb, 51, constants.ConsignmentRTOInitiated, map[string]interface{}{
"status": back, "returnreason": "", "returninitiatedat": nil,
})
if err != nil || !moved {
t.Fatalf("moved=%v err=%v", moved, err)
}
got := reload(t, gdb, 51)
if got.Status != constants.ConsignmentCollectedByMiler || got.Returnreason != "" || got.Returninitiatedat != nil {
t.Fatalf("after cancel: %+v", got)
}
// A second cancel finds nothing to move.
if moved, cur, _ := moveConsignment(gdb, 51, constants.ConsignmentRTOInitiated, map[string]interface{}{"status": back}); moved || cur != back {
t.Fatalf("second cancel: moved=%v current=%s", moved, cur)
}
}

View File

@@ -0,0 +1,166 @@
package controllers
import (
"strings"
"testing"
"time"
"doormile/constants"
"doormile/models"
)
func TestGenericStatusChangeGuard(t *testing.T) {
cases := []struct {
from, to string
allowed bool
}{
{constants.ConsignmentOutForDelivery, constants.ConsignmentDelivered, true},
{constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery, true},
{constants.ConsignmentOutForDelivery, "Cancelled", true},
{constants.ConsignmentDelivered, constants.ConsignmentDelivered, true}, // no-op re-save
{"Cancelled", constants.ConsignmentDelivered, false}, // the old bug
{constants.ConsignmentDelivered, constants.ConsignmentOutForDelivery, false},
{constants.ConsignmentReturnedToSender, constants.ConsignmentOutForDelivery, false},
{constants.ConsignmentOutForDelivery, constants.ConsignmentRTOInitiated, false}, // must use the RTO action
{constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender, false}, // must use the RTO action
{constants.ConsignmentRTOInitiated, constants.ConsignmentDelivered, false}, // re-attempt first
{constants.ConsignmentOutForDelivery, "Out_For_Delivery_typo", false},
}
for _, c := range cases {
msg := checkGenericStatusChange(c.from, c.to)
if (msg == "") != c.allowed {
t.Errorf("%s -> %s: allowed=%v, got %q", c.from, c.to, c.allowed, msg)
}
}
}
func TestRTOHistoryRemarkRoundTrip(t *testing.T) {
for _, from := range []string{constants.ConsignmentOutForDelivery, constants.ConsignmentCollectedByMiler,
constants.ConsignmentInwardedAtHub, constants.ConsignmentCreated} {
if got := statusBeforeRTO(rtoHistoryRemark(from, "Receiver refused: gate locked")); got != from {
t.Errorf("round trip %s -> %s", from, got)
}
}
// Anything unreadable or not a returnable status falls back to Out_for_Delivery.
for _, remark := range []string{"", "no prefix", "[from:Delivered] x", "[from:Cancelled] x"} {
if got := statusBeforeRTO(remark); got != constants.ConsignmentOutForDelivery {
t.Errorf("%q -> %s, want Out_for_Delivery", remark, got)
}
}
}
func TestRTOAutoAfterAttempts(t *testing.T) {
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "")
if rtoAutoAfterAttempts() != 3 {
t.Fatal("default must be 3")
}
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "0")
if rtoAutoAfterAttempts() != 0 {
t.Fatal("0 must turn it off")
}
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "5")
if rtoAutoAfterAttempts() != 5 {
t.Fatal("5 must be read")
}
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "-2")
if rtoAutoAfterAttempts() != 3 {
t.Fatal("a negative value must fall back to the default, not disable it")
}
}
// The deployed rider app does not know return_to_sender: with the flag off a
// returning parcel must read as "nothing for you", exactly as before.
func TestNextActionForReturnRespectsFlag(t *testing.T) {
t.Setenv("MILER_RTO_FLOW_ENABLED", "")
if got := nextActionForConsignment(constants.ConsignmentRTOInitiated); got != constants.NextActionNone {
t.Fatalf("flag off: %s", got)
}
t.Setenv("MILER_RTO_FLOW_ENABLED", "true")
if got := nextActionForConsignment(constants.ConsignmentRTOInitiated); got != constants.NextActionReturnToSender {
t.Fatalf("flag on: %s", got)
}
if got := nextActionForConsignment(constants.ConsignmentReturnedToSender); got != constants.NextActionNone {
t.Fatalf("returned is terminal: %s", got)
}
// Existing actions are unchanged.
if got := nextActionForConsignment(constants.ConsignmentOutForDelivery); got != constants.NextActionDeliver {
t.Fatalf("out for delivery: %s", got)
}
}
func TestReturnDestination(t *testing.T) {
cn := &models.Consignment{Status: constants.ConsignmentRTOInitiated, Pickuplatitude: 11.01, Pickuplongitude: 76.95, Pickuppincode: "641001"}
d := returnDestination(cn)
if d == nil || d["type"] != "sender" || d["pincode"] != "641001" || d["latitude"] != 11.01 {
t.Fatalf("destination = %v", d)
}
cn.Status = constants.ConsignmentOutForDelivery
if returnDestination(cn) != nil {
t.Fatal("not returning must give nil")
}
}
func TestReturnsDateRangeIsIndiaDays(t *testing.T) {
from, to, err := returnsDateRange("2026-10-01", "2026-10-01")
if err != nil {
t.Fatal(err)
}
// 1 Oct in India runs 30 Sep 18:30 UTC → 1 Oct 18:30 UTC.
if !from.Equal(time.Date(2026, 9, 30, 18, 30, 0, 0, time.UTC)) || !to.Equal(time.Date(2026, 10, 1, 18, 30, 0, 0, time.UTC)) {
t.Fatalf("range = %v .. %v", from, to)
}
if _, _, err := returnsDateRange("01-10-2026", ""); err == nil {
t.Fatal("a non-ISO date must be refused")
}
if f, tt, err := returnsDateRange("", ""); err != nil || f != nil || tt != nil {
t.Fatal("no dates must mean no bounds")
}
}
func TestRTOReasonText(t *testing.T) {
cases := []struct {
reason, note, text string
refused bool
}{
{"receiver_refused", "", "Receiver refused", false},
{"receiver_refused", " gate locked ", "Receiver refused: gate locked", false},
{" Address_Not_Found ", "", "Address not found", false}, // case and spaces forgiven
{"other", "Shop closed", "Other: Shop closed", false},
{"other", " ", "", true},
{"OTHER", "", "", true}, // used to slip past the note check
{"", "", "", true},
{"lost_it", "", "", true},
{"other", strings.Repeat("அ", 500), "Other: " + strings.Repeat("அ", 500), false}, // 500 Tamil chars = 1500 bytes, allowed
{"other", strings.Repeat("a", 501), "", true},
}
for _, c := range cases {
text, refusal := rtoReasonText(c.reason, c.note)
if (refusal != "") != c.refused || text != c.text {
t.Errorf("(%q, %d chars): text=%q refusal=%q", c.reason, len([]rune(c.note)), text, refusal)
}
}
}
func TestRTOReasonsCoverThePlan(t *testing.T) {
for _, k := range []string{"receiver_refused", "address_not_found", "customer_unavailable", "attempts_exhausted", "other"} {
if rtoReasons[k] == "" {
t.Errorf("missing reason %q", k)
}
}
}
// A parcel already handed over at a base is not with its pickup rider any
// more: starting its return must not push "do not attempt delivery" to them.
func TestRiderHoldsParcel(t *testing.T) {
for status, want := range map[string]bool{
constants.ConsignmentCreated: true, // hub handover flag on: carrying it to the base
constants.ConsignmentCollectedByMiler: true,
constants.ConsignmentOutForDelivery: true,
constants.ConsignmentInwardedAtHub: false,
constants.ConsignmentDelivered: false,
} {
if got := riderHoldsParcel(status); got != want {
t.Errorf("%s: %v, want %v", status, got, want)
}
}
}

View File

@@ -11,11 +11,11 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/milergeo"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
// Catalogue and configuration — §5 of the customer contract.
@@ -440,16 +440,7 @@ func milersWithin(lat, lng, radiusKM float64) int {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: lng,
Latitude: lat,
Radius: radiusKM,
RadiusUnit: "km",
Sort: "ASC",
Count: 50,
},
}).Result()
locs, err := milergeo.Search(ctx, db.Rdb, lat, lng, radiusKM, 50)
if err != nil {
utils.Warn("milersWithin: geo search failed", "error", err)
return 0

View File

@@ -0,0 +1,84 @@
package controllers
import (
"encoding/json"
"testing"
"doormile/models"
)
// A hub's city is derived, not stored. `hubs` carries only `applocationid`, and
// for as long as the response carried that and nothing else, every console
// screen that asked a hub what city it was in got undefined:
//
// - ZoneContext.matchesZone compares an order's address text against the
// hub's city. An empty city makes that comparison unreachable, so the only
// matcher left was a 35km radius — and an order stored without coordinates
// then belonged to no zone at all.
// - fetchAppLocations names each city `hub.city || hub.hubname`, so the
// Pricing and report pickers offered "Coimbatore Jupiter Hub" where they
// meant "Coimbatore".
func TestApplyHubCities(t *testing.T) {
locations := map[int]string{1: "Coimbatore", 3: "Bangalore"}
t.Run("resolves each hub against its applocation", func(t *testing.T) {
hubs := []models.Hub{
{Hubid: 1, Hubname: "Coimbatore Jupiter Hub", Applocationid: 1},
{Hubid: 5, Hubname: "Bangalore Earth Hub", Applocationid: 3},
}
applyHubCities(hubs, locations)
if hubs[0].City != "Coimbatore" || hubs[1].City != "Bangalore" {
t.Fatalf("got %q and %q", hubs[0].City, hubs[1].City)
}
})
t.Run("an unknown applocation leaves the city empty, not guessed", func(t *testing.T) {
// Empty is the honest answer and callers already read it as "unknown":
// ZoneContext skips its city comparison rather than matching everything.
hubs := []models.Hub{{Hubid: 99, Hubname: "Somewhere Hub", Applocationid: 42}}
applyHubCities(hubs, locations)
if hubs[0].City != "" {
t.Fatalf("expected empty city, got %q", hubs[0].City)
}
})
t.Run("a hub with no applocation at all is left alone", func(t *testing.T) {
hubs := []models.Hub{{Hubid: 98, Hubname: "Orphan Hub"}}
applyHubCities(hubs, locations)
if hubs[0].City != "" {
t.Fatalf("expected empty city, got %q", hubs[0].City)
}
})
t.Run("the city follows the applocation, never the name", func(t *testing.T) {
// Hub 22 is a live example of why this is worth asserting: it is named
// "Chennai Comet Hub" but sits on applocationid 1, because CreateCityHub
// copies the creating staff member's location.
hubs := []models.Hub{{Hubid: 22, Hubname: "Chennai Comet Hub", Applocationid: 1}}
applyHubCities(hubs, locations)
if hubs[0].City != "Coimbatore" {
t.Fatalf("city must come from applocationid, got %q", hubs[0].City)
}
})
t.Run("an empty list is not an error", func(t *testing.T) {
applyHubCities(nil, locations)
applyHubCities([]models.Hub{}, locations)
})
}
// The field has to survive JSON encoding, since the console reads `hub.city`.
// `gorm:"-"` keeps it out of the SQL; it must not also keep it out of the body.
func TestHubCityIsSerialised(t *testing.T) {
b, err := json.Marshal(models.Hub{Hubid: 5, Hubname: "Bangalore Earth Hub", City: "Bangalore"})
if err != nil {
t.Fatal(err)
}
var back map[string]any
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
if back["city"] != "Bangalore" {
t.Fatalf("city missing from the hub response: %s", b)
}
}

View File

@@ -1993,16 +1993,32 @@ func HubBatchAssign(c *fiber.Ctx) error {
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
}
// Every rider on duty at this hub, not only the idle ones. capPerRider is
// what limits a round; requiring Available made that limit unreachable,
// because a rider stopped being Available the moment they took the first
// booking of the very batch being built.
var riderProfiles []models.MilerProfile
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
if err := db.DB.Where("hubid = ? AND availabilitystatus IN ?", hubID, constants.MilerWorkingStatuses).
Find(&riderProfiles).Error; err != nil {
return utils.Internal(c, "failed to fetch available riders")
}
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
for _, mp := range riderProfiles {
// Seed the count with what the rider is ALREADY holding. capPerRider
// has to mean "stops in hand", not "stops added by this call" — now
// that busy riders are eligible, counting only this call's additions
// would hand five more to someone already carrying five.
var openStops int64
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{
constants.AssignmentAssigned,
constants.AssignmentAccepted,
}).
Count(&openStops)
candidates = append(candidates, &batchRiderCandidate{
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
assigned: int(openStops),
})
}

View File

@@ -243,6 +243,14 @@ func nextActionForConsignment(status string) string {
return constants.NextActionDeliver
case constants.ConsignmentInwardedAtHub:
return constants.NextActionHandedToHub
case constants.ConsignmentRTOInitiated:
// Being returned: the rider carries it back to the sender — once the
// rider app knows this action. Until then it reads as "nothing left
// for you" and ops close the return from the console.
if rtoRiderFlowEnabled() {
return constants.NextActionReturnToSender
}
return constants.NextActionNone
default:
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
// Damaged — all past this rider's leg.

View File

@@ -10,6 +10,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
@@ -36,7 +37,7 @@ func MilerStartDuty(c *fiber.Ctx) error {
var existing models.MilerDutyLog
if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight).
First(&existing).Error; err == nil {
return utils.BadRequest(c, "already on duty, end current duty first")
return resumeMilerDuty(c, milerUserID, existing, req.Lat, req.Lon)
}
now := time.Now()
@@ -61,12 +62,54 @@ func MilerStartDuty(c *fiber.Ctx) error {
})
db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 1)
// Make the rider findable by auto-assignment straight away, from the
// position they started duty at, instead of only after the app's first
// location ping. Skipped when the app sent no coordinates.
indexMilerLocation(milerUserID, req.Lat, req.Lon)
// Give the pending orders around them to this rider now, rather than at
// the next periodic sweep (balanced assignment, Phase B).
if req.Lat != 0 && req.Lon != 0 {
go assignment.SweepNear(req.Lat, req.Lon)
}
return utils.OK(c, fiber.Map{
"dutylogid": dutyLog.Dutylogid,
"loginat": dutyLog.Loginat,
})
}
// resumeMilerDuty answers a start-duty for a rider whose duty from earlier
// today is still open — after a reinstall, a crash, or signing in again. It
// used to answer 400 "already on duty" and change nothing: the app treated
// that as "on duty" and showed the rider online, while their profile stayed
// Offline, so the console listed them Offline and auto-assignment skipped
// them. Now the open duty is resumed: an Offline rider becomes Available
// (a rider mid-delivery or on a break keeps that status) and is made findable
// again from where they are.
func resumeMilerDuty(c *fiber.Ctx, milerUserID int, duty models.MilerDutyLog, lat, lon float64) error {
now := time.Now()
db.DB.Model(&models.MilerProfile{}).
Where("userid = ? AND availabilitystatus = ?", milerUserID, constants.MilerOffline).
Updates(map[string]interface{}{"availabilitystatus": constants.MilerAvailable, "updatedat": now})
if lat != 0 && lon != 0 {
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Updates(map[string]interface{}{
"currentlatitude": lat,
"currentlongitude": lon,
"lastlocationupdatedat": now,
})
indexMilerLocation(milerUserID, lat, lon)
go assignment.SweepNear(lat, lon)
}
db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 1)
return utils.OK(c, fiber.Map{
"dutylogid": duty.Dutylogid,
"loginat": duty.Loginat,
"resumed": true,
})
}
func MilerEndDuty(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
@@ -663,6 +706,11 @@ func MilerGetConsignment(c *fiber.Ctx) error {
"next_hub": nextHubForConsignment(consignment),
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
"inwardedat": consignment.Inwardedat,
// Reverse logistics (additive fields; older app builds ignore them).
"returning": consignment.Status == constants.ConsignmentRTOInitiated,
"can_return": consignment.Status == constants.ConsignmentRTOInitiated && rtoRiderFlowEnabled(),
"return_reason": consignment.Returnreason,
"return_to": returnDestination(consignment),
})
}
@@ -1001,16 +1049,18 @@ func MilerSkipDelivery(c *fiber.Ctx) error {
return utils.BadRequest(c, "reason is required")
}
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
// Ownership through the multi-destination-aware helper. The direct
// pickupbookings.consignmentid lookup named only the FIRST order of a
// pickup, so a rider could not report a failed attempt on orders 2..N.
// Same responses as before.
consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.NotFound(c, "consignment not found")
}
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found")
}
consignment := *consignmentPtr
// A failed attempt can be reported once the rider is carrying the parcel —
// whether they had already tapped start-delivery (Out_for_Delivery) or not
@@ -1058,6 +1108,11 @@ func MilerSkipDelivery(c *fiber.Ctx) error {
db.DB.Create(&exception)
}
// Reverse logistics: at RTO_AUTO_AFTER_ATTEMPTS failed attempts (default
// 3) the parcel goes back to the sender instead of retrying forever. The
// RTO resolves the exception raised just above.
autoRTOAfterSkip(&consignment, milerUserID, req.Reason)
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"attemptcount": consignment.Attemptcount,

View File

@@ -17,6 +17,7 @@ import (
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/legs"
"doormile/internal/milergeo"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
@@ -352,6 +353,32 @@ func UpdateMilerProfile(c *fiber.Ctx) error {
return utils.OK(c, profile)
}
// indexMilerLocation records a rider's position where auto-assignment looks
// for riders: the `miler:gps:{userid}` key and the `milers:locations` GEO set
// that internal/assignment GEOSEARCHes around a pickup.
//
// Used by UpdateMilerLocation and MilerStartDuty. Before, only the location
// ping wrote here, so a rider who had tapped "Start duty" was Available in the
// database but invisible to assignment until their app sent its first GPS
// ping — orders created in that gap were never offered to them.
func indexMilerLocation(milerUserID int, lat, lon float64) {
if db.Rdb == nil || lat == 0 || lon == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
redisKey := fmt.Sprintf("miler:gps:%d", milerUserID)
val := fmt.Sprintf("%f,%f", lat, lon)
db.Rdb.Set(ctx, redisKey, val, 30*time.Minute)
// A failed write is a rider auto-assignment can never find; say so
// instead of dropping the error.
if err := milergeo.Index(ctx, db.Rdb, milerUserID, lat, lon); err != nil {
utils.Warn("rider position not indexed for assignment", "miler_id", milerUserID, "error", err)
}
}
func UpdateMilerLocation(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
@@ -380,20 +407,7 @@ func UpdateMilerLocation(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update location")
}
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
redisKey := fmt.Sprintf("miler:gps:%d", milerUserID)
val := fmt.Sprintf("%f,%f", req.Latitude, req.Longitude)
db.Rdb.Set(ctx, redisKey, val, 30*time.Minute)
db.Rdb.GeoAdd(ctx, "milers:locations", &redis.GeoLocation{
Name: strconv.Itoa(milerUserID),
Latitude: req.Latitude,
Longitude: req.Longitude,
})
}
indexMilerLocation(milerUserID, req.Latitude, req.Longitude)
return utils.OK(c, fiber.Map{
"latitude": req.Latitude,

View File

@@ -0,0 +1,252 @@
package controllers
import (
"math"
"sort"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Reverse logistics, phase 4 (the parts that need no product decision): the
// timeline of one parcel, and return rates per client and per reason.
// Plan: krow_talent_app/docs/reverse-logistics-plan.md (C6, C7).
// historyEvent is one line of GET /admin/consignments/:id/history.
type historyEvent struct {
Historyid int `json:"historyid"`
Eventstatus string `json:"eventstatus"`
Remarks string `json:"remarks"`
Fromstatus string `json:"fromstatus,omitempty"`
Createdat time.Time `json:"createdat"`
Userid *int `json:"userid"`
Actorname string `json:"actorname"`
Hubid *int `json:"hubid"`
Hubname string `json:"hubname"`
}
// historyEventLimit caps one parcel's timeline; a real parcel has a few dozen
// events at most.
const historyEventLimit = 500
// GetConsignmentHistory — GET /admin/consignments/:id/history
// Every recorded event of one parcel, oldest first: pickup, hub hand-overs,
// failed attempts, a return starting, being re-attempted or completed. A client
// login reads only its own parcels (same scoping as the consignment read).
func GetConsignmentHistory(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil || id <= 0 {
return utils.BadRequest(c, "invalid consignment ID")
}
var cn models.Consignment
if err := scopeToOwnTenant(c, db.DB, "tenantid").Select("consignmentid").First(&cn, id).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
events := []historyEvent{}
if err := db.DB.Table("consignmenthistory AS h").
Select(`h.historyid, h.eventstatus, COALESCE(h.remarks, '') AS remarks, h.createdat,
h.userid, COALESCE(u.authname, '') AS actorname, h.hubid, COALESCE(hb.hubname, '') AS hubname`).
Joins("LEFT JOIN appusers u ON u.userid = h.userid").
Joins("LEFT JOIN hubs hb ON hb.hubid = h.hubid").
Where("h.consignmentid = ?", id).
// /miler/consignments/logs writes one row per GPS ping ("GPS Update:
// ..."); those are a trail, not events, and could run to hundreds.
Where("COALESCE(h.remarks, '') NOT LIKE ?", "GPS Update:%").
Order("h.createdat ASC, h.historyid ASC").
Limit(historyEventLimit).
Scan(&events).Error; err != nil {
utils.Error("consignment history", "consignment_id", id, "error", err.Error())
return utils.Internal(c, "failed to load the parcel history")
}
// The RTO_Initiated remark carries "[from:<status>] " so a re-attempt can
// restore the status; that is bookkeeping, not something to show a person.
for i := range events {
if m := rtoFromPrefix.FindStringSubmatch(events[i].Remarks); m != nil {
events[i].Fromstatus = m[1]
events[i].Remarks = strings.TrimSpace(strings.TrimPrefix(events[i].Remarks, m[0]))
}
}
return utils.OK(c, events)
}
// returnRate is returns as a percentage of parcels, to one decimal place.
func returnRate(returns, total int64) float64 {
if total == 0 {
return 0
}
return math.Round(float64(returns)*1000/float64(total)) / 10
}
// returnReasonLabel is the reason a return was filed under: returnreason is
// stored as "Label: free-text note" (rtoReasonText), and the label is what a
// report groups by.
func returnReasonLabel(stored string) string {
label := strings.TrimSpace(strings.SplitN(stored, ":", 2)[0])
if label == "" {
return "Not recorded"
}
return label
}
type returnsClientRow struct {
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Total int64 `json:"total"`
Delivered int64 `json:"delivered"`
InReturn int64 `json:"in_return"`
Returned int64 `json:"returned"`
ReturnRate float64 `json:"return_rate"`
}
type returnsReasonRow struct {
Reason string `json:"reason"`
Count int64 `json:"count"`
}
// GetReturnsSummary — GET /admin/returns/summary?from&to&tenantid
// Of the parcels created in the period (cancelled ones left out), how many went
// into a return, per client and per reason, and how long completed returns
// took. Defaults to the last 30 days. A client login sees only its own figures.
func GetReturnsSummary(c *fiber.Ctx) error {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
start, end, err := returnsDateRange(c.Query("from"), c.Query("to"))
if err != nil {
return utils.BadRequest(c, err.Error())
}
if start == nil {
// India midnight 29 days ago, the same clock returnsDateRange parses
// ?from= on (not DBToday, whose India digits are labelled UTC).
n := time.Now().In(utils.ISTLocation())
t := time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, utils.ISTLocation()).AddDate(0, 0, -29)
start = &t
}
base := func() *gorm.DB {
q := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.deletedat IS NULL AND cn.status <> ?", "Cancelled").
Where("cn.createdat >= ?", *start)
if end != nil {
q = q.Where("cn.createdat < ?", *end)
}
return q
}
clients := []returnsClientRow{}
if err := base().
Select(`cn.tenantid,
CASE WHEN cn.tenantid = 0 THEN 'Direct customers (B2C)' ELSE COALESCE(t.tenantname, '') END AS tenantname,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE cn.status = ?) AS delivered,
COUNT(*) FILTER (WHERE cn.status = ?) AS in_return,
COUNT(*) FILTER (WHERE cn.status = ?) AS returned`,
constants.ConsignmentDelivered, constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender).
Joins("LEFT JOIN tenants t ON t.tenantid = cn.tenantid").
Group("cn.tenantid, t.tenantname").
Scan(&clients).Error; err != nil {
utils.Error("returns summary: clients", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
var stored []string
if err := base().Where("cn.status IN ?", []string{constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender}).
Pluck("COALESCE(cn.returnreason, '')", &stored).Error; err != nil {
utils.Error("returns summary: reasons", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
reasonCount := map[string]int64{}
for _, r := range stored {
reasonCount[returnReasonLabel(r)]++
}
reasons := make([]returnsReasonRow, 0, len(reasonCount))
for label, n := range reasonCount {
reasons = append(reasons, returnsReasonRow{Reason: label, Count: n})
}
sortReasons(reasons)
// Every parcel in return right now, whenever it was created: the same set
// the Returns list's "In return" tab shows (the windowed counts above
// describe parcels created in the period, for the rate).
var inReturnNow int64
if err := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.deletedat IS NULL AND cn.status = ?", constants.ConsignmentRTOInitiated).
Count(&inReturnNow).Error; err != nil {
utils.Error("returns summary: in return now", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
var avg struct{ Days *float64 }
if err := base().Where("cn.status = ? AND cn.returninitiatedat IS NOT NULL AND cn.returndeliveredat IS NOT NULL",
constants.ConsignmentReturnedToSender).
Select("AVG(EXTRACT(EPOCH FROM (cn.returndeliveredat - cn.returninitiatedat)) / 86400.0) AS days").
Scan(&avg).Error; err != nil {
utils.Error("returns summary: duration", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
var total, delivered, inReturn, returned int64
for i := range clients {
clients[i].ReturnRate = returnRate(clients[i].InReturn+clients[i].Returned, clients[i].Total)
total += clients[i].Total
delivered += clients[i].Delivered
inReturn += clients[i].InReturn
returned += clients[i].Returned
}
sortClientsByReturns(clients)
var avgDays *float64
if avg.Days != nil {
d := math.Round(*avg.Days*10) / 10
avgDays = &d
}
resp := fiber.Map{
"from": start.Format("2006-01-02"),
"basis": "parcels created in the period, cancelled ones excluded",
"total": total,
"delivered": delivered,
"in_return": inReturn,
"in_return_now": inReturnNow,
"returned": returned,
"return_rate": returnRate(inReturn+returned, total),
"avg_return_days": avgDays,
"by_client": clients,
"by_reason": reasons,
}
if end != nil {
resp["to"] = end.AddDate(0, 0, -1).Format("2006-01-02")
}
return utils.OK(c, resp)
}
// sortReasons orders reasons by count, most common first (ties by name).
func sortReasons(rows []returnsReasonRow) {
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
return rows[i].Reason < rows[j].Reason
})
}
// sortClientsByReturns puts the clients with the most returns first.
func sortClientsByReturns(rows []returnsClientRow) {
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i].InReturn+rows[i].Returned, rows[j].InReturn+rows[j].Returned
if a != b {
return a > b
}
return rows[i].Tenantname < rows[j].Tenantname
})
}

View File

@@ -0,0 +1,151 @@
# Balanced auto-assignment: implementation plan
**Status:** Phases A and B built and tested, not committed or deployed (2026-10-06). Phase C is a server setting (`MILER_MAX_ACTIVE_BOOKINGS=20`).
### Phase B: what was built
- `internal/assignment/release.go` (new):
- **`releaseUnaccepted`** (runs at the start of every sweep): an assignment still `Assigned` on an order still `Miler_Assigned`, older than `ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES` (default 10; 0 = off), goes back to the pool. The order returns to `Pending_Pickup` with no rider, and the assignment becomes `Reassigned` with a note. The customer stage is walked back (`cxstage.Release`), and the rider is set to Available if they hold nothing else. Every write is conditional, so a rider accepting at the same moment wins.
- **Only auto-assignments are released.** They're now labelled `Remarks = "Auto-assigned"` (`autoAssignedRemark`). A rider hand-picked by ops or hub staff is never taken away. (The hub console's manual assign records no "assigned by", so the label is the only reliable marker.) Assignments made before this change have no label and are never released.
- **`ridersToSkip` / `withoutRiders`** (applied in `selectMilerWithAI`): an order is never offered back to a rider it was released from, who rejected it, or who cancelled it.
- **`SweepNear`**: called from `MilerStartDuty` in a goroutine. It assigns the pending orders within 10 km of a rider who just started duty, instead of waiting up to 5 minutes for the next sweep.
- `pendingForSweep` now also loads the pickup coordinates (needed by `pendingNear`).
- Tests: `release_test.go` (settings, skip filter, distance) and `release_pg_test.go` (release after timeout; inside the timeout, accepted and manual orders never released; the rider freed when nothing else is held; a concurrent accept wins; a released or rejected order goes to another rider; the duty-start sweep only picks nearby orders).
- End to end on the real backend: a waiting order was assigned **0.19 s** after the rider started duty. An unaccepted order was released after the timeout and given to the other rider in the same sweep. Accepted and manual orders were untouched after 3 sweeps. Bulk split was still 3/3.
### Phase A: what was built
- **In hand** = `openStopsToday` (today's open, non-cancelled orders), the same count the ceiling uses. Using all open records ever would let stale, abandoned orders skew the balance.
- `selectMilerWithAI` keeps only the least-loaded riders (`leastLoaded`), then the AI or the fallback chooses among them. It now also returns the full pool.
- `pickBestFromCandidates` → `betterChoice`: in hand ↑, then session ↑ (`sessionStops`, counted since the open `milerdutylogs.loginat`), then distance ↑, then rating ↓.
- `finalizeChoice` (both commit paths): takes `pg_advisory_xact_lock(7270001)`, recounts the pool, keeps the original choice if it's still least loaded, otherwise picks the best of the least loaded. Returns `errNoRiderCapacity` if everyone is at the ceiling (the attempt is retried later). The AI decision id is dropped when the choice changes.
- Tests: `balance_test.go` (ranking) and `balance_pg_test.go` (50 over 5 → 10 each; 23 → 5/5/5/4/4; **50 concurrent → within 1**; newcomers catch up; tie-break; all at the ceiling → waits).
- End to end on the real backend: bulk upload of 9 orders with riders at 0, 2 and 5 km → **3/3/3**. A 4th rider then joins and 4 more orders arrive: the newcomer gets 3, the 4th goes to the nearest (all tied).
**Goal:** however many orders and riders there are, split the orders **equally** among the riders, with riders logging in and out all day.
---
## 0. Where things stand (context for whoever picks this up)
### Already built (2026-10-06)
| Fix | Where |
|---|---|
| Rider search falls back to `GEORADIUS` on Redis < 6.2; `/ready` shows `redis_geo` | `internal/milergeo/` (committed `9b94be1`) |
| Only **today's**, non-cancelled open orders count towards the per-rider limit | `internal/assignment/ai_layer.go` → `openStopsToday` |
| Only riders with **live GPS** (default 15 min) are offered orders | `ai_layer.go` → `milerHasFreshGPS`, setting `ASSIGNMENT_MAX_GPS_AGE_MINUTES` |
| Console cancel (single and bulk) **frees the rider** (closes the assignment) | `controllers/adminController.go` → `closeOpenAssignments` |
| **Pending-order sweeper:** retries every unassigned pending order every 5 min (last 72 h) | `internal/assignment/sweeper.go`, started in `main.go` |
| **No double assignment:** the order is claimed only if it still has no rider and isn't cancelled | `crm_assignment.go` → `claimBooking` (used by both commit paths) |
Tests: `internal/assignment/eligibility_pg_test.go`, `sweeper_test.go`, `controllers/cancel_frees_rider_pg_test.go` (real-Postgres tests skip unless `REGISTRY_TEST_DSN` is set).
### How a rider is chosen today (what this plan changes)
1. Find riders within **10 km** of the pickup (Redis GEO), whose status allows work, with live GPS, holding fewer than `MILER_MAX_ACTIVE_BOOKINGS` (default **3**) of today's open orders.
2. Ask the AI service (`routemate …/decide-assignment`). That endpoint currently returns **404**, so the backend falls back to:
```
score = distance_km + 2 × open_orders_today − 0.5 × rating (lowest wins)
```
**The problem:** distance dominates, so riders near the pickups get more orders and the split is not equal. `MILER_MAX_ACTIVE_BOOKINGS` is only a ceiling and can't make it equal. **A code change is required.**
### Relevant facts found in the code
- A rider **can't end duty** while holding Assigned/Accepted orders (`MilerEndDuty`).
- If a rider's app dies, their orders **stay with them**: nothing releases an order a rider never accepted. (`InternalReassign` exists in `adminController.go`, but nothing calls it automatically.)
- Nothing reacts when a rider **starts duty**: they only get orders from the next retry or sweep.
- Each duty session is recorded in `milerdutylogs` (`loginat`, `logoutat`).
- The rider app sends GPS about **every 30 s** in the background (`PUT /miler/location`).
---
## 1. The balancing rule
Riders come and go, so "same number of orders **today**" isn't fair: a rider logging in at 3 PM would get *every* new order until they catch up.
| Rule | With riders joining and leaving |
|---|---|
| Equal orders today | ❌ late joiners get flooded |
| **Equal orders in hand right now** ✅ | fair at every moment; a new rider gets a fair share of *new* orders; a rider who leaves just drops out |
**Rule:** each new order goes to the eligible rider holding the **fewest unfinished orders right now**. Ties are broken by:
1. fewest orders **this duty session** (since `milerdutylogs.loginat`);
2. nearest to the pickup;
3. highest rating.
Balancing happens **among riders near each pickup** (10 km), so each area balances its own riders.
| Situation | Expected |
|---|---|
| 5 riders, 50 orders | 10 each, at most 1 apart (if the ceiling allows) |
| 23 orders, 5 riders | 5, 5, 5, 4, 4 |
| 3 riders hold 4 each; 2 riders log in | the next 8 orders go to the 2 new riders, then everyone shares |
| A rider goes offline (no GPS) | gets nothing new; the others share |
**"In hand"** = assignments `Assigned`/`Accepted` on orders that aren't `Cancelled`, `Delivered` or otherwise finished. Note: for hyperlocal parcels the assignment stays open until delivery, which is correct, because the rider is still carrying it.
---
## 2. Code changes (`doormile_backend`)
### Phase A: equal split (core) · ~½ day
| File / function | Change |
|---|---|
| `internal/assignment/ai_layer.go` → `collectEligibleCandidates` | For each eligible rider, compute **in-hand count** (replaces the today-only count for ranking; the today-only count can stay as the ceiling check) and **session count** (assignments since the latest open `milerdutylogs.loginat`). Store both on `milerCandidate` / `aiCandidate`. |
| `ai_layer.go` → `selectMilerWithAI` | Before calling the AI, **keep only candidates with the minimum in-hand count**. The AI or fallback then chooses among them, so balance holds even when the AI endpoint returns. |
| `ai_layer.go` → `pickBestFromCandidates` | Replace the weighted formula with ordering by in-hand ↑, session count ↑, distance ↑, rating ↓. |
| `internal/assignment/crm_assignment.go` → `commitAssignment`, `customer_assignment.go` → `commitCustomerAssignment` | **Bulk safety:** 50 orders arriving at once run in parallel and would all pick the same "least-loaded" rider. Inside the commit transaction, lock the rider's `milerprofiles` row (`SELECT … FOR UPDATE`), **recount** their in-hand orders, and refuse (try the next candidate) if they're at the ceiling or no longer the least loaded. Keep `claimBooking` as is. |
### Phase B: riders joining and leaving · ~½ day
| File / function | Change |
|---|---|
| `controllers/milerAppController.go` → `MilerStartDuty` | **Rider comes online:** after duty starts (and the GPS is indexed), trigger an immediate sweep of pending orders near them (new helper in `sweeper.go`, e.g. `SweepNear(lat, lon)`). New riders get orders within seconds, not up to 5 min. |
| `internal/assignment/sweeper.go` | **Rider stops responding:** release orders that are still **Assigned** (never Accepted) after `ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES` (new, e.g. 10): close that assignment as `Reassigned`, clear `pickupbookings.assignedmileruserid`, set the status back to `Pending_Pickup`, and let the sweeper reassign. **Never release an Accepted order.** Reuse the logic of `InternalReassign` where possible. |
### Phase C: settings (server, no code)
| Setting | Recommended | Purpose |
|---|---|---|
| `MILER_MAX_ACTIVE_BOOKINGS` | **20** on the server (code default stays 3) | safety ceiling only; balancing decides the split |
| `ASSIGNMENT_MAX_GPS_AGE_MINUTES` | 15 (exists) | only riders whose app is running |
| `ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES` | 10 (new, Phase B) | release orders never accepted |
| `ASSIGNMENT_SWEEP_SECONDS` | 300 (exists) | pending-order retry interval |
| `ASSIGNMENT_SWEEP_MAX_AGE_HOURS` | 72 (exists) | older pending orders are left alone |
---
## 3. Tests (real Postgres, same pattern as `eligibility_pg_test.go`)
1. 5 riders, 50 orders assigned one by one → 10 each, max − min ≤ 1.
2. **50 orders created concurrently** → still balanced, no rider over the ceiling, every order exactly 1 rider.
3. 3 busy riders + 2 who just started duty → new orders go to the newcomers until level.
4. A rider with stale GPS gets nothing new.
5. An order still Assigned after the timeout is released and goes to the least-loaded rider; an **Accepted** order is never released.
6. Riders outside 10 km are never used.
7. A tie on in-hand count is broken by session count, then distance, then rating.
8. Starting duty triggers assignment of nearby pending orders (Phase B).
Plus a unit test for the new ranking (`pickBestFromCandidates`) and the new setting parser.
---
## 4. Rollout
1. Deploy **Phase A** with `MILER_MAX_ACTIVE_BOOKINGS=20`. For one day, compare orders per rider (should be within 1 of each other among riders in the same area).
2. Deploy **Phase B**.
3. If riders are sent too far, add a cap such as "prefer balance, but not more than X km further than the nearest eligible rider" (`ASSIGNMENT_BALANCE_MAX_EXTRA_KM`).
---
## 5. Unchanged
The 10 km radius, the live-GPS rule, cancel freeing the rider, the pending-order sweeper, `claimBooking`, manual assignment (`AssignMilerToBooking`), hub batch assign (`HubBatchAssign`, its own cap of 5), the express dispatch agent, and the customer-app flow.
---
## 6. Open points (decide before or during the work)
- **Distance vs balance:** do you want the extra-km cap from rollout step 3 from day one?
- **The AI endpoint** (`/api/v1/doormile/decide-assignment` on `routemate.workolik.com`) is missing. Once restored, it will choose only among the least-loaded riders (Phase A guarantees this).
- **Hub batch assign** has its own greedy logic and cap (5). Should it use the same balancing later?

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,692 @@
# Doormile Backend — Customer App Integration Handbook
**For:** the developer building `doormile_customer_app` (Flutter)
**Backend:** `doormile_backend` @ `main` (Go · Fiber v2 · PostgreSQL · Redis · NATS)
**Base URL:** `https://api.doormile.com/api/v1`
**Namespace:** everything you call lives under `/customer/*` — 28 routes, no exceptions
**Verified against source:** 16 Sep 2026
> This is the *practical* handbook. Two companion docs already exist and are still correct:
> `docs/customer-app-api.md` (the full change record and design rationale) and
> `docs/customer-app-api-crisp.md` (the terse endpoint reference).
> This file is what you need to actually ship — the flows, the gotchas, and the things that will
> waste your week if nobody tells you.
---
## 0. Read this first — two things are blocking you today
### 0.1 There is no SMS gateway. Nobody can sign in.
`sms.Register()` had **zero callers** in the entire backend until this week. The package shipped with a
logging sink standing in for a real gateway, and the sink was never replaced. So:
```
POST /customer/auth/otp/request
-> backend generates a 4-digit code
-> writes it to the APPLICATION LOG
-> returns { success: true } <-- a lie
-> no text is ever sent
```
The endpoint reports success. No customer has ever received a code.
**What changed:** `sms.Configure()` is now called at boot (`main.go`), and the log sink now *refuses*
in production instead of pretending. The boot log says plainly which transport it got, and
`GET /api/v1/ready` reports it:
```json
{ "sms": { "transport": "log", "configured": false } }
```
**What has NOT changed:** there is still no provider. `SMS_GATEWAY_URL` is unset. That is a
procurement item (an Indian provider plus DLT template registration), not something you or I can code
around.
**How you get in *today* — pick one:**
| Method | How | Notes |
|---|---|---|
| **Staging OTP** (best) | Ops sets `CX_STAGING_OTP=1234` on a **non-production** deployment | A fixed code that always verifies. Refused outright when `ENV=production` — `internal/sms/sms.go:105`. **Not yet set on the cluster.** |
| **Read the log** | Ask backend/ops for the code out of the pod log | Works right now, no deploy needed. Tedious. |
| **Paste a token** | `--dart-define=DM_DEV_TOKEN=eyJ...` | A **real** server-issued token. Everything after it is genuinely authorised. Expires in 1 hour unless you also pass `DM_DEV_REFRESH_TOKEN`. |
### 0.2 `DM_MOCK=true` bookings never reach the server. At all.
This is the root cause of *"I created a booking in the app and it doesn't show in the admin console."*
`DevDoormileApi.createBooking()` (`lib/data/dev_doormile_api.dart:440`) builds a `Booking` object in
memory and pushes it onto a local list. **It never opens a socket.** Nothing is POSTed, nothing is
stored, nothing exists.
Because sign-in was impossible (§0.1), offline mode became the only practical way into the app — and
every booking made that way was fiction. The database confirms it: the newest `Customer_App` booking
is **id 565, dated 8 Sep 2026**. The count has not moved since.
**Rule:** a booking is only real if `AppConfig.useDevData == false`. If the Account screen says
`DEV DATA (offline)`, nothing you do on that screen reaches Doormile.
Use `DM_LOGIN_AS` + `DM_LOGIN_CODE` instead when you want to skip the login *screen* without faking
the login — it runs the real `otp/request` + `otp/verify` pair and the token it gets back is the
server's.
---
## 1. Connection basics
### 1.1 Base URL
| Environment | URL |
|---|---|
| Production | `https://api.doormile.com/api/v1` |
| Staging | *not yet provisioned* — staging currently points at production |
| Local | `http://10.0.2.2:8080/api/v1` (Android emulator to host) |
Every customer path is then prefixed `/customer`, so a full URL looks like:
```
https://api.doormile.com/api/v1/customer/bookings
```
### 1.2 Headers
| Header | When | Value |
|---|---|---|
| `Authorization` | every authenticated call | `Bearer <accessToken>` |
| `Content-Type` | every POST/PUT/PATCH | `application/json` |
| `Idempotency-Key` | `POST /bookings`, `POST /auth/otp/verify` | any stable unique string per logical action (mint a UUID when the user taps the button) |
| `X-Client` | optional | `doormile-cx/1.0.0+1` |
| `X-Platform` | optional | `android` / `ios` |
> **Flutter Web only:** the backend's CORS `AllowHeaders` is
> `Origin,Content-Type,Accept,Authorization,Idempotency-Key` (`main.go:169`).
> `X-Client` and `X-Platform` are **not** in it, so a browser preflight will reject them.
> On a native build CORS does not apply and they are fine. If you target web, either drop those
> two headers or ask backend to add them.
### 1.3 Timeouts
| Call | Budget |
|---|---|
| Reads | 15s |
| `POST /bookings` | 30s — it writes across several tables; better to wait than orphan a booking the server did create |
---
## 2. The response envelope
Customer endpoints use their **own** envelope (`utils/response_cx.go`), deliberately different from
the miler/console one. Do not copy parsing code from another Doormile client.
### 2.1 Success
```json
{ "success": true, "data": { }, "message": "" }
```
`message` is **always present** on success — empty string, never omitted.
### 2.2 List
```json
{
"success": true,
"data": [],
"total": 48,
"nextCursor": "540",
"message": ""
}
```
- `data` is **always an array**, never `null`. Type it as a list.
- `nextCursor` is `null` on the last page.
- `total` is the size of the *filtered* set — the count matches the tab you asked for.
### 2.3 Error
```json
{
"success": false,
"message": "That code has expired. Request a new one.",
"error": { "code": "invalid_otp" }
}
```
The machine-readable code is **nested under `error.code`**, not at the top level. `message` is
customer-safe English — render it verbatim in your error state.
### 2.4 Error codes
| Code | HTTP | Meaning | What the app should do |
|---|---|---|---|
| `invalid` | 400 | Malformed or rejected request | Show `message`, let them fix it |
| `invalid_name` | 400 | Name failed validation at signup | Focus the name field |
| `invalid_otp` | 401 | Wrong or expired code | Clear the field, offer resend |
| `unauthorized` | 401 | Missing or expired access token | Refresh once, then sign out |
| `forbidden` | 403 | Not your resource | Go back |
| `not_found` | 404 | No such booking or order | Go back, refresh the list |
| `conflict` | 409 | Already done, or in progress | Refresh and re-read state |
| `unserviceable` | 400 | Outside operating cities | Show the serviceability message |
| `rate_limited` | 429 | Throttle hit | Back off, show a countdown |
| `server_error` | 500 | We broke | Generic retry state |
---
## 3. Authentication
No passwords anywhere. A 4-digit code to a phone **or** an email address.
### 3.1 The flow
```
+- new user ---> POST /customer/auth/signup {name, phone, email}
| |
user -+ v
+- returning --> POST /customer/auth/otp/request {identifier}
|
v (code delivered - see 0.1)
POST /customer/auth/otp/verify {identifier, code}
+ Idempotency-Key
|
v
{ accessToken, refreshToken, expiresIn, customer }
|
+------------------+------------------+
v v
use for 1 hour POST /customer/auth/refresh
{refreshToken} -> new pair
```
### 3.2 Session response
```json
{
"success": true,
"message": "",
"data": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "9f2c... (64 hex chars)",
"expiresIn": 3600,
"customer": { "id": 1, "name": "", "phone": "", "email": "" }
}
}
```
### 3.3 Lifetimes and limits — code against these exactly
| Thing | Value | Source |
|---|---|---|
| Access token TTL | **1 hour** | `cxAccessTTL` |
| Refresh token TTL | **60 days** | `cxRefreshTTL` |
| OTP TTL | **5 minutes** | `cxOtpTTL` |
| OTP length | **4 digits** | `cxOtpLength` |
| Resend cooldown | **30 seconds** | `cxResendWait` |
| Verify attempts per code | **3**, then the code dies | `cxOtpMaxVerify` |
| Codes per identifier per hour | **5** | `cxOtpMaxRequests` |
| Credential endpoint throttle | **10/min shared** across `otp/request`, `signup`, `otp/verify`, `refresh` | `authLimiter()` |
That last one matters: the budget is **shared**. An aggressive resend loop will 429 the verify call
too. Put a real 30-second countdown on the resend button.
### 3.4 Token handling
- Persist the refresh token. 60 days means a returning customer should never see the login screen.
- Refresh **once** on a 401, then give up and sign out. Do not loop — the throttle is shared.
- The refresh token is stored **hashed** server-side. If you lose it, it cannot be recovered; the
customer signs in again.
### 3.5 `POST /auth/otp/verify` needs an `Idempotency-Key`
The client retries over flaky networks, and a replayed verify must return the **original** session
rather than mint a second one. Send a key.
> **Fixed this week:** the idempotency middleware used to cache any status below 500 for 24 hours,
> including the **401** from a mistyped code. One typo locked a customer out for a day — confirmed
> live, the retry came back carrying `Idempotent-Replay: true`. Only 2xx is cached now
> (`middlewares/idempotency.go:104`). A wrong code now genuinely re-executes.
---
## 4. Endpoint reference — all 28
### 4.1 Public (no token)
| Method | Path | Purpose |
|---|---|---|
| POST | `/customer/auth/otp/request` | Send a code to a phone or email |
| POST | `/customer/auth/signup` | Create an account |
| POST | `/customer/auth/otp/verify` | Exchange code for a session · **Idempotency-Key** |
| POST | `/customer/auth/refresh` | Exchange refresh token for a new pair |
| GET | `/customer/serviceability/states` | State picker |
| GET | `/customer/serviceability/states/:stateCode/districts` | District picker |
| GET | `/customer/pickup-slots` | Bookable time slots |
| GET | `/customer/config/booking-limits?lat=&lng=` | `maxPackages`, `maxDestinations` |
> The booking form is explorable **before** sign-in by design. Do not put a login wall on the first
> screen.
### 4.2 Authenticated (`Bearer` + role 9)
**Session and profile**
| Method | Path | Purpose |
|---|---|---|
| GET | `/customer/auth/me` | Who am I |
| POST | `/customer/auth/logout` | Revoke this session |
| GET · PUT | `/customer/profile` | Read / update profile |
**Saved addresses**
| Method | Path |
|---|---|
| GET · POST | `/customer/locations` |
| PUT · DELETE | `/customer/locations/:id` |
**Push**
| Method | Path | Purpose |
|---|---|---|
| POST | `/customer/devices` | Register an FCM token |
| DELETE | `/customer/devices/:token` | Unregister |
> One row per device token, not one column per customer — a phone and a tablet must both receive the
> delivery notification. Re-register on every token rotation.
**Places** — proxied, never keyed
| Method | Path |
|---|---|
| GET | `/customer/places/reverse-geocode?lat=&lng=` |
| GET | `/customer/places/search?q=` |
> The legacy rider app shipped a Google Maps key inside the binary and it had to be revoked. **You
> are never handed a key.** Ask the backend, the backend asks the geocoder.
**Pricing**
| Method | Path |
|---|---|
| POST | `/customer/fare/estimate` |
**Bookings**
| Method | Path | Purpose |
|---|---|---|
| POST | `/customer/bookings` | Create · **Idempotency-Key** · city-gated |
| GET | `/customer/bookings?status=&limit=&cursor=` | List |
| GET | `/customer/bookings/:reference` | Detail / tracking poll |
| POST | `/customer/bookings/:reference/cancel` | Cancel |
| PATCH | `/customer/bookings/:reference/destinations/:index` | Edit one destination |
| GET | `/customer/orders/:trackingId` | One order, for `doormile://track/...` deep links |
**QA only**
| Method | Path |
|---|---|
| POST | `/customer/ops/bookings/:reference/stage` |
> Refused unless `ENV` is non-production **and** `CX_ALLOW_STAGE_OVERRIDE=true` — two independent
> switches, because either one wrong in production would let any customer mark their own parcel
> delivered. It exists so every tracking state is reachable for design QA, which means **your debug
> stepper can be deleted.**
---
## 5. The payloads that matter
### 5.1 `POST /customer/fare/estimate`
Call this on every route and package-count change. It is cheap, cached 60s, and a failed estimate
**must never block a booking**.
```json
{
"pickup": { "lat": 11.0168, "lng": 76.9558 },
"destinations": [
{ "stateCode": "TN", "districtCode": "CBE", "packageCount": 2 }
]
}
```
```json
{
"min": 180,
"max": 240,
"paymentMethod": "UPI · Cash at doorstep",
"parcel": "2 parcels",
"routeKm": 12.4
}
```
### 5.2 `POST /customer/bookings`
```json
{
"pickup": {
"title": "Home",
"sub": "12 Gandhi St, RS Puram",
"lat": 11.0168,
"lng": 76.9558
},
"slotId": "2026-09-16T10:00",
"destinations": [
{
"stateCode": "TN",
"districtCode": "CBE",
"packageCount": 2,
"details": {
"street": "45 Cross Cut Rd",
"building": "Flat 3B",
"landmark": "opp. the bakery",
"recipientName": "Priya",
"recipientPhone": "9876543210",
"instructions": "Ring twice",
"pin": { "lat": 11.0041, "lng": 76.9662 },
"codAmount": 500
}
}
],
"estimate": { "min": 180, "max": 240 },
"remarks": "Handle with care"
}
```
Field notes that will bite you:
| Field | Why it matters |
|---|---|
| `estimate` | What the customer was **shown on Review**. Recorded for dispute audit — when the settled price is questioned months later, the number on the screen is the fact that matters. The server validates it against its own quote and rejects a tampered band. |
| `remarks` | **Top-level, not inside a destination.** Lands in `PickupBooking.Notes`, which the admin Orders table displays and searches. Put it in the wrong place and every booking reaches the console with an empty note. |
| `codAmount` | Money collected at that door **on the customer's behalf**. Doormile is the carrier, not the seller. |
| `details.pin` | Overrides the district centroid with an exact drop pin. Send it whenever you have one. |
Caps: `maxDestinations` and `maxPackages` from `/config/booking-limits` (hard ceiling 25,
`cxAbsoluteMaxDestinations`). Re-fetch the limits when the pickup point moves — they vary by city.
**City gate:** pickup must be in an operating city, matched on the 3-digit pincode prefix —
`641` Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Anything else is
refused. The backend resolves your `lat`/`lng` to a pincode itself, so you do not send one.
### 5.3 `GET /customer/bookings`
| Param | Values | Default |
|---|---|---|
| `status` | `active` · `completed` · `cancelled` | all |
| `limit` | 1–**50** | 20 |
| `cursor` | the `nextCursor` from the previous page | — |
**Keyset pagination, not offset.** Offsets drift when a new booking lands mid-scroll and show the
same row twice. Pass the cursor back verbatim; stop when `nextCursor` is `null`.
---
## 6. The booking object
One shape. The list row and the detail read are **identical** — build one parser.
```json
{
"reference": "DM2609160042",
"stage": "on_the_way",
"status": "active",
"cancellable": true,
"createdAt": 1789564800000,
"pickup": { "title": "Home", "sub": "12 Gandhi St", "lat": 11.0168, "lng": 76.9558 },
"slotId": "2026-09-16T10:00",
"destinations": [
{
"stateCode": "TN", "stateName": "Tamil Nadu",
"districtCode": "CBE", "districtName": "Coimbatore",
"packageCount": 2,
"district": { "code": "CBE", "name": "Coimbatore", "available": true,
"hub": "CBE Central", "promise": "Same day" },
"details": { "street": "45 Cross Cut Rd", "recipientName": "Priya", "codAmount": 500 },
"trackingId": null,
"stage": null,
"verification": null
}
],
"miler": { "name": "Ravi", "vehicle": "TN 37 AB 1234", "phone": "9000000000",
"rating": 4.8, "trips": 412, "vehicleType": "bike" },
"deliveryAgent": null,
"milerDistanceKm": 2.4,
"milerEtaMinutes": 9,
"milersInZone": 0,
"routeKm": 12.4,
"expectedDelivery": 1789600000000,
"fare": { "min": 180, "max": 240,
"paymentMethod": "UPI · Cash at doorstep", "parcel": "2 parcels" },
"amountPaid": null,
"deliveredAt": null,
"cancelReason": null,
"history": [ { "stage": "booked", "at": 1789564800000, "actor": "customer" } ]
}
```
### 6.1 Contract guarantees — your type declarations depend on these
- `pickup` and `slotId` are present on **every** booking, cancelled ones included.
- `destinations[].stateName` and `districtName` are **always populated**. Render
`"Coimbatore, Tamil Nadu"` from them; never look a code up client-side.
- All timestamps are **epoch milliseconds**, integers.
### 6.2 Nullability — when each field appears
| Field | Null until |
|---|---|
| `miler` | a rider is assigned (stage >= `assigned`) |
| `milerDistanceKm` / `milerEtaMinutes` | **only** at `on_the_way` and `arrived` — after pickup the number would describe a journey that already ended |
| `milersInZone` | `0` except on a **single-booking read** at stage `booked` (it is a Redis GEOSEARCH; running it across a 20-row list would put 20 of them behind one page load) |
| `destinations[].trackingId` | `order_created` |
| `destinations[].stage` | `order_created` |
| `destinations[].verification` | `picked_up` — the weight and photos are what the rider recorded at the door |
| `amountPaid` | `picked_up` |
| `deliveryAgent` | an order is out for delivery |
| `deliveredAt` | every destination is delivered |
| `cancelReason` | cancelled |
Parcel photo URLs are **signed and expire in 30 minutes**. Long enough to open the receipt; short
enough that a forwarded link is dead. Do not cache them — re-fetch the booking.
---
## 7. The stage machine
### 7.1 Nine stages
The wire format is **lowercase snake_case, verbatim**. Your client rolls these into its seven
milestones and falls back to `booked` on an unknown key — **silently**. A new stage added without an
app release therefore makes a parcel look un-started. Never rename; coordinate.
| # | `stage` | Means | Set by |
|---|---|---|---|
| 0 | `booked` | Pickup requested, nobody assigned | booking create |
| 1 | `assigned` | A rider was allotted the job | assignment |
| 2 | `on_the_way` | Rider heading over — distance/ETA live | rider taps Accept |
| 3 | `arrived` | Rider at the door — **last cancellable stage** | rider taps Reached |
| 4 | `picked_up` | Weighed, photographed, price settled | pickup-complete |
| 5 | `order_created` | One tracking number minted per destination | pickup-complete |
| 6 | `in_transit` | In the Doormile network — **per order** | hub inward / tripsheet |
| 7 | `out_for_delivery` | Delivery agent carrying it | start-delivery |
| 8 | `delivered` | Handed over | deliver |
### 7.2 `status` — three values
| Value | When |
|---|---|
| `active` | stages 0–7 |
| `completed` | stage reaches `delivered` |
| `cancelled` | customer, ops, or rider stand-down. **Terminal** — late rider telemetry cannot resurrect it |
### 7.3 Which leg is which
```
customer's door --(1)--> HUB --(2)--> recipient's door
| |
order_created out_for_delivery
+- in_transit -+
```
`in_transit` is the **middle** leg — the only stage where no rider is holding the parcel. The
first-mile ride (customer to hub) is still `order_created`; the last mile is `out_for_delivery`.
### 7.4 Four behaviours that will look like bugs and are not
**A hyperlocal booking never shows `in_transit`.** Same postal area means no hub leg — the same rider
carries it door to door. The stage jumps `order_created` to `out_for_delivery`. Your milestone UI
must tolerate a skipped rung.
**Stages 6–8 take the *slowest* destination.** Three parcels, one still at a hub, and the booking
stays `in_transit`. Deliberate: showing "Delivered" while a parcel is in Kerala is worse than being
pessimistic. Per-destination progress is on `destinations[].stage` — use that for the per-parcel rows.
**`booked` can be reached *backwards*.** If a rider cancels or skips, the pickup is not cancelled —
it returns to the pool, `stage` walks back to `booked`, `miler` goes null. Your tracking screen must
handle a rider card disappearing. History entries for `assigned` and `on_the_way` stay, because those
things did happen.
**`in_transit` currently fires before the parcel reaches the hub.** With `MILER_HUB_HANDOVER_ENABLED`
off (the default, and it is unset everywhere), pickup-complete stamps `Inwarded_at_Hub` immediately.
The customer sees "In transit" while the rider is still standing at their door. Known — the backend
comment says so in as many words. Do not build UI that assumes the parcel is physically at a hub.
### 7.5 Cancellation window
`cancellable` is on the booking — **use it, do not compute it.** It closes after `arrived`
(rank <= 3). The server re-checks on the cancel call regardless; the flag is a hint for hiding the
button, never the authority. Expect a `409` if the stage moved between render and tap, and handle it
by refreshing rather than erroring.
### 7.6 `history`
Append-only, one entry per stage **actually reached**, with the real time and the real actor
(`customer` · `miler` · `ops` · `system`). **Nothing is backfilled.** A booking that predates this
surface has a short history — a short honest history beats a long invented one, because the customer
cannot tell which entries were guessed. Render what you get; do not pad it.
A cancellation rides the event log with `remarks` rather than as a stage, because `cancelled` is not
one of the nine. Read `status` and `cancelReason` for the display.
---
## 8. Rules the app must implement
1. **Idempotency keys on `POST /bookings` and `POST /auth/otp/verify`.** Mint a UUID when the user
taps, reuse it across retries, discard it on success. A duplicate pickup is unacceptable.
2. **Keyset pagination.** Pass `nextCursor` back; never construct offsets.
3. **Refresh once on 401, then sign out.** The throttle is shared across all credential endpoints.
4. **Fetch `/config/booking-limits` when the pickup point moves.** Caps vary by city and are
deliberately not hardcoded anywhere in the UI.
5. **A failed fare estimate must not block the booking.** Let them proceed on the last good quote.
6. **Never cache signed photo URLs.** 30-minute expiry.
7. **Render `message` verbatim** on any failure. It is written to be customer-safe.
8. **Poll the detail endpoint while tracking is open.** It is the canonical read and is built for it
— the whole bundle loads in batch specifically so a poll is not six queries.
---
## 9. Backend quirks worth knowing
| Quirk | Impact on you |
|---|---|
| `Arrived_At_Pickup` is a declared status that is **never written** | Arrival is stored as a fact (`arrivedat` plus GPS), not a status. You get it via `stage: "arrived"`. Do not look for the status string. |
| `Picked_Up` is **transient** | Written and overwritten to `Converted_To_Consignment` inside the same transaction. You will effectively never observe it. |
| Two response envelopes exist in this backend | `/customer/*` uses `CxOK`/`CxFail` (nested `error.code`). `/miler/*` and `/admin/*` use `OK`/`Fail` (top-level code). Never copy parsing code across. |
| `/miler/verify-pin` returns its payload **outside** the envelope | A known inconsistency that cost the miler client a release. It is not repeated on your surface — every `/customer/*` response, auth included, puts its payload in `data`. |
| `ENV` is currently **not** `production` on `api.doormile.com` | Confirmed via a CORS probe: an `OPTIONS` from an un-allowlisted origin was echoed back. This means `CX_STAGING_OTP` *would* work there — and also that the production safety guard is inert. Flag for ops. |
---
## 10. Build flags
```bash
# Real backend, real login - what a release build does
flutter run --dart-define=DM_ENV=prod
```
```bash
# Skip the login SCREEN, keep the login REAL <-- use this for day-to-day dev
flutter run --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234
```
```bash
# Paste a real token you already hold
flutter run --dart-define=DM_DEV_TOKEN=eyJ... --dart-define=DM_DEV_REFRESH_TOKEN=abc...
```
```bash
# Offline fake - UI work only. NOTHING reaches a server.
flutter run --dart-define=DM_MOCK=true
```
| Flag | Default | Effect |
|---|---|---|
| `DM_ENV` | `staging` | `prod` · `staging` · `dev` |
| `DM_API_BASE` | — | Overrides the per-environment base URL |
| `DM_MOCK` | `false` | Offline fake API. **Never reaches a server.** |
| `DM_DEV_LOGIN` | `true` | With `DM_MOCK`, opens straight on Home |
| `DM_LOGIN_AS` / `DM_LOGIN_CODE` | — | Real auto sign-in against the real API |
| `DM_DEV_TOKEN` | — | A real server-issued access token |
| `DM_ALLOW_STAGE_OVERRIDE` | `false` | Offers the QA stage stepper (server must also allow it) |
Every one of these is guarded by `!kReleaseMode`. **No define can put any of them in a release
build**, and each is named on the Account screen whenever it is on.
Backend-side flags that change what you observe:
| Var | Default | Effect on the app |
|---|---|---|
| `CX_STAGING_OTP` | unset | A fixed code that always verifies. Ignored when `ENV=production`. |
| `CX_ALLOW_STAGE_OVERRIDE` | unset | Enables `POST /ops/bookings/:ref/stage` |
| `SMS_GATEWAY_URL` | unset | Unset means codes go to the log and no text is sent |
| `MILER_HUB_HANDOVER_ENABLED` | unset | Off means `in_transit` fires at pickup, not at the hub |
| `MILER_COLLECTED_STATE_ENABLED` | unset | Off means hyperlocal goes straight to `out_for_delivery` |
---
## 11. Pre-release checklist
- [ ] `DM_MOCK` is off and the Account screen shows a real base URL, not `DEV DATA (offline)`
- [ ] A booking made on the build appears in the admin console within 15 seconds
- [ ] Idempotency keys sent on `POST /bookings` and `POST /auth/otp/verify`
- [ ] Resend button has a real 30-second countdown
- [ ] A 401 triggers exactly **one** refresh, then sign-out
- [ ] Pagination uses `nextCursor`, never an offset
- [ ] Tracking UI survives a skipped `in_transit` (hyperlocal)
- [ ] Tracking UI survives the rider card disappearing (release back to `booked`)
- [ ] An unknown `stage` value does not crash — falls back to `booked` and logs
- [ ] Cancel button driven by `cancellable`, and a `409` refreshes rather than errors
- [ ] Photo URLs re-fetched, not cached
- [ ] Debug stage stepper removed (the server's QA endpoint replaces it)
- [ ] `X-Client` / `X-Platform` dropped if you ship a web target
---
## 12. Where the source is
| Topic | File |
|---|---|
| Routes and middleware wiring | `routes/routes.go:105-171` |
| Response envelope | `utils/response_cx.go` |
| Auth, OTP, sessions | `controllers/cxAuthController.go` |
| Booking create / list / detail / cancel | `controllers/cxBookingController.go` |
| **The booking JSON shape** | `controllers/cxBookingView.go` |
| Stage machine and timeline | `internal/cxstage/stage.go` |
| Stage and status constants | `constants/constants.go:190-230` |
| Consignment to stage mapping | `controllers/cxConsignmentHooks.go` |
| Fare | `controllers/cxFareController.go` |
| Serviceability, slots, limits | `controllers/cxCatalogueController.go` |
| City gate | `middlewares/city_gate.go` |
| Idempotency | `middlewares/idempotency.go` |
| SMS gateway | `internal/sms/` |
Full contract and design rationale: `docs/customer-app-api.md`
Terse endpoint list: `docs/customer-app-api-crisp.md`
Machine-readable: `docs/openapi-customer.yaml`

View File

@@ -0,0 +1,293 @@
# Reverse logistics (Return to sender): rider app integration
This is the backend contract the Miler (rider) app needs in order to support
**returns**, also called RTO ("return to origin"). It lists what the server
sends, what the app should show, the one new endpoint, and the order to roll it
out in.
Base URL `https://api.doormile.com/api/v1`. Every endpoint here uses the normal
rider login (`Authorization: Bearer <miler token>`).
> **Summary for the app team.** A parcel can now be sent back to its sender
> instead of being delivered. While that is happening, the parcel's
> **consignment** status is `RTO_Initiated`, but the **booking** status does not
> change. Today the app decides what to show from the booking status, so a
> parcel being returned still looks deliverable. Tapping Deliver or Skip then
> returns a 400. The app must read the per-parcel fields below and show a
> "Return to sender" stop instead.
---
## 1. What happens to a parcel
```
Collected_By_Miler / Out_for_Delivery
│ ops press "Return to sender" in the console,
│ or automatically after 3 failed delivery attempts (skips)
▼
RTO_Initiated ──── ops "Re-attempt delivery" ───▶ back to Out_for_Delivery
│ (or wherever it was)
│ rider hands it back to the sender ← NEW: POST …/return-complete
│ (or ops press "Mark returned")
▼
Returned_to_Sender (final; the rider's job on it is closed)
```
- **Where it goes:** back to the **sender**, which is the parcel's pickup
point. The server sends the coordinates (`return_to`, see §3). The app never
chooses the destination.
- **Who starts it:** ops, from the console, or the server automatically on the
3rd failed attempt. The rider never starts a return.
- **Who finishes it:** the rider, through the new endpoint once the feature
flag is on (§6), or ops from the console.
### Wire values (spell exactly like this)
| Thing | Value |
|---|---|
| Consignment status while returning | `RTO_Initiated` |
| Consignment status once returned | `Returned_to_Sender` |
| `next_action` while returning | `return_to_sender` (only when the flag is on, §6) |
| `next_action` once returned | `none` |
| `return_to.type` | `sender` |
| Push `data.type` | `rto` |
---
## 2. The feature flag (server side)
`MILER_RTO_FLOW_ENABLED` is an environment variable on the server, **off by
default**. It is read on every request.
| | flag **off** (today) | flag **on** (after your release) |
|---|---|---|
| `next_action` for a parcel being returned | `none` | `return_to_sender` |
| `can_return` | `false` | `true` |
| `POST /miler/consignments/:id/return-complete` | `403 RTO_FLOW_DISABLED` | works |
| Who closes the return | ops, in the console | the rider (or ops) |
`returning`, `return_reason` and `return_to` are sent **in both states**, so the
app can show the return as soon as your build ships. That is before the flag
goes on.
---
## 3. Reading a parcel: `GET /miler/consignments/:consignmentid`
Unchanged fields stay as they were. **Four fields are new** and are additive,
so older builds ignore them.
| Field | Type | Meaning |
|---|---|---|
| `returning` | bool | `true` while the parcel is being returned (`status == "RTO_Initiated"`). |
| `can_return` | bool | `true` when the rider may finish the return in the app (returning **and** flag on). Show the "Returned to sender" button only when this is `true`. |
| `return_reason` | string | Why it is being returned, e.g. `"Receiver refused: gate locked"`. `""` when not returning. |
| `return_to` | object \| null | Where to take it. `null` when not returning. |
`return_to`:
```json
{ "type": "sender", "latitude": 11.0168, "longitude": 76.9558, "pincode": "641001" }
```
Example response for a parcel being returned (flag on):
```json
{
"success": true,
"data": {
"consignmentid": 9103,
"trackingno": "DMX09103",
"status": "RTO_Initiated",
"attemptcount": 0,
"next_action": "return_to_sender",
"returning": true,
"can_return": true,
"return_reason": "Address not found: No such door number",
"return_to": { "type": "sender", "latitude": 11.0168, "longitude": 76.9558, "pincode": "641001" },
"can_deliver": false,
"can_skip": false,
"can_start_delivery": false,
"can_inward_at_hub": false,
"delivered": false,
"out_for_delivery": false,
"collected": false,
"paymentmode": "",
"codamount": 0,
"codcollected": 0,
"next_hub": null,
"inwardedat": null
}
}
```
With the flag **off**, the same parcel has `"next_action": "none"` and
`"can_return": false`. Everything else is the same.
Note that `can_deliver`, `can_skip` and `can_start_delivery` are all `false`
while returning. If the app already uses these flags to enable its buttons, the
buttons disable themselves correctly.
---
## 4. The queue: `GET /miler/bookings`
Each stop already carries `consignmentid`, `consignmentstatus`, `trackingno`,
`next_action` and `next_hub`. Nothing new is added here. A stop being returned
shows:
- `consignmentstatus: "RTO_Initiated"`
- `next_action: "return_to_sender"` (flag on) or `"none"` (flag off)
- the booking `status` **unchanged** (e.g. `Converted_To_Consignment`)
**This is the important app change:** `ApiConfig.legacyStatusFromNew` maps
`Converted_To_Consignment` to `picked`. A parcel being returned therefore
renders as a normal delivery today. Before mapping a stop to a delivery card,
check `consignmentstatus` / `next_action`:
| `consignmentstatus` | `next_action` | Show |
|---|---|---|
| `RTO_Initiated` | `return_to_sender` | **Return to sender** card: navigate to `return_to`, "Returned to sender" button |
| `RTO_Initiated` | `none` | **Being returned**: no Deliver/Skip buttons; text such as "Return to sender. Ops will close this." |
| `Returned_to_Sender` | `none` | Done: move to history like a delivered stop |
To get `return_to` and `return_reason` for a stop, read
`GET /miler/consignments/:consignmentid`. A push (below) is also a good moment
to refresh.
---
## 5. Finishing a return: `POST /miler/consignments/:id/return-complete` (NEW)
The rider has handed the parcel back to the sender.
Headers: `Authorization: Bearer <token>`, `Content-Type: application/json`, and
**`Idempotency-Key: <uuid>`** (recommended; see below).
Request:
```json
{
"lat": 11.0168,
"lon": 76.9558,
"receivedby": "Acme kitchen manager",
"photourl": "https://…/proof.jpg"
}
```
| Field | Required | Notes |
|---|---|---|
| `lat`, `lon` | send them | Rider's position at handover. Stored in the parcel history. |
| `receivedby` | optional | Who at the sender took it back. |
| `photourl` | optional | Proof photo. Upload it the same way as delivery proof (`POST /miler/uploads/sign`, then use the URL). |
Success `200`:
```json
{ "success": true, "data": { "consignmentid": 9103, "status": "Returned_to_Sender", "next_action": "none" } }
```
What the server does on success:
- parcel → `Returned_to_Sender`, with the return time stored;
- a history row: `Returned to sender by rider at (lat, lon), received by …, photo …`;
- the rider's assignment on that booking → `Completed`.
The rider's job on that parcel is then closed, so it no longer blocks
**End duty**.
Errors. Codes follow the existing miler format,
`{"success": false, "code": "...", "message": "..."}`. Plain 400/404/500
responses have no `code` and only carry `message`.
| HTTP | `code` | When | App should |
|---|---|---|---|
| 403 | `RTO_FLOW_DISABLED` | The server flag is off | Hide the button. This should not happen if you check `can_return`. |
| 400 | `INVALID_STATE` | Parcel is not being returned (e.g. ops re-attempted it, or it was delivered) | Refresh the parcel and show its new state |
| 404 | — (`message: "consignment not found"`) | No such parcel | Refresh the queue |
| 404 | `CONSIGNMENT_NOT_ASSIGNED` | Parcel isn't on this rider's bookings | Refresh the queue |
| 400 | — | Bad id or bad JSON body | Bug in the app |
| 500 | — | Server error, nothing was saved | Retry with the **same** `Idempotency-Key` |
**Retries are safe.** With the same `Idempotency-Key`, a successful response is
replayed for 24 h. Even without the key, a second call on a parcel that is
already `Returned_to_Sender` returns `200` and changes nothing.
---
## 6. Skip responses change on the 3rd attempt
`POST /miler/consignments/:id/skip` is unchanged in what it accepts. The
response `status` can now be **`RTO_Initiated`**:
```json
{ "success": true, "data": { "consignmentid": 9102, "attemptcount": 3, "status": "RTO_Initiated" } }
```
That is the server starting the return automatically, by default on the 3rd
failed attempt (server setting `RTO_AUTO_AFTER_ATTEMPTS`; ops may set it to `0`
to turn this off). After a skip, use the returned `status`. If it is
`RTO_Initiated`, switch the stop to the return card (§4) instead of keeping it
as a delivery to retry.
Also new: skip now works for the 2nd, 3rd … orders of a multi-drop pickup. It
used to answer `CONSIGNMENT_NOT_ASSIGNED` for every order after the first.
---
## 7. Push notification
When ops start a return (or it starts automatically), the rider **holding the
parcel** gets:
| | |
|---|---|
| title | `Return parcel to sender` |
| body | `Parcel DMX09103 is being returned to the sender. Do not attempt delivery.` |
| data | `{ "type": "rto", "consignmentid": "9103" }` (both strings) |
On `type == "rto"`: refresh that consignment (§3) and the queue (§4). A rider
who already handed the parcel over at a base is **not** notified.
---
## 8. Rollout order
1. **Backend deployed** with the flag off. Ops start and close returns from the
console; riders get the push. Until step 3, a return can leave a rider
unable to end duty until ops press "Mark returned". To avoid that, ops may
deploy with `RTO_AUTO_AFTER_ATTEMPTS=0`.
2. **App release**: §4 (card per `consignmentstatus` / `next_action`), §3
fields, §5 button behind `can_return`, §6 skip handling, §7 push.
3. **Flag on** (`MILER_RTO_FLOW_ENABLED=true`) once most riders have the new
build. Riders finish their own returns, and automatic returns can be turned
on.
Old builds keep working at every step. The new fields are additive, and with
the flag off nothing new is required from the app.
---
## 9. App test checklist
Use a staging backend with `MILER_RTO_FLOW_ENABLED=true`.
- [ ] Ops start a return on a parcel the rider is carrying → push arrives →
the stop turns into a **Return to sender** card with the reason; Deliver
and Skip are gone.
- [ ] Navigation goes to `return_to` (the sender), not to the receiver.
- [ ] "Returned to sender" (with photo and receiver name) → `200` → the stop
moves to history → **End duty** works.
- [ ] Tap it twice or with no network, then retry → no error, one return.
- [ ] Skip the same parcel 3 times → the 3rd response has
`status: RTO_Initiated` → the card switches to return.
- [ ] Ops press "Re-attempt delivery" while the card is open → button tap gets
`400 INVALID_STATE` → the app refreshes and shows the delivery again.
- [ ] Flag **off**: the card shows "Being returned", with no button and no 403
shown to the rider.
- [ ] Multi-drop pickup: skip and return work on the 2nd and 3rd order.
---
*Server code: `controllers/consignmentReturn.go`. The full plan, decisions and
test record are in `krow_talent_app/docs/reverse-logistics-plan.md`.*

View File

@@ -0,0 +1,238 @@
package playground
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// OpenAICompat drives the playground through any OpenAI-compatible chat
// completions API — Groq by default (https://api.groq.com/openai/v1), or xAI,
// or another provider — with plain net/http, so no SDK dependency is needed.
//
// Configured from PLAYGROUND_LLM_BASE_URL / _API_KEY / _MODEL (see config).
// The model comes from that setting, not from the agent's registry pin: the
// registry holds Claude ids for AI_engine, which this provider cannot serve.
type OpenAICompat struct {
BaseURL string
APIKey string
Model string
HTTP *http.Client
}
// NewOpenAICompat returns a client with a bounded HTTP timeout.
func NewOpenAICompat(baseURL, apiKey, model string) *OpenAICompat {
return &OpenAICompat{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Model: model,
HTTP: &http.Client{Timeout: 90 * time.Second},
}
}
// ModelName is what the trace reports as the model that answered.
func (o *OpenAICompat) ModelName() string { return o.Model }
// ── Wire types (OpenAI chat completions) ────────────────────────────────────
type oaFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type oaToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function oaFunctionCall `json:"function"`
}
type oaMessage struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls []oaToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type oaTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type oaRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Tools []oaTool `json:"tools,omitempty"`
MaxCompletionTokens int64 `json:"max_completion_tokens,omitempty"`
}
type oaResponse struct {
Choices []struct {
Message oaMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func strp(s string) *string { return &s }
// objectSchema makes sure a tool's parameters are an object schema with a
// properties map — OpenAI-style APIs reject `{"type":"object"}` alone on some
// models, and several registry tools have open schemas.
func objectSchema(raw json.RawMessage) json.RawMessage {
var m map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &m) != nil || m == nil {
m = map[string]any{}
}
m["type"] = "object"
if _, ok := m["properties"]; !ok {
m["properties"] = map[string]any{}
}
b, _ := json.Marshal(m)
return b
}
// toWire converts the playground conversation into chat-completions messages.
func toWire(req Request) oaRequest {
out := oaRequest{MaxCompletionTokens: req.MaxTokens}
if req.System != "" {
out.Messages = append(out.Messages, oaMessage{Role: "system", Content: strp(req.System)})
}
for _, t := range req.Turns {
switch {
case t.Role == "assistant":
msg := oaMessage{Role: "assistant"}
var texts []string
for _, b := range t.Assistant {
switch {
case b.Type == "text" && b.Text != "":
texts = append(texts, b.Text)
case b.Type == "tool_use" && b.ToolUse != nil:
args := string(b.ToolUse.Input)
if args == "" {
args = "{}"
}
msg.ToolCalls = append(msg.ToolCalls, oaToolCall{
ID: b.ToolUse.ID, Type: "function",
Function: oaFunctionCall{Name: b.ToolUse.Name, Arguments: args},
})
}
}
if len(texts) > 0 {
msg.Content = strp(strings.Join(texts, "\n\n"))
}
out.Messages = append(out.Messages, msg)
case len(t.Results) > 0:
for _, r := range t.Results {
out.Messages = append(out.Messages, oaMessage{Role: "tool", ToolCallID: r.ToolUseID, Content: strp(r.Content)})
}
default:
out.Messages = append(out.Messages, oaMessage{Role: "user", Content: strp(t.Text)})
}
}
for _, td := range req.Tools {
var tool oaTool
tool.Type = "function"
tool.Function.Name = td.Name
tool.Function.Description = td.Description
tool.Function.Parameters = objectSchema(td.InputSchema)
out.Tools = append(out.Tools, tool)
}
return out
}
// fromWire converts one chat-completions response into a playground Reply.
func fromWire(resp oaResponse) (Reply, error) {
if len(resp.Choices) == 0 {
return Reply{}, errors.New("model returned no choices")
}
ch := resp.Choices[0]
r := Reply{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens}
if ch.Message.Content != nil && strings.TrimSpace(*ch.Message.Content) != "" {
r.Blocks = append(r.Blocks, Block{Type: "text", Text: *ch.Message.Content})
}
for _, tc := range ch.Message.ToolCalls {
input := json.RawMessage(tc.Function.Arguments)
if !json.Valid(input) {
input = json.RawMessage(`{}`)
}
r.Blocks = append(r.Blocks, Block{Type: "tool_use", ToolUse: &ToolUse{ID: tc.ID, Name: tc.Function.Name, Input: input}})
}
switch ch.FinishReason {
case "tool_calls":
r.StopReason = "tool_use"
case "length":
r.StopReason = "max_tokens"
default:
r.StopReason = "end_turn"
}
// Some providers report "stop" while still returning tool calls; the calls win.
if len(ch.Message.ToolCalls) > 0 {
r.StopReason = "tool_use"
}
return r, nil
}
// Next performs one chat-completions call.
func (o *OpenAICompat) Next(ctx context.Context, req Request) (Reply, error) {
body := toWire(req)
body.Model = o.Model
payload, err := json.Marshal(body)
if err != nil {
return Reply{}, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, o.BaseURL+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return Reply{}, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+o.APIKey)
httpReq.Header.Set("Content-Type", "application/json")
res, err := o.HTTP.Do(httpReq)
if err != nil {
return Reply{}, fmt.Errorf("model request: %w", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
var out oaResponse
_ = json.Unmarshal(raw, &out)
if res.StatusCode/100 != 2 {
msg := strings.TrimSpace(string(raw))
if out.Error != nil && out.Error.Message != "" {
msg = out.Error.Message
}
if len(msg) > 300 {
msg = msg[:300]
}
return Reply{}, &ProviderError{Status: res.StatusCode, Message: msg}
}
return fromWire(out)
}
// ProviderError is a non-2xx answer from the model provider. The status lets
// the controller tell "rate limited" (429, common on free tiers) from a
// genuine failure. The message never contains the API key.
type ProviderError struct {
Status int
Message string
}
func (e *ProviderError) Error() string {
return fmt.Sprintf("model provider answered %d: %s", e.Status, e.Message)
}

View File

@@ -0,0 +1,130 @@
package playground
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A fake OpenAI-compatible server: first call asks for a tool, second answers.
func fakeProvider(t *testing.T, seen *[]map[string]any) *httptest.Server {
t.Helper()
calls := 0
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openai/v1/chat/completions" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("auth header = %q", r.Header.Get("Authorization"))
}
b, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(b, &body)
*seen = append(*seen, body)
calls++
w.Header().Set("Content-Type", "application/json")
if calls == 1 {
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"get_booking_cache","arguments":"{\"booking_id\":5}"}},
{"id":"call_2","type":"function","function":{"name":"reassign_booking","arguments":"not json"}}]},
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`)
return
}
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"Proposed a reassign."},"finish_reason":"stop"}],
"usage":{"prompt_tokens":150,"completion_tokens":10}}`)
}))
}
func TestOpenAICompatRunsTheToolLoop(t *testing.T) {
var seen []map[string]any
srv := fakeProvider(t, &seen)
defer srv.Close()
m := NewOpenAICompat(srv.URL+"/openai/v1/", "test-key", "openai/gpt-oss-120b")
plan := mustPlan(t, "EXCEPTION_AGENT", "stall_response")
plan.Model = m.ModelName()
execs := map[string]Executor{
"get_booking_cache": func(context.Context, json.RawMessage) (any, error) {
return map[string]any{"bookingid": 5, "customername": "Ravi"}, nil
},
}
tr, err := Run(context.Background(), m, plan, "booking 5 is stuck", execs)
if err != nil {
t.Fatal(err)
}
if tr.Final != "Proposed a reassign." || tr.Turns != 2 || tr.Inputtokens != 250 || tr.Outputtokens != 30 {
t.Fatalf("trace = %+v", tr)
}
if tr.Model != "openai/gpt-oss-120b" {
t.Fatalf("model = %s", tr.Model)
}
if tr.Steps[0].Outcome != OutcomeExecuted || tr.Steps[1].Outcome != OutcomeProposed || string(tr.Steps[1].Input) != "{}" {
t.Fatalf("steps = %+v", tr.Steps)
}
// First request: system + user, the model from config, tools as functions
// with object schemas.
first := seen[0]
if first["model"] != "openai/gpt-oss-120b" || first["max_completion_tokens"].(float64) != MaxTokens {
t.Fatalf("first request = %v", first)
}
msgs := first["messages"].([]any)
if msgs[0].(map[string]any)["role"] != "system" || msgs[1].(map[string]any)["role"] != "user" {
t.Fatalf("messages = %v", msgs)
}
tool := first["tools"].([]any)[0].(map[string]any)
params := tool["function"].(map[string]any)["parameters"].(map[string]any)
if tool["type"] != "function" || params["type"] != "object" || params["properties"] == nil {
t.Fatalf("tool = %v", tool)
}
// Second request: the assistant's tool_calls echoed, then one tool message
// per call, redacted.
msgs = seen[1]["messages"].([]any)
asst := msgs[2].(map[string]any)
if asst["role"] != "assistant" || len(asst["tool_calls"].([]any)) != 2 {
t.Fatalf("assistant echo = %v", asst)
}
res1, res2 := msgs[3].(map[string]any), msgs[4].(map[string]any)
if res1["role"] != "tool" || res1["tool_call_id"] != "call_1" || res2["tool_call_id"] != "call_2" {
t.Fatalf("tool results = %v %v", res1, res2)
}
if strings.Contains(res1["content"].(string), "Ravi") {
t.Fatal("personal data reached the provider")
}
if !strings.Contains(res2["content"].(string), `"executed":false`) {
t.Fatalf("write tool was not answered as a proposal: %v", res2["content"])
}
}
func TestOpenAICompatReportsProviderErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, `{"error":{"message":"Rate limit reached for model"}}`)
}))
defer srv.Close()
_, err := NewOpenAICompat(srv.URL, "secret-key-value", "m").Next(context.Background(), Request{Turns: []Turn{{Role: "user", Text: "hi"}}})
var pe *ProviderError
if !errors.As(err, &pe) || pe.Status != 429 || !strings.Contains(pe.Message, "Rate limit") {
t.Fatalf("err = %v", err)
}
if strings.Contains(err.Error(), "secret-key-value") {
t.Fatal("the API key leaked into the error")
}
}
func TestOpenAICompatStopWithToolCallsStillLoops(t *testing.T) {
r, err := fromWire(oaResponse{Choices: []struct {
Message oaMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}{{Message: oaMessage{ToolCalls: []oaToolCall{{ID: "c", Function: oaFunctionCall{Name: "x", Arguments: "{}"}}}}, FinishReason: "stop"}}})
if err != nil || r.StopReason != "tool_use" {
t.Fatalf("reply = %+v, err = %v", r, err)
}
}

View File

@@ -0,0 +1,341 @@
// Package playground runs one operator prompt through Claude with a registry
// skill's tools, for Agent Studio's Test tab (Phase 6 of
// krow_talent_app/docs/agent-platform-plan.md).
//
// The rules that make it safe to point at production:
// - read tools the backend can serve run for real, and their results are
// redacted (names, phones, addresses, emails, free text) before Claude sees
// them — see redact.go;
// - write, notify and event tools NEVER run: the call is answered with a
// proposal and shown in the trace as "proposed";
// - a tool outside the selected skill is refused;
// - the loop is bounded (MaxTurns) and so is every tool call (ToolTimeout).
//
// Claude is reached through the Model interface, so the loop is tested with a
// fake and the server wires in a real client only when one is configured.
package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"doormile/internal/ai/registry"
)
// DefaultModel is used when the agent has no model pinned in the registry.
const DefaultModel = "claude-opus-5-5"
const (
MaxTurns = 6
MaxTokens = 4096
MaxPromptChars = 2000
ToolTimeout = 5 * time.Second
maxResultBytes = 16 * 1024
)
// Tool kinds, as the registry stores them.
const (
kindRead = "read"
)
// Outcomes of a tool call, as the trace shows them.
const (
OutcomeExecuted = "executed"
OutcomeProposed = "proposed"
OutcomeUnavailable = "unavailable"
OutcomeError = "error"
OutcomeRejected = "rejected"
)
// ── The model boundary ──────────────────────────────────────────────────────
// ToolUse is a tool call Claude asked for.
type ToolUse struct {
ID string
Name string
Input json.RawMessage
}
// Block is one content block of a reply. Text and tool_use are read by the
// loop; anything else (thinking) is carried in Raw and sent back unchanged,
// which the API requires within a tool-use turn.
type Block struct {
Type string
Text string
ToolUse *ToolUse
Raw json.RawMessage
}
// Reply is one model response.
type Reply struct {
Blocks []Block
StopReason string
InputTokens int64
OutputTokens int64
}
// ToolResult answers one ToolUse.
type ToolResult struct {
ToolUseID string
Content string
IsError bool
}
// Turn is one message of the conversation: the user's prompt, an assistant
// reply, or the user turn carrying tool results.
type Turn struct {
Role string // "user" or "assistant"
Text string
Assistant []Block
Results []ToolResult
}
// ToolDef is a tool as offered to the model.
type ToolDef struct {
Name string
Description string
InputSchema json.RawMessage
}
// Request is one model call.
type Request struct {
Model string
System string
MaxTokens int64
Tools []ToolDef
Turns []Turn
}
// Model is the one call the loop needs from Claude.
type Model interface {
Next(ctx context.Context, req Request) (Reply, error)
}
// ── Plan: what a run may use ────────────────────────────────────────────────
// ErrNotFound is returned when the agent or skill does not exist.
var ErrNotFound = errors.New("not found")
// Plan is the resolved agent, skill and tools for one run.
type Plan struct {
AgentID string
AgentName string
SkillID string
Model string
System string
Tools []ToolDef
kinds map[string]string
}
// Prepare resolves a run from the registry. skillID may be empty: the run then
// gets every tool of the agent's enabled skills.
func Prepare(snap *registry.Snapshot, agentID, skillID string) (*Plan, error) {
var agent *registry.AgentView
for i := range snap.Agents {
if snap.Agents[i].Agentid == agentID {
agent = &snap.Agents[i]
}
}
if agent == nil {
return nil, fmt.Errorf("agent %q: %w", agentID, ErrNotFound)
}
toolNames := map[string]bool{}
var skillLines []string
found := skillID == ""
for _, s := range snap.Skills {
if s.Agentid != agentID {
continue
}
if skillID != "" && s.Skillid != skillID {
continue
}
if skillID == "" && !s.Enabled {
continue
}
found = true
skillLines = append(skillLines, fmt.Sprintf("- %s: %s", s.Title, s.Description))
for _, t := range s.Tools {
toolNames[t] = true
}
}
if !found {
return nil, fmt.Errorf("skill %q of agent %q: %w", skillID, agentID, ErrNotFound)
}
plan := &Plan{
AgentID: agent.Agentid, AgentName: agent.Name, SkillID: skillID,
Model: agent.Model, kinds: map[string]string{}, Tools: []ToolDef{},
}
if plan.Model == "" {
plan.Model = DefaultModel
}
for _, t := range snap.Tools {
if !toolNames[t.Toolname] {
continue
}
desc := t.Description
if t.Kind != kindRead {
desc += " [Playground: NOT executed — calling it records a proposal for a human.]"
}
plan.Tools = append(plan.Tools, ToolDef{Name: t.Toolname, Description: desc, InputSchema: t.Inputschema})
plan.kinds[t.Toolname] = t.Kind
}
plan.System = strings.Join([]string{
fmt.Sprintf("You are %s, an agent in Doormile's delivery operations, being tested by an operator in the Agent Studio playground.", agent.Name),
"Purpose: " + agent.Purpose,
"Skills in scope:\n" + strings.Join(skillLines, "\n"),
"Read tools return live data with personal details (names, phones, addresses, notes) removed; do not ask for them.",
"Write, notify and event tools are not executed here: calling one records a proposal for a human to review. Say plainly what you would do and why.",
"If a tool is unavailable, say so rather than guessing its result. Keep the final answer short and concrete.",
}, "\n\n")
return plan, nil
}
// ── The run ─────────────────────────────────────────────────────────────────
// Executor runs one read tool. Its result is redacted before the model sees it.
type Executor func(ctx context.Context, input json.RawMessage) (any, error)
// Step is one line of the trace the console shows.
type Step struct {
Kind string `json:"kind"` // "text" or "tool"
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
Toolkind string `json:"toolkind,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Outcome string `json:"outcome,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Ms int64 `json:"ms"`
}
// Trace is the whole run.
type Trace struct {
Agentid string `json:"agentid"`
Skillid string `json:"skillid"`
Model string `json:"model"`
Steps []Step `json:"steps"`
Final string `json:"final"`
Stopreason string `json:"stopreason"`
Turns int `json:"turns"`
Inputtokens int64 `json:"inputtokens"`
Outputtokens int64 `json:"outputtokens"`
Ms int64 `json:"ms"`
}
// Run executes the prompt. A model error ends the run with the error; the
// trace so far is still returned so the console can show how far it got.
func Run(ctx context.Context, m Model, plan *Plan, prompt string, execs map[string]Executor) (*Trace, error) {
started := time.Now()
tr := &Trace{Agentid: plan.AgentID, Skillid: plan.SkillID, Model: plan.Model, Steps: []Step{}}
turns := []Turn{{Role: "user", Text: prompt}}
defer func() { tr.Ms = time.Since(started).Milliseconds() }()
for tr.Turns < MaxTurns {
callStarted := time.Now()
reply, err := m.Next(ctx, Request{
Model: plan.Model, System: plan.System, MaxTokens: MaxTokens, Tools: plan.Tools, Turns: turns,
})
tr.Turns++
if err != nil {
tr.Stopreason = "error"
return tr, err
}
tr.Inputtokens += reply.InputTokens
tr.Outputtokens += reply.OutputTokens
tr.Stopreason = reply.StopReason
modelMs := time.Since(callStarted).Milliseconds()
turns = append(turns, Turn{Role: "assistant", Assistant: reply.Blocks})
var texts []string
var results []ToolResult
for _, b := range reply.Blocks {
switch {
case b.Type == "text" && strings.TrimSpace(b.Text) != "":
texts = append(texts, b.Text)
tr.Steps = append(tr.Steps, Step{Kind: "text", Text: b.Text, Ms: modelMs})
modelMs = 0
case b.Type == "tool_use" && b.ToolUse != nil:
step, res := callTool(ctx, plan, execs, *b.ToolUse)
tr.Steps = append(tr.Steps, step)
results = append(results, res)
}
}
if reply.StopReason != "tool_use" || len(results) == 0 {
tr.Final = strings.Join(texts, "\n\n")
return tr, nil
}
turns = append(turns, Turn{Role: "user", Results: results})
}
tr.Stopreason = "max_turns"
return tr, nil
}
func callTool(ctx context.Context, plan *Plan, execs map[string]Executor, use ToolUse) (Step, ToolResult) {
started := time.Now()
input := use.Input
if len(input) == 0 || !json.Valid(input) {
input = json.RawMessage(`{}`)
}
kind, inSkill := plan.kinds[use.Name]
step := Step{Kind: "tool", Tool: use.Name, Toolkind: kind, Input: input}
res := ToolResult{ToolUseID: use.ID}
finish := func(outcome string, payload any, isError bool) (Step, ToolResult) {
body := encode(payload)
step.Outcome, step.Result, step.Ms = outcome, body, time.Since(started).Milliseconds()
res.Content, res.IsError = string(body), isError
return step, res
}
switch {
case !inSkill:
return finish(OutcomeRejected, map[string]string{"error": "This tool is not part of the selected skill."}, true)
case kind != kindRead:
return finish(OutcomeProposed, map[string]any{
"executed": false,
"proposal": map[string]any{"tool": use.Name, "input": input},
"note": "Playground: recorded as a proposal for a human. Nothing was changed.",
}, false)
case execs[use.Name] == nil:
return finish(OutcomeUnavailable, map[string]string{
"error": "This read tool is not available in the playground (it runs inside AI_engine or calls an external service).",
}, true)
}
tctx, cancel := context.WithTimeout(ctx, ToolTimeout)
defer cancel()
out, err := execs[use.Name](tctx, input)
if err != nil {
return finish(OutcomeError, map[string]string{"error": err.Error()}, true)
}
return finish(OutcomeExecuted, Redact(out), false)
}
// encode marshals a tool result, capped so one large read cannot blow the
// context window or the console.
func encode(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
b, _ = json.Marshal(map[string]string{"error": "result could not be encoded"})
}
if len(b) > maxResultBytes {
b, _ = json.Marshal(map[string]any{
"truncated": true,
"note": fmt.Sprintf("Result was %d bytes; showing the first %d.", len(b), maxResultBytes),
"partial": string(b[:maxResultBytes]),
})
}
return b
}

View File

@@ -0,0 +1,280 @@
package playground
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"doormile/internal/ai/registry"
"doormile/models"
)
// fakeModel replays scripted replies and records every request.
type fakeModel struct {
replies []Reply
err error
reqs []Request
}
func (f *fakeModel) Next(_ context.Context, req Request) (Reply, error) {
f.reqs = append(f.reqs, req)
if f.err != nil {
return Reply{}, f.err
}
if len(f.replies) == 0 {
return Reply{StopReason: "end_turn", Blocks: []Block{{Type: "text", Text: "done"}}}, nil
}
r := f.replies[0]
f.replies = f.replies[1:]
return r, nil
}
func toolCall(id, name, input string) Block {
return Block{Type: "tool_use", ToolUse: &ToolUse{ID: id, Name: name, Input: json.RawMessage(input)}}
}
func testSnapshot() *registry.Snapshot {
agents := []models.AIAgent{
{Agentid: "EXCEPTION_AGENT", Name: "Exception", Purpose: "Handles stalled riders.", Model: "claude-sonnet-5-5"},
{Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Purpose: "Watches the board."},
}
tools := []models.AITool{
{Toolname: "get_booking_cache", Kind: "read", Description: "Read a booking.", Inputschema: `{"type":"object"}`},
{Toolname: "nearby_milers", Kind: "read", Description: "Riders near a point.", Inputschema: `{"type":"object"}`},
{Toolname: "decide_stall_response", Kind: "read", Description: "Engine decision.", Inputschema: `{"type":"object"}`},
{Toolname: "reassign_booking", Kind: "write", Description: "Reassign.", Inputschema: `{"type":"object"}`},
{Toolname: "scan_bookings", Kind: "read", Description: "Scan.", Inputschema: `{"type":"object"}`},
}
skills := []models.AISkill{
{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stall", Description: "Respond to stalls.", Enabled: true},
{Skillid: "off_skill", Agentid: "EXCEPTION_AGENT", Title: "Off", Enabled: false},
{Skillid: "late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late", Enabled: true},
}
links := []models.AISkillTool{
{Skillid: "stall_response", Toolname: "get_booking_cache"},
{Skillid: "stall_response", Toolname: "nearby_milers"},
{Skillid: "stall_response", Toolname: "decide_stall_response"},
{Skillid: "stall_response", Toolname: "reassign_booking"},
{Skillid: "off_skill", Toolname: "scan_bookings"},
{Skillid: "late_dispatch", Toolname: "scan_bookings"},
}
return registry.Build(agents, tools, skills, links)
}
func mustPlan(t *testing.T, agent, skill string) *Plan {
t.Helper()
p, err := Prepare(testSnapshot(), agent, skill)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
return p
}
func toolNames(p *Plan) []string {
var out []string
for _, t := range p.Tools {
out = append(out, t.Name)
}
return out
}
// ── Prepare ─────────────────────────────────────────────────────────────────
func TestPrepareUsesSkillToolsAndPinnedModel(t *testing.T) {
p := mustPlan(t, "EXCEPTION_AGENT", "stall_response")
if p.Model != "claude-sonnet-5-5" {
t.Fatalf("model = %q, want the registry pin", p.Model)
}
got := strings.Join(toolNames(p), ",")
// Registry order (Load sorts by toolname; this fixture is in its own order).
if got != "get_booking_cache,nearby_milers,decide_stall_response,reassign_booking" {
t.Fatalf("tools = %s", got)
}
for _, td := range p.Tools {
marked := strings.Contains(td.Description, "NOT executed")
if (td.Name == "reassign_booking") != marked {
t.Fatalf("%s: write-tool marking = %v", td.Name, marked)
}
}
}
func TestPrepareDefaultsModelAndSkipsDisabledSkillsWhenNoSkillGiven(t *testing.T) {
p := mustPlan(t, "CONSOLE_OPS_AGENT", "")
if p.Model != DefaultModel {
t.Fatalf("model = %q, want %q", p.Model, DefaultModel)
}
p = mustPlan(t, "EXCEPTION_AGENT", "")
for _, n := range toolNames(p) {
if n == "scan_bookings" {
t.Fatal("a disabled skill's tool was offered")
}
}
}
func TestPrepareNotFound(t *testing.T) {
for _, c := range [][2]string{{"NOPE", ""}, {"EXCEPTION_AGENT", "late_dispatch"}, {"EXCEPTION_AGENT", "missing"}} {
if _, err := Prepare(testSnapshot(), c[0], c[1]); !errors.Is(err, ErrNotFound) {
t.Fatalf("%v: err = %v, want ErrNotFound", c, err)
}
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
func TestRunTextOnly(t *testing.T) {
m := &fakeModel{replies: []Reply{{StopReason: "end_turn", InputTokens: 10, OutputTokens: 5,
Blocks: []Block{{Type: "thinking", Raw: json.RawMessage(`{"type":"thinking"}`)}, {Type: "text", Text: "All clear."}}}}}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "status?", nil)
if err != nil {
t.Fatal(err)
}
if tr.Final != "All clear." || tr.Turns != 1 || tr.Inputtokens != 10 || tr.Outputtokens != 5 || tr.Model != "claude-sonnet-5-5" {
t.Fatalf("trace = %+v", tr)
}
if len(tr.Steps) != 1 || tr.Steps[0].Kind != "text" {
t.Fatalf("steps = %+v", tr.Steps)
}
req := m.reqs[0]
if req.Turns[0].Text != "status?" || req.MaxTokens != MaxTokens || len(req.Tools) != 4 || req.System == "" {
t.Fatalf("request = %+v", req)
}
}
func TestRunToolOutcomes(t *testing.T) {
executed := 0
execs := map[string]Executor{
"get_booking_cache": func(_ context.Context, in json.RawMessage) (any, error) {
executed++
return map[string]any{"bookingid": 5, "customername": "Ravi", "notes": "call 9876543210"}, nil
},
// Present but NOT in the skill — must never run.
"scan_bookings": func(context.Context, json.RawMessage) (any, error) {
t.Fatal("a tool outside the skill was executed")
return nil, nil
},
"nearby_milers": func(context.Context, json.RawMessage) (any, error) { return nil, errors.New("positions unavailable") },
}
m := &fakeModel{replies: []Reply{
{StopReason: "tool_use", Blocks: []Block{
toolCall("t1", "get_booking_cache", `{"booking_id":5}`),
toolCall("t2", "reassign_booking", `{"booking_id":5}`),
toolCall("t3", "scan_bookings", `{}`),
toolCall("t4", "decide_stall_response", `{}`),
toolCall("t5", "nearby_milers", `{"lat":11,"lon":77}`),
}},
{StopReason: "end_turn", Blocks: []Block{{Type: "text", Text: "Proposed a reassign."}}},
}}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "booking 5 is stuck", execs)
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"get_booking_cache": OutcomeExecuted, "reassign_booking": OutcomeProposed, "scan_bookings": OutcomeRejected,
"decide_stall_response": OutcomeUnavailable, "nearby_milers": OutcomeError,
}
for _, s := range tr.Steps {
if s.Kind == "tool" && want[s.Tool] != s.Outcome {
t.Fatalf("%s outcome = %s, want %s", s.Tool, s.Outcome, want[s.Tool])
}
}
if executed != 1 {
t.Fatalf("read tool executed %d times", executed)
}
// The second request carries all five results, in order, and redacted.
results := m.reqs[1].Turns[2].Results
if len(results) != 5 || results[0].ToolUseID != "t1" {
t.Fatalf("results = %+v", results)
}
if strings.Contains(results[0].Content, "Ravi") || strings.Contains(results[0].Content, "9876543210") {
t.Fatalf("personal data reached the model: %s", results[0].Content)
}
if !strings.Contains(results[1].Content, `"executed":false`) || results[1].IsError {
t.Fatalf("write tool result = %+v", results[1])
}
for _, i := range []int{2, 3, 4} {
if !results[i].IsError {
t.Fatalf("result %d should be an error: %+v", i, results[i])
}
}
// The assistant turn (with its tool_use blocks) is echoed back before the results.
if m.reqs[1].Turns[1].Role != "assistant" || len(m.reqs[1].Turns[1].Assistant) != 5 {
t.Fatalf("assistant turn not echoed: %+v", m.reqs[1].Turns[1])
}
if tr.Final != "Proposed a reassign." || tr.Turns != 2 {
t.Fatalf("trace = %+v", tr)
}
}
func TestRunStopsAtMaxTurns(t *testing.T) {
var replies []Reply
for i := 0; i < MaxTurns+2; i++ {
replies = append(replies, Reply{StopReason: "tool_use", Blocks: []Block{toolCall("t", "reassign_booking", `{}`)}})
}
m := &fakeModel{replies: replies}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "loop", nil)
if err != nil {
t.Fatal(err)
}
if tr.Turns != MaxTurns || tr.Stopreason != "max_turns" || len(m.reqs) != MaxTurns {
t.Fatalf("turns = %d, stop = %s, calls = %d", tr.Turns, tr.Stopreason, len(m.reqs))
}
}
func TestRunModelError(t *testing.T) {
tr, err := Run(context.Background(), &fakeModel{err: errors.New("overloaded")}, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "x", nil)
if err == nil || tr == nil || tr.Stopreason != "error" {
t.Fatalf("err = %v, trace = %+v", err, tr)
}
}
func TestInvalidToolInputBecomesEmptyObject(t *testing.T) {
m := &fakeModel{replies: []Reply{{StopReason: "tool_use", Blocks: []Block{toolCall("t", "reassign_booking", `{not json`)}}}}
tr, _ := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "x", nil)
if string(tr.Steps[0].Input) != "{}" {
t.Fatalf("input = %s", tr.Steps[0].Input)
}
}
func TestEncodeCapsLargeResults(t *testing.T) {
b := encode(map[string]string{"blob": strings.Repeat("x", maxResultBytes*2)})
if len(b) > maxResultBytes+1024 || !strings.Contains(string(b), `"truncated":true`) {
t.Fatalf("len = %d", len(b))
}
}
// ── Redact ──────────────────────────────────────────────────────────────────
func TestRedact(t *testing.T) {
in := map[string]any{
"bookingid": 7,
"customerName": "Ravi",
"pickupaddress": "12 MG Road",
"status": "Created",
"createdat_ist": "2026-09-29 12:30",
"deliverycity": "Coimbatore",
"cancelreason": "customer asked",
"nested": []any{map[string]any{"phone": "9876543210", "hub": "call +91 98765 43210 or a@b.co"}},
"missingnote": nil,
}
out := Redact(in).(map[string]any)
for _, k := range []string{"customerName", "pickupaddress", "cancelreason"} {
if out[k] != Redacted {
t.Fatalf("%s = %v, want redacted", k, out[k])
}
}
for k, want := range map[string]any{"status": "Created", "createdat_ist": "2026-09-29 12:30", "deliverycity": "Coimbatore", "bookingid": float64(7)} {
if out[k] != want {
t.Fatalf("%s = %v, want %v (must not be redacted)", k, out[k], want)
}
}
nested := out["nested"].([]any)[0].(map[string]any)
if nested["phone"] != Redacted || strings.ContainsAny(nested["hub"].(string), "@") || strings.Contains(nested["hub"].(string), "98765") {
t.Fatalf("nested = %v", nested)
}
if out["missingnote"] != nil {
t.Fatal("a null personal field should stay null")
}
}

View File

@@ -0,0 +1,78 @@
package playground
import (
"encoding/json"
"regexp"
"strings"
)
// Redacted replaces every value Redact removes.
const Redacted = "[redacted]"
// piiKeys: a field whose name contains one of these is personal or free text
// (free text is where people type phone numbers and addresses).
var piiKeys = []string{
"name", "phone", "mobile", "email", "address", "landmark", "otp", "contact",
"note", "remark", "instruction", "description", "reason", "comment",
}
var (
emailLike = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
// An Indian mobile (10 digits from 6-9, optional +91). Narrow on purpose:
// a looser digit-run pattern also masks dates like "2026-09-29 12".
phoneLike = regexp.MustCompile(`(?:\+?91[\s-]?)?\b[6-9]\d{4}[\s-]?\d{5}\b`)
)
// Redact returns a copy of v, as plain JSON values, with personal data
// removed: values under personal keys are replaced, and any remaining string
// that contains an email or a phone-like number has it masked.
//
// Executors already select only non-personal columns; this is the backstop
// that makes a later column addition safe by default.
func Redact(v any) any {
b, err := json.Marshal(v)
if err != nil {
return nil
}
var generic any
if err := json.Unmarshal(b, &generic); err != nil {
return nil
}
return redactValue(generic)
}
func redactValue(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if isPIIKey(k) && val != nil {
out[k] = Redacted
continue
}
out[k] = redactValue(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = redactValue(val)
}
return out
case string:
s := emailLike.ReplaceAllString(t, Redacted)
return phoneLike.ReplaceAllString(s, Redacted)
default:
return v
}
}
func isPIIKey(k string) bool {
k = strings.ToLower(k)
for _, p := range piiKeys {
if strings.Contains(k, p) {
return true
}
}
return false
}

View File

@@ -0,0 +1,174 @@
package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"doormile/internal/milergeo"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// The read tools the backend serves itself. Every other read tool in the
// registry runs inside AI_engine (decide_*), calls an external service
// (sequence_stops, simulate_pricing_quote) or an engine-only internal route
// (list_express_*), and answers "unavailable" in the playground.
//
// Each executor selects named, non-personal columns only — never addresses,
// names, phones or notes — and Redact runs over the result as a backstop.
// Coordinates are rounded to 2 decimals (about 1 km).
const (
scanDefaultLimit = 20
scanMaxLimit = 50
nearbyMaxKm = 10.0
nearbyMaxCount = 20
)
// bookingRow is the only projection of a booking the playground exposes.
type bookingRow struct {
Bookingid int `json:"bookingid"`
Bookingno string `json:"bookingno"`
Tenantid *int `json:"tenantid"`
Status string `json:"status"`
Pickuppincode string `json:"pickuppincode"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Pickuplatitude float64 `json:"pickuplat"`
Pickuplongitude float64 `json:"pickuplon"`
Deliverylatitude float64 `json:"deliverylat"`
Deliverylongitude float64 `json:"deliverylon"`
Assignedmileruserid *int `json:"assignedmileruserid"`
Routekm *float64 `json:"routekm"`
Createdat *time.Time `json:"-"`
Createdatist string `json:"createdat_ist,omitempty" gorm:"-"`
}
const bookingColumns = "bookingid, bookingno, tenantid, status, pickuppincode, deliverypincode, deliverycity, " +
"pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, assignedmileruserid, routekm, createdat"
func round2(f float64) float64 { return math.Round(f*100) / 100 }
func (r *bookingRow) tidy() {
r.Pickuplatitude, r.Pickuplongitude = round2(r.Pickuplatitude), round2(r.Pickuplongitude)
r.Deliverylatitude, r.Deliverylongitude = round2(r.Deliverylatitude), round2(r.Deliverylongitude)
if r.Createdat != nil {
// pickupbookings.createdat is timestamp WITHOUT zone holding IST digits.
r.Createdatist = r.Createdat.Format("2006-01-02 15:04")
}
}
// Executors returns the read tools this backend can serve. A nil db or rdb
// just leaves the tools that need it out (they then answer "unavailable").
func Executors(db *gorm.DB, rdb *redis.Client) map[string]Executor {
execs := map[string]Executor{}
if db != nil {
execs["get_booking_cache"] = getBooking(db)
execs["scan_bookings"] = scanBookings(db)
}
if rdb != nil {
execs["nearby_milers"] = nearbyMilers(rdb)
}
return execs
}
func decode(input json.RawMessage, into any) error {
if len(input) == 0 {
return nil
}
if err := json.Unmarshal(input, into); err != nil {
return fmt.Errorf("invalid input: %v", err)
}
return nil
}
func getBooking(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
BookingID int `json:"booking_id"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.BookingID <= 0 {
return nil, errors.New("booking_id (a positive integer) is required")
}
var row bookingRow
res := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns).
Where("bookingid = ?", in.BookingID).Limit(1).Scan(&row)
if res.Error != nil {
return nil, errors.New("booking lookup failed")
}
if res.RowsAffected == 0 {
return map[string]any{"found": false, "booking_id": in.BookingID}, nil
}
row.tidy()
return map[string]any{"found": true, "booking": row, "source": "pickupbookings table"}, nil
}
}
func scanBookings(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Status string `json:"status"`
Limit int `json:"limit"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Limit <= 0 {
in.Limit = scanDefaultLimit
}
if in.Limit > scanMaxLimit {
in.Limit = scanMaxLimit
}
q := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns)
if s := strings.TrimSpace(in.Status); s != "" {
q = q.Where("status = ?", s)
}
var rows []bookingRow
if err := q.Order("bookingid DESC").Limit(in.Limit).Scan(&rows).Error; err != nil {
return nil, errors.New("booking scan failed")
}
byStatus := map[string]int{}
for i := range rows {
rows[i].tidy()
byStatus[rows[i].Status]++
}
return map[string]any{"count": len(rows), "bystatus": byStatus, "bookings": rows, "order": "newest first"}, nil
}
}
func nearbyMilers(rdb *redis.Client) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
RadiusKm float64 `json:"radius_km"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Lat == nil || in.Lon == nil || math.Abs(*in.Lat) > 90 || math.Abs(*in.Lon) > 180 {
return nil, errors.New("lat and lon (decimal degrees) are required")
}
if in.RadiusKm <= 0 || in.RadiusKm > nearbyMaxKm {
in.RadiusKm = 5
}
locs, err := milergeo.Search(ctx, rdb, *in.Lat, *in.Lon, in.RadiusKm, nearbyMaxCount)
if err != nil {
return nil, errors.New("live rider positions are unavailable")
}
riders := make([]map[string]any, 0, len(locs))
for _, l := range locs {
riders = append(riders, map[string]any{"miler": l.Name, "distancekm": math.Round(l.Dist*100) / 100})
}
return map[string]any{"count": len(riders), "radiuskm": in.RadiusKm, "riders": riders}, nil
}
}

View File

@@ -0,0 +1,82 @@
package playground
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"doormile/internal/testpg"
)
// The booking executors against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN points at a THROWAWAY database (see
// internal/ai/registry/store_integration_test.go for how to start one).
//
// The table is a minimal stand-in for pickupbookings WITH personal columns,
// so the test proves the executors never select them.
func TestBookingExecutorsSelectNoPersonalColumns(t *testing.T) {
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "aiplayground_test")
for _, q := range []string{
`DROP TABLE IF EXISTS pickupbookings`,
`CREATE TABLE pickupbookings (
bookingid serial PRIMARY KEY, bookingno text NOT NULL, tenantid int, status text,
pickuppincode text, deliverypincode text, deliverycity text,
pickuplatitude double precision, pickuplongitude double precision,
deliverylatitude double precision, deliverylongitude double precision,
assignedmileruserid int, routekm double precision, createdat timestamp,
pickupaddress text, deliveryaddress text, notes text)`,
`INSERT INTO pickupbookings (bookingno, status, pickuppincode, deliverypincode, deliverycity,
pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, createdat, pickupaddress, deliveryaddress, notes)
VALUES ('DM-1', 'Created', '641001', '641002', 'Coimbatore', 11.01684, 76.95583, 11.0, 76.9, '2026-09-29 12:30', '12 MG Road', '4 Park St', 'call 9876543210'),
('DM-2', 'Cancelled', '641001', '641003', 'Coimbatore', 11.0, 76.9, 11.0, 76.9, '2026-09-29 12:40', 'x', 'y', 'z')`,
} {
if err := db.Exec(q).Error; err != nil {
t.Fatalf("setup: %v", err)
}
}
execs := Executors(db, nil)
if _, ok := execs["nearby_milers"]; ok {
t.Fatal("nearby_milers offered without Redis")
}
run := func(tool, input string) string {
out, err := execs[tool](context.Background(), json.RawMessage(input))
if err != nil {
t.Fatalf("%s: %v", tool, err)
}
b, _ := json.Marshal(Redact(out))
return string(b)
}
got := run("get_booking_cache", `{"booking_id":1}`)
for _, leaked := range []string{"MG Road", "Park St", "9876543210"} {
if strings.Contains(got, leaked) {
t.Fatalf("personal data %q in %s", leaked, got)
}
}
for _, want := range []string{`"found":true`, `"pickuplat":11.02`, `"createdat_ist":"2026-09-29 12:30"`, `"status":"Created"`} {
if !strings.Contains(got, want) {
t.Fatalf("missing %s in %s", want, got)
}
}
if !strings.Contains(run("get_booking_cache", `{"booking_id":99}`), `"found":false`) {
t.Fatal("missing booking should be found:false")
}
if _, err := execs["get_booking_cache"](context.Background(), json.RawMessage(`{}`)); err == nil {
t.Fatal("booking_id should be required")
}
scan := run("scan_bookings", `{"status":"Cancelled"}`)
if !strings.Contains(scan, `"count":1`) || !strings.Contains(scan, `"Cancelled":1`) {
t.Fatalf("scan = %s", scan)
}
if all := run("scan_bookings", `{"limit":500}`); !strings.Contains(all, `"count":2`) {
t.Fatalf("scan all = %s", all)
}
}

View File

@@ -0,0 +1,480 @@
package registry
import (
"encoding/json"
"errors"
"strings"
"testing"
"doormile/models"
)
// No database here: these pin the seed's integrity and every rule a write must
// pass. Seed/Load/Update against Postgres are not exercised by this file.
func boolp(b bool) *bool { return &b }
func strp(s string) *string { return &s }
func isValidation(err error) bool { var v *ValidationError; return errors.As(err, &v) }
// ── Seed integrity ──────────────────────────────────────────────────────────
func TestSeedIDsAreUnique(t *testing.T) {
seen := map[string]bool{}
for _, a := range SeedAgents {
if seen["agent:"+a.Agentid] {
t.Errorf("agent %s seeded twice", a.Agentid)
}
seen["agent:"+a.Agentid] = true
}
for _, tl := range SeedTools {
if seen["tool:"+tl.Toolname] {
t.Errorf("tool %s seeded twice", tl.Toolname)
}
seen["tool:"+tl.Toolname] = true
}
for _, s := range SeedSkills {
if seen["skill:"+s.Skill.Skillid] {
t.Errorf("skill %s seeded twice", s.Skill.Skillid)
}
seen["skill:"+s.Skill.Skillid] = true
}
}
func TestSeedAgentsUseKnownValues(t *testing.T) {
for _, a := range SeedAgents {
if !validStatuses[a.Status] {
t.Errorf("%s: unknown status %q", a.Agentid, a.Status)
}
if !validRuntimes[a.Runtime] {
t.Errorf("%s: unknown runtime %q", a.Agentid, a.Runtime)
}
if a.Autonomous {
t.Errorf("%s is seeded autonomous; every agent must start with autonomy off", a.Agentid)
}
if a.Name == "" || a.Purpose == "" || a.Classref == "" {
t.Errorf("%s: name, purpose and classref are all required", a.Agentid)
}
}
}
// Autonomy can only be switched on the three agents whose writes AI_engine
// actually gates. A gate on any other agent would be a switch wired to nothing.
func TestOnlyTheThreeGatedAgentsHaveAutonomyGates(t *testing.T) {
want := map[string]bool{"DISPATCH_AGENT": true, "EXCEPTION_AGENT": true, "EXPRESS_DISPATCH_AGENT": true}
for _, a := range SeedAgents {
if a.Hasautonomygate != want[a.Agentid] {
t.Errorf("%s: hasautonomygate = %v, want %v", a.Agentid, a.Hasautonomygate, want[a.Agentid])
}
}
}
// The simulated agents must say so — the console renders this badge, and an
// agent that looks live and is not is the failure this registry exists to end.
func TestSimulatedAgentsAreSeededAsSimulation(t *testing.T) {
for _, id := range []string{"HUB_AGENT", "FLEET_AGENT", "ROUTE_OPTIMIZER"} {
for _, a := range SeedAgents {
if a.Agentid == id && a.Status != StatusSimulation {
t.Errorf("%s status = %q, want simulation", id, a.Status)
}
}
}
}
func TestSeedToolsAreWellFormed(t *testing.T) {
for _, tl := range SeedTools {
if !validKinds[tl.Kind] {
t.Errorf("%s: unknown kind %q", tl.Toolname, tl.Kind)
}
if (tl.Kind == KindWrite || tl.Kind == KindNotify) && !tl.Requiresconfirmation {
t.Errorf("%s is a %s tool but does not require confirmation", tl.Toolname, tl.Kind)
}
if tl.Kind == KindRead && tl.Requiresconfirmation {
t.Errorf("%s is read-only but requires confirmation", tl.Toolname)
}
var schema map[string]any
if err := json.Unmarshal([]byte(tl.Inputschema), &schema); err != nil || schema["type"] != "object" {
t.Errorf("%s: inputschema is not a JSON-schema object: %s", tl.Toolname, tl.Inputschema)
}
if tl.Description == "" || tl.Target == "" || tl.Implementedat == "" {
t.Errorf("%s: description, target and implementedat are all required", tl.Toolname)
}
}
}
func TestEverySkillPointsAtRealAgentsAndTools(t *testing.T) {
agents := map[string]bool{}
for _, a := range SeedAgents {
agents[a.Agentid] = true
}
tools := map[string]bool{}
for _, tl := range SeedTools {
tools[tl.Toolname] = true
}
for _, s := range SeedSkills {
if !agents[s.Skill.Agentid] {
t.Errorf("skill %s belongs to unknown agent %s", s.Skill.Skillid, s.Skill.Agentid)
}
if len(s.Tools) == 0 {
t.Errorf("skill %s has no tools", s.Skill.Skillid)
}
for _, tl := range s.Tools {
if !tools[tl] {
t.Errorf("skill %s uses unknown tool %s", s.Skill.Skillid, tl)
}
}
if s.Skill.Source != SourceEngine && s.Skill.Source != SourceConsole {
t.Errorf("seeded skill %s has source %q; custom is for operator-made skills only", s.Skill.Skillid, s.Skill.Source)
}
}
}
// Every seeded tool is used by some skill — the registry lists capabilities in
// use, not a catalogue of ideas.
func TestEveryToolIsUsedBySomeSkill(t *testing.T) {
used := map[string]bool{}
for _, s := range SeedSkills {
for _, tl := range s.Tools {
used[tl] = true
}
}
for _, tl := range SeedTools {
if !used[tl.Toolname] {
t.Errorf("tool %s is seeded but no skill uses it", tl.Toolname)
}
}
}
func TestSeedThresholdDefaultsAreValid(t *testing.T) {
for _, s := range SeedSkills {
keys := map[string]bool{}
for _, spec := range s.Schema {
if keys[spec.Key] {
t.Errorf("%s: threshold %s declared twice", s.Skill.Skillid, spec.Key)
}
keys[spec.Key] = true
if spec.Min >= spec.Max {
t.Errorf("%s.%s: min %v is not below max %v", s.Skill.Skillid, spec.Key, spec.Min, spec.Max)
}
if err := spec.check(spec.Default); err != nil {
t.Errorf("%s.%s: default is itself invalid: %v", s.Skill.Skillid, spec.Key, err)
}
}
}
}
// Rebalancing has no endpoint behind it. It must never ship switched on.
func TestRebalanceShipsDisabled(t *testing.T) {
for _, s := range SeedSkills {
if s.Skill.Skillid == "dispatch_rebalance" && s.Skill.Enabled {
t.Fatal("dispatch_rebalance is seeded enabled; nothing implements it")
}
}
}
// The ops-layer skills keep the branch's ids and threshold keys so the Phase 3
// port maps one to one. Pin them.
func TestConsoleOpsSkillsKeepBranchIDsAndKeys(t *testing.T) {
want := map[string][]string{
"skill_sla_guardian": {"slaRiskWindowMin", "unassignedAgingMin"},
"skill_doorstep_stall": {"arrivedStalledMin"},
"skill_fleet_balancer": {"riderActiveCap"},
"skill_high_value_cod": {"codRiskThresholdAmount"},
"skill_rider_battery_safety": {"criticalBatteryPercent"},
"skill_hub_congestion": {"hubDwellMinutes"},
"skill_late_dispatch": {"lateDispatchMinutes", "criticalDispatchMinutes"},
"skill_cash_exposure": {"maxCashPerRider", "warningCashPercent"},
}
for _, s := range SeedSkills {
keys, ok := want[s.Skill.Skillid]
if !ok {
continue
}
delete(want, s.Skill.Skillid)
var got []string
for _, spec := range s.Schema {
got = append(got, spec.Key)
}
if strings.Join(got, ",") != strings.Join(keys, ",") {
t.Errorf("%s threshold keys = %v, want %v", s.Skill.Skillid, got, keys)
}
}
for id := range want {
t.Errorf("branch skill %s is missing from the seed", id)
}
}
// ── Thresholds ──────────────────────────────────────────────────────────────
var testSchema = []ThresholdSpec{
{Key: "minutes", Default: 20, Min: 10, Max: 60, Step: 5},
{Key: "confidence", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05},
}
func TestEffectiveThresholdsFillsDefaultsAndDropsStrays(t *testing.T) {
got := EffectiveThresholds(testSchema, map[string]float64{"minutes": 30, "removed": 9, "confidence": 5})
if got["minutes"] != 30 {
t.Errorf("a valid stored value was not kept: %v", got["minutes"])
}
if got["confidence"] != 0.7 {
t.Errorf("an out-of-range stored value was not replaced by the default: %v", got["confidence"])
}
if _, stray := got["removed"]; stray {
t.Error("a key the schema no longer has was kept")
}
}
func TestApplyThresholdPatchAcceptsValidValues(t *testing.T) {
got, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 45.0, "confidence": 0.85})
if err != nil {
t.Fatalf("valid patch refused: %v", err)
}
if got["minutes"] != 45 || got["confidence"] != 0.85 {
t.Errorf("patch not applied: %v", got)
}
}
func TestApplyThresholdPatchRefusesBadInput(t *testing.T) {
cases := map[string]map[string]any{
"unknown key": {"minuts": 30.0},
"not a number": {"minutes": "30"},
"a boolean": {"minutes": true},
"below min": {"minutes": 5.0},
"above max": {"minutes": 65.0},
"off the step": {"minutes": 33.0},
"off the fstep": {"confidence": 0.72},
}
for name, patch := range cases {
if _, err := ApplyThresholdPatch(testSchema, nil, patch); !isValidation(err) {
t.Errorf("%s: want a ValidationError, got %v", name, err)
}
}
}
// Every problem is reported at once, so an operator fixes the form in one pass.
func TestApplyThresholdPatchReportsEveryProblem(t *testing.T) {
_, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 5.0, "nope": 1.0})
if err == nil || !strings.Contains(err.Error(), "minutes") || !strings.Contains(err.Error(), "nope") {
t.Fatalf("want both problems named, got %v", err)
}
}
// A refused patch changes nothing — the whole set, not the valid half, is rejected.
func TestApplyThresholdPatchIsAllOrNothing(t *testing.T) {
got, err := ApplyThresholdPatch(testSchema, map[string]float64{"minutes": 20}, map[string]any{"minutes": 40.0, "confidence": 9.0})
if err == nil || got != nil {
t.Fatalf("partial patch was applied: %v, %v", got, err)
}
}
func TestParseThresholdsToleratesJunk(t *testing.T) {
for _, raw := range []string{"", "null", "not json", "[]"} {
if got := ParseThresholds(raw); got == nil || len(got) != 0 {
t.Errorf("ParseThresholds(%q) = %v, want an empty map", raw, got)
}
}
if got := ParseSchema("garbage"); got == nil || len(got) != 0 {
t.Errorf("ParseSchema(garbage) = %v, want empty", got)
}
}
// ── Agent patches ───────────────────────────────────────────────────────────
var gated = models.AIAgent{Agentid: "EXCEPTION_AGENT", Runtime: RuntimeEngine, Hasautonomygate: true}
func TestAutonomyOnNeedsTypedConfirmation(t *testing.T) {
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true)}); !isValidation(err) {
t.Errorf("autonomy switched on with no confirmation: %v", err)
}
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "exception_agent"}); !isValidation(err) {
t.Errorf("a near-miss confirmation was accepted: %v", err)
}
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}); err != nil {
t.Errorf("correct confirmation refused: %v", err)
}
}
// Switching autonomy OFF is always allowed without ceremony — the safe direction.
func TestAutonomyOffNeedsNoConfirmation(t *testing.T) {
on := gated
on.Autonomous = true
if err := CheckAgentPatch(on, AgentPatch{Autonomous: boolp(false)}); err != nil {
t.Errorf("switching autonomy off was refused: %v", err)
}
}
func TestAutonomyRefusedOnUngatedAgent(t *testing.T) {
hub := models.AIAgent{Agentid: "HUB_AGENT", Runtime: RuntimeEngine}
if err := CheckAgentPatch(hub, AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}); !isValidation(err) {
t.Errorf("autonomy set on an agent with no gate: %v", err)
}
}
func TestModelIDRules(t *testing.T) {
for _, ok := range []string{"", "claude-sonnet-5-5", "claude-haiku-4-5-20251001", "claude-opus-5-5"} {
if err := CheckAgentPatch(gated, AgentPatch{Model: strp(ok)}); err != nil {
t.Errorf("model %q refused: %v", ok, err)
}
}
for _, bad := range []string{"gpt-4o", "claude-", "Claude-Sonnet", "claude-x; drop table", strings.Repeat("claude-a", 20)} {
if err := CheckAgentPatch(gated, AgentPatch{Model: strp(bad)}); !isValidation(err) {
t.Errorf("model %q accepted", bad)
}
}
console := models.AIAgent{Agentid: "CONSOLE_ASSISTANT", Runtime: RuntimeConsole}
if err := CheckAgentPatch(console, AgentPatch{Model: strp("claude-sonnet-5-5")}); !isValidation(err) {
t.Error("a model was set on a console agent, which has no model setting")
}
}
func TestEmptyAgentPatchRefused(t *testing.T) {
if err := CheckAgentPatch(gated, AgentPatch{}); !isValidation(err) {
t.Error("an empty patch was accepted")
}
}
// ── New skills ──────────────────────────────────────────────────────────────
func TestCheckNewSkill(t *testing.T) {
good := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"lookup_order"}}
if err := CheckNewSkill(good); err != nil {
t.Fatalf("valid skill refused: %v", err)
}
bad := map[string]NewSkill{
"no agent": {Title: "x", Tools: []string{"a"}},
"no title": {Agentid: "A", Title: " ", Tools: []string{"a"}},
"no tools": {Agentid: "A", Title: "x"},
"duplicate tool": {Agentid: "A", Title: "x", Tools: []string{"a", "a"}},
"title too long": {Agentid: "A", Title: strings.Repeat("x", 121), Tools: []string{"a"}},
}
for name, n := range bad {
if err := CheckNewSkill(n); !isValidation(err) {
t.Errorf("%s: accepted", name)
}
}
}
func TestCustomSkillID(t *testing.T) {
cases := map[string]string{
"Night Shift Watch": "custom_night_shift_watch",
" COD > ₹5,000 alerts!! ": "custom_cod_5_000_alerts",
"!!!": "custom_skill",
}
for in, want := range cases {
if got := customSkillID(in); got != want {
t.Errorf("customSkillID(%q) = %q, want %q", in, got, want)
}
}
if got := customSkillID(strings.Repeat("abc ", 40)); len(got) > 64 {
t.Errorf("id %q exceeds the 64-char column", got)
}
}
// ── Build & ETag ────────────────────────────────────────────────────────────
func seededRows() ([]models.AIAgent, []models.AITool, []models.AISkill, []models.AISkillTool) {
var skills []models.AISkill
var links []models.AISkillTool
for _, s := range SeedSkills {
row := s.Skill
row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema))
row.Thresholds = mustJSON(DefaultThresholds(s.Schema))
skills = append(skills, row)
for _, tl := range s.Tools {
links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: tl})
}
}
return SeedAgents, SeedTools, skills, links
}
func TestBuildCountsSkillsAndToolsPerAgent(t *testing.T) {
snap := Build(seededRows())
for _, a := range snap.Agents {
if a.Agentid == "EXPRESS_DISPATCH_AGENT" && (a.Skillcount != 1 || a.Toolcount != 4) {
t.Errorf("EXPRESS_DISPATCH_AGENT: %d skills, %d tools; want 1 and 4", a.Skillcount, a.Toolcount)
}
if a.Agentid == "HUB_AGENT" && (a.Skillcount != 0 || a.Toolcount != 0) {
t.Errorf("HUB_AGENT has no skills, got %d/%d", a.Skillcount, a.Toolcount)
}
}
}
// The API must emit thresholds and schemas as JSON values, not as strings of JSON.
func TestSnapshotSerialisesJSONColumnsAsJSON(t *testing.T) {
b, err := json.Marshal(Build(seededRows()))
if err != nil {
t.Fatal(err)
}
var out struct {
Skills []struct {
Skillid string `json:"skillid"`
Thresholds map[string]float64 `json:"thresholds"`
Thresholdsschema []ThresholdSpec `json:"thresholdsschema"`
Tools []string `json:"tools"`
} `json:"skills"`
Tools []struct {
Inputschema map[string]any `json:"inputschema"`
} `json:"tools"`
}
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("snapshot JSON does not decode as structured values: %v", err)
}
for _, s := range out.Skills {
if s.Skillid == "skill_cash_exposure" {
if s.Thresholds["maxCashPerRider"] != 10000 || len(s.Thresholdsschema) != 2 || len(s.Tools) != 2 {
t.Errorf("skill_cash_exposure serialised wrongly: %+v", s)
}
}
}
if len(out.Tools) == 0 || out.Tools[0].Inputschema["type"] != "object" {
t.Error("tool inputschema is not emitted as a JSON object")
}
}
func TestETagIsStableAndChangesWithContent(t *testing.T) {
a := ETag(Build(seededRows()))
if a != ETag(Build(seededRows())) {
t.Fatal("ETag differs for identical content")
}
agents, tools, skills, links := seededRows()
skills[0].Enabled = !skills[0].Enabled
if a == ETag(Build(agents, tools, skills, links)) {
t.Fatal("ETag did not change when a skill was toggled")
}
if !strings.HasPrefix(a, `W/"`) {
t.Errorf("ETag %s is not a weak validator", a)
}
}
// These three read fields /admin/bookings rows do not carry (payment amounts,
// battery). Enabled, they would read undefined on every row and report an
// all-clear board. They must ship off, in step with the console's defaults.
func TestSkillsWithNoDataSourceShipDisabled(t *testing.T) {
off := map[string]bool{"skill_high_value_cod": true, "skill_cash_exposure": true, "skill_rider_battery_safety": true}
for _, s := range SeedSkills {
if off[s.Skill.Skillid] {
delete(off, s.Skill.Skillid)
if s.Skill.Enabled {
t.Errorf("%s is seeded enabled but its rows carry no data for its rule", s.Skill.Skillid)
}
if !strings.Contains(s.Skill.Description, "OFF:") {
t.Errorf("%s does not say why it is off", s.Skill.Skillid)
}
}
}
for id := range off {
t.Errorf("%s missing from the seed", id)
}
}
// Only notify_riders has an executor in the console. Every other console write
// must say REVIEW ONLY, or Agent Studio would advertise an action that cannot run.
func TestConsoleWritesWithoutExecutorSayReviewOnly(t *testing.T) {
for _, tl := range SeedTools {
if !strings.HasPrefix(tl.Implementedat, consoleSrc+"lib/assistant/skills/") && !strings.Contains(tl.Implementedat, "(no executor)") {
continue
}
if tl.Kind != KindRead && !strings.HasPrefix(tl.Description, "REVIEW ONLY") {
t.Errorf("%s has no executor but its description does not start with REVIEW ONLY", tl.Toolname)
}
}
}

View File

@@ -0,0 +1,134 @@
package registry
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"doormile/models"
)
// ErrNotFound is returned when the agent or skill named by a request does not exist.
var ErrNotFound = errors.New("not found")
// ValidationError is a request the registry refuses. Its message is written for
// the operator and is safe to return as-is.
type ValidationError struct{ Msg string }
func (e *ValidationError) Error() string { return e.Msg }
func invalid(format string, args ...any) error {
return &ValidationError{Msg: fmt.Sprintf(format, args...)}
}
// Actor is who is making a change. The email is kept with the id because an
// admin login without an appusers row carries user id 0, and an audit that
// says "user 0" answers nothing.
type Actor struct {
UserID int
Email string
}
// SkillPatch is what an operator may change on a skill.
type SkillPatch struct {
Enabled *bool `json:"enabled"`
Thresholds map[string]any `json:"thresholds"`
}
// AgentPatch is what an operator may change on an agent.
//
// Confirm must repeat the agent id to switch autonomy ON. Autonomy lets an
// agent reassign riders or message customers with no human in the loop; a
// stray click or a replayed request must not be enough to turn that on.
type AgentPatch struct {
Autonomous *bool `json:"autonomous"`
Model *string `json:"model"`
Confirm string `json:"confirm"`
}
// NewSkill is an operator-created skill. It may only use tools that exist.
type NewSkill struct {
Agentid string `json:"agentid"`
Title string `json:"title"`
Category string `json:"category"`
Description string `json:"description"`
Sampleprompt string `json:"sampleprompt"`
Tools []string `json:"tools"`
}
var modelID = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9.-]{0,56}$`)
// CheckAgentPatch validates a patch against the agent it targets. Pure, so the
// rules are tested without a database.
func CheckAgentPatch(agent models.AIAgent, p AgentPatch) error {
if p.Autonomous == nil && p.Model == nil {
return invalid("nothing to change: send autonomous and/or model")
}
if p.Autonomous != nil {
if !agent.Hasautonomygate {
return invalid("%s has no autonomy gate; only agents that can act on their own can be switched", agent.Agentid)
}
if *p.Autonomous && !agent.Autonomous && p.Confirm != agent.Agentid {
return invalid("switching %s to autonomous needs confirm set to %q", agent.Agentid, agent.Agentid)
}
}
if p.Model != nil && *p.Model != "" && !modelID.MatchString(*p.Model) {
return invalid("model must be a Claude model id such as claude-sonnet-5-5, or empty for the engine default")
}
if p.Model != nil && agent.Runtime != RuntimeEngine {
return invalid("%s runs in the console; it has no model setting", agent.Agentid)
}
return nil
}
// CheckNewSkill validates the shape of a new skill. Whether the agent and tools
// exist is checked against the database by CreateSkill.
func CheckNewSkill(n NewSkill) error {
title := strings.TrimSpace(n.Title)
switch {
case n.Agentid == "":
return invalid("agentid is required")
case title == "":
return invalid("title is required")
case len(title) > 120:
return invalid("title must be 120 characters or fewer")
case len(n.Tools) == 0:
return invalid("a skill needs at least one tool")
case len(n.Tools) > 20:
return invalid("a skill may use at most 20 tools")
}
seen := map[string]bool{}
for _, t := range n.Tools {
if seen[t] {
return invalid("tool %s is listed twice", t)
}
seen[t] = true
}
return nil
}
// customSkillID derives a stable id from a title: "custom_" plus a lowercase
// slug. CreateSkill appends a counter if it is taken.
func customSkillID(title string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range strings.ToLower(strings.TrimSpace(title)) {
if unicode.IsLetter(r) && r < unicode.MaxASCII || unicode.IsDigit(r) {
b.WriteRune(r)
lastUnderscore = false
} else if !lastUnderscore && b.Len() > 0 {
b.WriteByte('_')
lastUnderscore = true
}
}
slug := strings.Trim(b.String(), "_")
if slug == "" {
slug = "skill"
}
if len(slug) > 48 {
slug = strings.Trim(slug[:48], "_")
}
return "custom_" + slug
}

View File

@@ -0,0 +1,304 @@
package registry
import "doormile/models"
// The registry as the code actually stands (verified 2026-09-29). Every entry
// says what the code DOES, not what a doc claims: an agent that only simulates
// is seeded as "simulation", and the console must show that badge. Source of
// this inventory: krow_talent_app/docs/agent-platform-plan.md §2.
//
// Upserted on every boot by Seed. Changing a code-defined field here changes
// it in the database on the next start; operator-owned fields (skill enabled
// and thresholds, agent autonomous and model) are only written on first insert.
//
// Deliberately NOT seeded:
// - AI_engine's ask_question, order_intake_skill and repeat_run_skill —
// registered in core/tool_registry.py but never loaded in production.
// - The four Krow workforce-training tools the console mock carried.
// - ORDER_AGENT's crmbooking calls — that route was renamed to
// expressbooking, so they reach nothing.
// - /internal/agent-decisions — an append-only log the Go side writes, not a
// capability any skill chooses to use.
// Agent statuses and runtimes. The console renders these verbatim.
const (
StatusLive = "live"
StatusPartial = "partial"
StatusSimulation = "simulation"
StatusBroken = "broken"
StatusUnmerged = "unmerged"
StatusRetired = "retired"
RuntimeEngine = "engine"
RuntimeConsole = "console"
KindRead = "read"
KindWrite = "write"
KindNotify = "notify"
// KindEvent is an internal bus event or record: no effect on an order, a
// rider or a customer by itself, so it is never behind confirmation.
KindEvent = "event"
SourceEngine = "engine"
SourceConsole = "console"
SourceCustom = "custom"
)
var (
validStatuses = map[string]bool{StatusLive: true, StatusPartial: true, StatusSimulation: true, StatusBroken: true, StatusUnmerged: true, StatusRetired: true}
validRuntimes = map[string]bool{RuntimeEngine: true, RuntimeConsole: true}
validKinds = map[string]bool{KindRead: true, KindWrite: true, KindNotify: true, KindEvent: true}
)
const consoleSrc = "krow_talent_app/src/"
// SeedAgents — AI_engine's nine, then the console's two.
var SeedAgents = []models.AIAgent{
{Agentid: "JARVIS", Name: "JARVIS", Runtime: RuntimeEngine, Classref: "AI_engine/core/agent.py:196 MasterAgent",
Purpose: "Orchestrator and escalation inbox. Receives EXCEPTION_DETECTED from other agents.",
Wakeon: "NATS logistics.direct.JARVIS", Status: StatusPartial, Sortorder: 10},
{Agentid: "DISPATCH_AGENT", Name: "Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/dispatch_agent.py:72",
Purpose: "Watches assignment outcomes and flags coverage gaps. Alerts; notifies customers only when autonomous.",
Wakeon: "JetStream booking.assigned, booking.assignment_failed",
Status: StatusLive, Llmdecision: "decide_assignment_failure", Hasautonomygate: true, Sortorder: 20},
{Agentid: "EXCEPTION_AGENT", Name: "Exception", Runtime: RuntimeEngine, Classref: "AI_engine/agents/exception_agent.py:106",
Purpose: "Detects stalled riders and decides the response. Reassigns only when autonomous and confident.",
Wakeon: "TRACKING miler.location.updated, miler.stalled; 60 s database sweep",
Status: StatusLive, Llmdecision: "decide_stall_response", Hasautonomygate: true, Sortorder: 30},
{Agentid: "EXPRESS_DISPATCH_AGENT", Name: "Express Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/express_dispatch_agent.py:80",
Purpose: "Tenant-scoped batch assignment for DoormileExpress, then road sequencing of each rider's stops.",
Wakeon: "JetStream express.dispatch_requested", Status: StatusLive, Hasautonomygate: true, Sortorder: 40},
{Agentid: "CUSTOMER_AGENT", Name: "Customer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/customer_agent.py:50",
Purpose: "Customer notifications and tracking. Reached only from an autonomous Dispatch agent.",
Wakeon: "Direct task", Status: StatusPartial, Sortorder: 50},
{Agentid: "ORDER_AGENT", Name: "Order", Runtime: RuntimeEngine, Classref: "AI_engine/agents/order_agent.py:15",
Purpose: "Order intake and validation. Not connected: it has no working backend route (/admin/* needs a console JWT, crmbooking is gone), so its backend calls are refused and logged.",
Wakeon: "Direct task (no sender in production)", Status: StatusBroken, Sortorder: 60},
{Agentid: "HUB_AGENT", Name: "Hub", Runtime: RuntimeEngine, Classref: "AI_engine/agents/hub_agent.py:35",
Purpose: "Hub capacity over 8 hard-coded fictional hubs.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 70},
{Agentid: "FLEET_AGENT", Name: "Fleet", Runtime: RuntimeEngine, Classref: "AI_engine/agents/fleet_agent.py:34",
Purpose: "An in-memory fleet of 19 fake vehicles.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 80},
{Agentid: "ROUTE_OPTIMIZER", Name: "Route Optimizer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/route_optimizer_agent.py:41",
Purpose: "Haversine routing with traffic multipliers. Real sequencing is done by Express Dispatch via routes.workolik.com.",
Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 90},
{Agentid: "CONSOLE_ASSISTANT", Name: "Console Assistant", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant",
Purpose: "The Home chat: orders, bulk upload, assignment, repeat runs. Regex intent catalogue; every write is a proposal the operator confirms.",
Wakeon: "Operator prompt", Status: StatusLive, Sortorder: 100},
{Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant/agent",
Purpose: "The Exceptions early-warnings banner and the chat's 'what needs attention' briefing: eight rule-based monitoring skills over the booking scan. Nothing runs on its own; an action runs only when an operator clicks it.",
Wakeon: "Exceptions page load, 60 s poll, and the chat briefing", Status: StatusLive, Sortorder: 110},
}
// obj builds a JSON-schema object for a tool's input. Only parameters the code
// provably takes are listed; where the body is not pinned down, the schema is
// left open rather than invented.
func obj(props map[string]map[string]string, required ...string) string {
properties := map[string]any{}
for name, p := range props {
properties[name] = p
}
s := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
s["required"] = required
}
return mustJSON(s)
}
var (
integer = func(desc string) map[string]string { return map[string]string{"type": "integer", "description": desc} }
number = func(desc string) map[string]string { return map[string]string{"type": "number", "description": desc} }
str = func(desc string) map[string]string { return map[string]string{"type": "string", "description": desc} }
open = obj(map[string]map[string]string{})
)
// SeedTools — every capability a seeded skill uses, and nothing else.
var SeedTools = []models.AITool{
// AI_engine
{Toolname: "reassign_booking", Kind: KindWrite, Requiresconfirmation: true,
Description: "Reassign a booking whose rider has stalled.", Target: "doormile_backend POST /internal/bookings/:id/reassign",
Implementedat: "AI_engine/agents/exception_agent.py:466", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to reassign")}, "booking_id")},
{Toolname: "notify_customer", Kind: KindNotify, Requiresconfirmation: true,
Description: "Send a customer a delivery update.", Target: "doormile_backend POST /internal/notify",
Implementedat: "AI_engine/agents/exception_agent.py:476, customer_agent.py:349", Inputschema: open},
{Toolname: "list_express_bookings", Kind: KindRead,
Description: "Read the bookings in an express dispatch batch.", Target: "doormile_backend GET /internal/express/bookings",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:351", Inputschema: open},
{Toolname: "list_express_riders", Kind: KindRead,
Description: "Read a tenant's riders available for an express batch.", Target: "doormile_backend GET /internal/express/riders",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:342", Inputschema: open},
{Toolname: "assign_express_batch", Kind: KindWrite, Requiresconfirmation: true,
Description: "Write back the rider assignments decided for an express batch.", Target: "doormile_backend POST /internal/express/assign",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:222", Inputschema: open},
{Toolname: "sequence_stops", Kind: KindRead,
Description: "Order a rider's stops by road (computes, writes nothing).", Target: "routes.workolik.com POST /api/v1/optimization/doormile/sequence",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:308", Inputschema: open},
{Toolname: "get_booking_cache", Kind: KindRead,
Description: "Read a booking from the backend's booking cache.", Target: "doormile_backend GET /bookings/cache/:id",
Implementedat: "AI_engine/agents/customer_agent.py:243", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to read")}, "booking_id")},
{Toolname: "nearby_milers", Kind: KindRead,
Description: "Find riders near a point from live positions.", Target: "Redis GEO milers:locations",
Implementedat: "AI_engine/agents/dispatch_agent.py:170-240",
Inputschema: obj(map[string]map[string]string{
"lat": number("Latitude of the point, decimal degrees"), "lon": number("Longitude of the point, decimal degrees"),
"radius_km": number("Search radius in km (default 5, at most 10)"),
}, "lat", "lon")},
{Toolname: "publish_miler_stalled", Kind: KindEvent,
Description: "Publish a stalled-rider event for other agents.", Target: "NATS miler.stalled",
Implementedat: "AI_engine/agents/exception_agent.py:377", Inputschema: open},
{Toolname: "decide_stall_response", Kind: KindRead,
Description: "Ask the model how to respond to a stalled rider (structured output).", Target: "Claude via AI_engine/core/llm.py",
Implementedat: "AI_engine/core/llm.py:157", Inputschema: open},
{Toolname: "decide_assignment_failure", Kind: KindRead,
Description: "Ask the model why an assignment failed and what to do (structured output).", Target: "Claude via AI_engine/core/llm.py",
Implementedat: "AI_engine/core/llm.py:227", Inputschema: open},
// Console ops layer (krow_talent_app, ported onto main in Phase 3). The
// tool names are the proposal VERBS the findings carry, because that is what
// an operator's click resolves (lib/assistant/agent/actions.js). Only
// notify_riders has an executor; every other write is review-only and the
// console renders it disabled — the descriptions say so.
{Toolname: "scan_bookings", Kind: KindRead,
Description: "Read open and recent bookings (drained page by page) for the rules to evaluate.", Target: "doormile_backend GET /admin/bookings",
Implementedat: consoleSrc + "lib/assistant/scan.js",
Inputschema: obj(map[string]map[string]string{
"status": str("Only bookings in this status, e.g. Created, Miler_Assigned, Picked_Up, Cancelled"),
"limit": integer("How many of the newest bookings (default 20, at most 50)"),
})},
{Toolname: "notify_riders", Kind: KindNotify, Requiresconfirmation: true,
Description: "Message the riders on a finding's orders. Runs only when an operator clicks it; partial success is reported as partial.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js", Inputschema: open},
{Toolname: "assign_riders", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Would assign a finding's orders to riders, but POST /hub/bookings/batch-assign accepts hub-staff logins only, so the console cannot run it.", Target: "doormile_backend POST /hub/bookings/batch-assign (hub staff only)",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js (no executor)", Inputschema: open},
{Toolname: "enforce_otp_verification", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Flag a high-value COD order as requiring the receiver's OTP at handover. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/HighValueCodSkill.js", Inputschema: obj(map[string]map[string]string{"bookingId": integer("Booking to flag")}, "bookingId")},
{Toolname: "alert_low_battery_rider", Kind: KindNotify, Requiresconfirmation: true,
Description: "REVIEW ONLY. Tell a rider on a low battery to charge or report to the nearest hub. No executor.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/RiderBatterySafetySkill.js", Inputschema: obj(map[string]map[string]string{"milerId": integer("Rider to alert")}, "milerId")},
{Toolname: "dispatch_hub_idle_parcels", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Send an idle rider to collect parcels dwelling at a hub. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/HubCongestionSkill.js",
Inputschema: obj(map[string]map[string]string{"hubId": str("Hub where parcels are waiting"), "milerId": integer("Idle rider")}, "hubId", "milerId")},
{Toolname: "trigger_auto_dispatch", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Auto-assign orders that have waited too long for dispatch. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/LateDispatchSkill.js", Inputschema: open},
{Toolname: "enforce_cash_handoff", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Route a rider carrying too much COD via the nearest hub. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/CashExposureSkill.js", Inputschema: open},
// Console assistant
{Toolname: "simulate_pricing_quote", Kind: KindRead,
Description: "Quote a delivery from the tenant's pricing row and the routed distance, without booking it.", Target: "doormile_backend GET /admin/pricing + OSRM route",
Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open},
{Toolname: "create_single_order", Kind: KindWrite, Requiresconfirmation: true,
Description: "Create one express booking. Returns a proposal; the operator confirms.", Target: "doormile_backend POST /admin/expressbooking",
Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open},
{Toolname: "rebalance_riders", Kind: KindWrite, Requiresconfirmation: true,
Description: "Move idle riders between zones. NOT IMPLEMENTED: no endpoint does zone rebalancing yet.", Target: "none yet",
Implementedat: "none", Inputschema: open}}
// SeedSkill pairs a skill row with its tool links and threshold schema.
type SeedSkill struct {
Skill models.AISkill
Tools []string
Schema []ThresholdSpec
}
func minutes(key, label string, def, min, max, step float64) ThresholdSpec {
return ThresholdSpec{Key: key, Label: label, Unit: "min", Default: def, Min: min, Max: max, Step: step}
}
// SeedSkills. The console ops skills keep the ids and threshold keys of the
// feat/agentic-ops-layer branch, which were ported onto main one to one (Phase 3).
var SeedSkills = []SeedSkill{
// AI_engine. Thresholds mirror the env knobs the agents read today; the
// engine starts reading them from here in Phase 5.
{Skill: models.AISkill{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stalled-rider response", Category: "rider_operations", Source: SourceEngine, Enabled: true,
Description: "Detect a rider who has stopped moving, ask the model what to do, and alert or (when autonomous) reassign."},
Tools: []string{"nearby_milers", "decide_stall_response", "reassign_booking", "notify_customer", "publish_miler_stalled"},
Schema: []ThresholdSpec{
minutes("stallMinutes", "Stall threshold", 10, 5, 60, 5),
{Key: "reassignConfidence", Label: "Auto-reassign confidence floor", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05},
}},
{Skill: models.AISkill{Skillid: "assignment_failure_triage", Agentid: "DISPATCH_AGENT", Title: "Assignment-failure triage", Category: "dispatch", Source: SourceEngine, Enabled: true,
Description: "When no rider could be assigned, work out why from nearby supply and raise one alert per gap."},
Tools: []string{"nearby_milers", "decide_assignment_failure", "notify_customer"},
Schema: []ThresholdSpec{
{Key: "realertEvery", Label: "Re-alert after N repeat failures", Unit: "failures", Default: 100, Min: 10, Max: 1000, Step: 10},
}},
{Skill: models.AISkill{Skillid: "express_batch_dispatch", Agentid: "EXPRESS_DISPATCH_AGENT", Title: "Express batch dispatch", Category: "dispatch", Source: SourceEngine, Enabled: true,
Description: "Assign a tenant's express batch to its riders greedily, then sequence each rider's stops by road."},
Tools: []string{"list_express_bookings", "list_express_riders", "sequence_stops", "assign_express_batch"},
Schema: []ThresholdSpec{
{Key: "maxRadiusKm", Label: "Max rider radius", Unit: "km", Default: 30, Min: 5, Max: 60, Step: 1},
{Key: "maxPerRider", Label: "Max stops per rider", Unit: "stops", Default: 5, Min: 1, Max: 10, Step: 1},
{Key: "loadPenaltyKm", Label: "Load penalty per held stop", Unit: "km", Default: 3, Min: 0, Max: 10, Step: 0.5},
}},
{Skill: models.AISkill{Skillid: "customer_notifications", Agentid: "CUSTOMER_AGENT", Title: "Customer notifications", Category: "customer", Source: SourceEngine, Enabled: true,
Description: "Tell a customer what is happening to their delivery."},
Tools: []string{"get_booking_cache", "notify_customer"}},
// Console ops layer (on main since Phase 3). Ids and threshold keys match
// lib/assistant/skills/definitions; defaults are copied from them. Every skill
// reads the booking scan; its other tools are the actions its findings propose.
{Skill: models.AISkill{Skillid: "skill_sla_guardian", Agentid: "CONSOLE_OPS_AGENT", Title: "SLA Breach Guardian", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Flags breached and imminent SLA violations against promised delivery ETAs."},
Tools: []string{"scan_bookings", "notify_riders", "assign_riders"},
Schema: []ThresholdSpec{
minutes("slaRiskWindowMin", "At-risk warning window", 45, 15, 90, 5),
minutes("unassignedAgingMin", "Unassigned aging threshold", 60, 15, 120, 5),
}},
{Skill: models.AISkill{Skillid: "skill_doorstep_stall", Agentid: "CONSOLE_OPS_AGENT", Title: "Doorstep Stall Rescuer", Category: "rider_operations", Source: SourceConsole, Enabled: true,
Description: "Flags riders who marked arrival at the doorstep but have made no progress since."},
Tools: []string{"scan_bookings", "notify_riders"},
Schema: []ThresholdSpec{minutes("arrivedStalledMin", "Doorstep stall timeout", 20, 10, 60, 5)}},
{Skill: models.AISkill{Skillid: "skill_fleet_balancer", Agentid: "CONSOLE_OPS_AGENT", Title: "Fleet Load Balancer", Category: "fleet_optimization", Source: SourceConsole, Enabled: true,
Description: "Flags riders at maximum active capacity while queued work waits."},
Tools: []string{"scan_bookings"}, // flags only; its findings propose no action
Schema: []ThresholdSpec{{Key: "riderActiveCap", Label: "Rider active capacity cap", Unit: "orders", Default: 3, Min: 1, Max: 6, Step: 1}}},
{Skill: models.AISkill{Skillid: "skill_high_value_cod", Agentid: "CONSOLE_OPS_AGENT", Title: "High-Value Cash Guardian", Category: "loss_prevention", Source: SourceConsole,
Enabled: false, // no data: /admin/bookings rows carry no payment amount or mode
Description: "Audits large cash-on-delivery consignments. OFF: the booking rows it reads carry no payment amount or mode (bookingpayments is not preloaded), so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "enforce_otp_verification"},
Schema: []ThresholdSpec{{Key: "codRiskThresholdAmount", Label: "High-value COD threshold", Unit: "₹", Default: 3000, Min: 1000, Max: 20000, Step: 500}}},
{Skill: models.AISkill{Skillid: "skill_rider_battery_safety", Agentid: "CONSOLE_OPS_AGENT", Title: "Rider Device & SOS Safety", Category: "rider_safety", Source: SourceConsole,
Enabled: false, // no data: battery lives on milerprofiles, not booking rows
Description: "Warns before a rider becomes unreachable on a flat battery. OFF: battery level is on the rider profile, not on the booking rows it reads, so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "alert_low_battery_rider"},
Schema: []ThresholdSpec{{Key: "criticalBatteryPercent", Label: "Critical battery level", Unit: "%", Default: 15, Min: 5, Max: 30, Step: 5}}},
{Skill: models.AISkill{Skillid: "skill_hub_congestion", Agentid: "CONSOLE_OPS_AGENT", Title: "Hub Congestion Agent", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Detects parcels dwelling at a hub without rider pickup and proposes the nearest idle rider."},
Tools: []string{"scan_bookings", "dispatch_hub_idle_parcels"},
Schema: []ThresholdSpec{minutes("hubDwellMinutes", "Hub dwell threshold", 45, 15, 120, 5)}},
{Skill: models.AISkill{Skillid: "skill_late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late Dispatch Agent", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Flags accepted orders still waiting for dispatch and proposes auto-assignment."},
Tools: []string{"scan_bookings", "trigger_auto_dispatch"},
Schema: []ThresholdSpec{
minutes("lateDispatchMinutes", "Dispatch deadline", 30, 10, 90, 5),
minutes("criticalDispatchMinutes", "Critical dispatch deadline", 60, 30, 180, 10),
}},
{Skill: models.AISkill{Skillid: "skill_cash_exposure", Agentid: "CONSOLE_OPS_AGENT", Title: "Cash Exposure Agent", Category: "loss_prevention", Source: SourceConsole,
Enabled: false, // no data: no COD amount per order in /admin/bookings rows
Description: "Tracks COD cash per rider and proposes a hub handoff. OFF: the booking rows it reads carry no COD amount, so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "enforce_cash_handoff"},
Schema: []ThresholdSpec{
{Key: "maxCashPerRider", Label: "Max safe cash per rider", Unit: "₹", Default: 10000, Min: 2000, Max: 50000, Step: 1000},
{Key: "warningCashPercent", Label: "Warning threshold", Unit: "%", Default: 75, Min: 50, Max: 95, Step: 5},
}},
{Skill: models.AISkill{Skillid: "ops_briefing", Agentid: "CONSOLE_OPS_AGENT", Title: "Ops briefing", Category: "operations", Source: SourceConsole, Enabled: true,
Description: "Answer \"what needs attention right now\" in the chat by running every enabled monitoring skill over the booking scan — the same engine as the Exceptions banner.", Sampleprompt: "What needs attention right now?"},
Tools: []string{"scan_bookings"}},
{Skill: models.AISkill{Skillid: "dispatch_rebalance", Agentid: "CONSOLE_OPS_AGENT", Title: "Dispatch Rebalance & Allocation", Category: "dispatch", Source: SourceConsole,
// Off: rebalance_riders has no implementation behind it.
Enabled: false,
Description: "Move idle riders into zones with a demand spike. Disabled until an endpoint implements it.",
Sampleprompt: "Rebalance available riders into Zone 1 to prevent SLA delays"},
Tools: []string{"rebalance_riders"}},
// Console assistant
{Skill: models.AISkill{Skillid: "order_intake_auto_schedule", Agentid: "CONSOLE_ASSISTANT", Title: "Order Intake & Auto-Schedule", Category: "logistics", Source: SourceConsole, Enabled: true,
Description: "Parse orders from text or a sheet, price them, and create them once the operator confirms.",
Sampleprompt: "Repeat yesterday's orders for Neptune"},
Tools: []string{"create_single_order", "simulate_pricing_quote"}},
}

View File

@@ -0,0 +1,389 @@
package registry
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"doormile/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Seed upserts the code-defined registry. Idempotent, and safe to run on every
// boot: code-defined columns are brought in line with the code, operator-owned
// columns (skill enabled/thresholds, agent autonomous/model) are only written
// when the row is first created. Custom skills are never touched.
func Seed(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for i := range SeedAgents {
a := SeedAgents[i]
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "agentid"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "runtime", "classref", "purpose", "wakeon", "status", "llmdecision", "hasautonomygate", "sortorder"}),
}).Create(&a).Error; err != nil {
return fmt.Errorf("seed agent %s: %w", a.Agentid, err)
}
}
for i := range SeedTools {
t := SeedTools[i]
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "toolname"}},
DoUpdates: clause.AssignmentColumns([]string{"description", "kind", "target", "implementedat", "inputschema", "requiresconfirmation"}),
}).Create(&t).Error; err != nil {
return fmt.Errorf("seed tool %s: %w", t.Toolname, err)
}
}
seededIDs := make([]string, 0, len(SeedSkills))
for _, s := range SeedSkills {
row := s.Skill
row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema))
row.Thresholds = mustJSON(DefaultThresholds(s.Schema))
row.Version = 1
// enabled, thresholds and version are written on first insert only;
// they are operator-owned from then on (see DoUpdates).
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "skillid"}},
DoUpdates: clause.AssignmentColumns([]string{"agentid", "title", "category", "description", "sampleprompt", "source", "thresholdsschema"}),
}).Create(&row).Error; err != nil {
return fmt.Errorf("seed skill %s: %w", row.Skillid, err)
}
seededIDs = append(seededIDs, row.Skillid)
}
// Tool links of seeded skills are code-defined: replace them wholesale.
if err := tx.Where("skillid IN ?", seededIDs).Delete(&models.AISkillTool{}).Error; err != nil {
return fmt.Errorf("seed skill tools: %w", err)
}
var links []models.AISkillTool
for _, s := range SeedSkills {
for _, t := range s.Tools {
links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: t})
}
}
if len(links) > 0 {
if err := tx.Create(&links).Error; err != nil {
return fmt.Errorf("seed skill tools: %w", err)
}
}
return nil
})
}
func schemaOrEmpty(s []ThresholdSpec) []ThresholdSpec {
if s == nil {
return []ThresholdSpec{}
}
return s
}
// AgentView is an agent as the API returns it.
type AgentView struct {
models.AIAgent
Skillcount int `json:"skillcount"`
Toolcount int `json:"toolcount"`
}
// ToolView is a tool with its input schema as real JSON.
type ToolView struct {
models.AITool
Inputschema json.RawMessage `json:"inputschema"`
}
// SkillView is a skill with its tools and its EFFECTIVE thresholds: stored
// values where still valid, defaults otherwise.
type SkillView struct {
models.AISkill
Tools []string `json:"tools"`
Thresholds map[string]float64 `json:"thresholds"`
Thresholdsschema []ThresholdSpec `json:"thresholdsschema"`
}
// Snapshot is the whole registry. Small by construction (tens of rows), so it
// is always read whole — a fixed four queries — and filtered in memory.
type Snapshot struct {
Agents []AgentView `json:"agents"`
Skills []SkillView `json:"skills"`
Tools []ToolView `json:"tools"`
}
// Load reads the registry.
func Load(db *gorm.DB) (*Snapshot, error) {
var agents []models.AIAgent
var tools []models.AITool
var skills []models.AISkill
var links []models.AISkillTool
if err := db.Order("sortorder, agentid").Find(&agents).Error; err != nil {
return nil, err
}
if err := db.Order("toolname").Find(&tools).Error; err != nil {
return nil, err
}
if err := db.Order("agentid, skillid").Find(&skills).Error; err != nil {
return nil, err
}
if err := db.Order("skillid, toolname").Find(&links).Error; err != nil {
return nil, err
}
return Build(agents, tools, skills, links), nil
}
// Build assembles a Snapshot from rows. Pure; Load is its only database step.
func Build(agents []models.AIAgent, tools []models.AITool, skills []models.AISkill, links []models.AISkillTool) *Snapshot {
toolsBySkill := map[string][]string{}
for _, l := range links {
toolsBySkill[l.Skillid] = append(toolsBySkill[l.Skillid], l.Toolname)
}
snap := &Snapshot{Agents: []AgentView{}, Skills: []SkillView{}, Tools: []ToolView{}}
skillCount := map[string]int{}
agentTools := map[string]map[string]bool{}
for _, s := range skills {
schema := ParseSchema(s.Thresholdsschema)
ts := toolsBySkill[s.Skillid]
if ts == nil {
ts = []string{}
}
snap.Skills = append(snap.Skills, SkillView{
AISkill: s,
Tools: ts,
Thresholds: EffectiveThresholds(schema, ParseThresholds(s.Thresholds)),
Thresholdsschema: schema,
})
skillCount[s.Agentid]++
if agentTools[s.Agentid] == nil {
agentTools[s.Agentid] = map[string]bool{}
}
for _, t := range ts {
agentTools[s.Agentid][t] = true
}
}
for _, a := range agents {
snap.Agents = append(snap.Agents, AgentView{AIAgent: a, Skillcount: skillCount[a.Agentid], Toolcount: len(agentTools[a.Agentid])})
}
for _, t := range tools {
raw := json.RawMessage(t.Inputschema)
if !json.Valid(raw) {
raw = json.RawMessage(`{"type":"object"}`)
}
snap.Tools = append(snap.Tools, ToolView{AITool: t, Inputschema: raw})
}
return snap
}
// ETag fingerprints a snapshot, so the engine can poll with If-None-Match and
// get a 304 until an operator actually changes something.
func ETag(s *Snapshot) string {
b, _ := json.Marshal(s)
sum := sha256.Sum256(b)
return `W/"` + hex.EncodeToString(sum[:8]) + `"`
}
func audit(tx *gorm.DB, entity, id, field string, oldV, newV any, actor Actor) error {
return tx.Create(&models.AIRegistryAudit{
Entity: entity, Entityid: id, Field: field,
Oldvalue: mustJSON(oldV), Newvalue: mustJSON(newV), Changedby: actor.UserID, Changedbyemail: actor.Email,
}).Error
}
// UpdateSkill applies an operator's patch. Row-locked, audited in the same
// transaction, and a no-op (no version bump, no audit) when nothing changes.
func UpdateSkill(db *gorm.DB, skillID string, p SkillPatch, actor Actor) error {
if p.Enabled == nil && p.Thresholds == nil {
return invalid("nothing to change: send enabled and/or thresholds")
}
return db.Transaction(func(tx *gorm.DB) error {
var s models.AISkill
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("skillid = ?", skillID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
updates := map[string]any{}
if p.Enabled != nil && *p.Enabled != s.Enabled {
if err := audit(tx, "skill", s.Skillid, "enabled", s.Enabled, *p.Enabled, actor); err != nil {
return err
}
updates["enabled"] = *p.Enabled
}
if p.Thresholds != nil {
schema := ParseSchema(s.Thresholdsschema)
current := EffectiveThresholds(schema, ParseThresholds(s.Thresholds))
next, err := ApplyThresholdPatch(schema, current, p.Thresholds)
if err != nil {
return err
}
if mustJSON(next) != mustJSON(current) {
if err := audit(tx, "skill", s.Skillid, "thresholds", current, next, actor); err != nil {
return err
}
updates["thresholds"] = mustJSON(next)
}
}
if len(updates) == 0 {
return nil
}
updates["version"] = gorm.Expr("version + 1")
updates["updatedby"] = actor.UserID
updates["updatedat"] = time.Now()
return tx.Model(&models.AISkill{}).Where("skillid = ?", s.Skillid).Updates(updates).Error
})
}
// UpdateAgent applies an operator's patch to an agent's autonomy or model.
func UpdateAgent(db *gorm.DB, agentID string, p AgentPatch, actor Actor) error {
return db.Transaction(func(tx *gorm.DB) error {
var a models.AIAgent
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("agentid = ?", agentID).First(&a).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if err := CheckAgentPatch(a, p); err != nil {
return err
}
updates := map[string]any{}
if p.Autonomous != nil && *p.Autonomous != a.Autonomous {
if err := audit(tx, "agent", a.Agentid, "autonomous", a.Autonomous, *p.Autonomous, actor); err != nil {
return err
}
updates["autonomous"] = *p.Autonomous
}
if p.Model != nil && *p.Model != a.Model {
if err := audit(tx, "agent", a.Agentid, "model", a.Model, *p.Model, actor); err != nil {
return err
}
updates["model"] = *p.Model
}
if len(updates) == 0 {
return nil
}
updates["updatedby"] = actor.UserID
updates["updatedat"] = time.Now()
return tx.Model(&models.AIAgent{}).Where("agentid = ?", a.Agentid).Updates(updates).Error
})
}
// CreateSkill registers an operator-made skill on a console agent, built only
// from tools that exist. Returns the new skill id.
//
// Engine agents are refused: AI_engine runs only the skills written in its
// code, so a custom skill attached to one would do nothing while looking live.
func CreateSkill(db *gorm.DB, n NewSkill, actor Actor) (string, error) {
if err := CheckNewSkill(n); err != nil {
return "", err
}
var id string
err := db.Transaction(func(tx *gorm.DB) error {
var a models.AIAgent
if err := tx.Where("agentid = ?", n.Agentid).First(&a).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return invalid("agent %s does not exist", n.Agentid)
}
return err
}
if a.Runtime != RuntimeConsole {
return invalid("%s runs in AI_engine, which only runs the skills in its code; custom skills can be added to console agents only", a.Agentid)
}
var found []string
if err := tx.Model(&models.AITool{}).Where("toolname IN ?", n.Tools).Pluck("toolname", &found).Error; err != nil {
return err
}
if len(found) != len(n.Tools) {
have := map[string]bool{}
for _, f := range found {
have[f] = true
}
var missing []string
for _, t := range n.Tools {
if !have[t] {
missing = append(missing, t)
}
}
return invalid("unknown tools: %s", strings.Join(missing, ", "))
}
base := customSkillID(n.Title)
id = base
for i := 2; ; i++ {
var count int64
if err := tx.Model(&models.AISkill{}).Where("skillid = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
break
}
if i > 50 {
return invalid("too many skills are already called %q", strings.TrimSpace(n.Title))
}
id = fmt.Sprintf("%s_%d", base, i)
}
uid := actor.UserID
row := models.AISkill{
Skillid: id, Agentid: a.Agentid, Title: strings.TrimSpace(n.Title), Category: strings.TrimSpace(n.Category),
Description: strings.TrimSpace(n.Description), Sampleprompt: strings.TrimSpace(n.Sampleprompt),
Source: SourceCustom, Enabled: true, Thresholds: "{}", Thresholdsschema: "[]", Version: 1, Updatedby: &uid,
}
if err := tx.Create(&row).Error; err != nil {
return err
}
links := make([]models.AISkillTool, 0, len(n.Tools))
for _, t := range n.Tools {
links = append(links, models.AISkillTool{Skillid: id, Toolname: t})
}
if err := tx.Create(&links).Error; err != nil {
return err
}
return audit(tx, "skill", id, "created", nil, map[string]any{"agentid": a.Agentid, "title": row.Title, "tools": n.Tools}, actor)
})
if err != nil {
return "", err
}
return id, nil
}
// AuditView is an audit row with its values as real JSON.
type AuditView struct {
models.AIRegistryAudit
Oldvalue json.RawMessage `json:"oldvalue"`
Newvalue json.RawMessage `json:"newvalue"`
}
// ListAudit returns the most recent registry changes, newest first.
func ListAudit(db *gorm.DB, limit int) ([]AuditView, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
var rows []models.AIRegistryAudit
if err := db.Order("changedat DESC, auditid DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
out := make([]AuditView, 0, len(rows))
for _, r := range rows {
out = append(out, AuditView{AIRegistryAudit: r, Oldvalue: rawOrNull(r.Oldvalue), Newvalue: rawOrNull(r.Newvalue)})
}
return out, nil
}
func rawOrNull(s string) json.RawMessage {
if s == "" || !json.Valid([]byte(s)) {
return json.RawMessage("null")
}
return json.RawMessage(s)
}

View File

@@ -0,0 +1,280 @@
package registry
import (
"os"
"strings"
"testing"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// Integration tests: the real SQL (upsert seed, row locks, jsonb, audit)
// against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set.
//
// The DSN must point at a THROWAWAY database: these tests DROP and recreate
// the five registry tables. Never point it at a shared or production database.
// For example, with a disposable container:
//
// docker run --rm -d --name dm-registry-pg -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:16-alpine
// REGISTRY_TEST_DSN="host=127.0.0.1 port=55432 user=postgres password=test dbname=postgres sslmode=disable" go test ./internal/ai/registry/
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "airegistry_store_test")
all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}}
if err := db.Migrator().DropTable(all...); err != nil {
t.Fatalf("drop: %v", err)
}
if err := db.AutoMigrate(all...); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := Seed(db); err != nil {
t.Fatalf("seed: %v", err)
}
return db
}
func skillRow(t *testing.T, db *gorm.DB, id string) models.AISkill {
t.Helper()
var s models.AISkill
if err := db.Where("skillid = ?", id).First(&s).Error; err != nil {
t.Fatalf("read skill %s: %v", id, err)
}
return s
}
func count(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var n int64
if err := db.Model(model).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func TestPGSeedIsCompleteAndIdempotent(t *testing.T) {
db := testDB(t)
links := 0
for _, s := range SeedSkills {
links += len(s.Tools)
}
for i := 0; i < 2; i++ { // the second pass must change nothing
if i == 1 {
if err := Seed(db); err != nil {
t.Fatalf("second seed: %v", err)
}
}
if n := count(t, db, &models.AIAgent{}); n != int64(len(SeedAgents)) {
t.Errorf("pass %d: %d agents, want %d", i+1, n, len(SeedAgents))
}
if n := count(t, db, &models.AITool{}); n != int64(len(SeedTools)) {
t.Errorf("pass %d: %d tools, want %d", i+1, n, len(SeedTools))
}
if n := count(t, db, &models.AISkill{}); n != int64(len(SeedSkills)) {
t.Errorf("pass %d: %d skills, want %d", i+1, n, len(SeedSkills))
}
if n := count(t, db, &models.AISkillTool{}); n != int64(links) {
t.Errorf("pass %d: %d skill-tool links, want %d", i+1, n, links)
}
}
if n := count(t, db, &models.AIRegistryAudit{}); n != 0 {
t.Errorf("seeding wrote %d audit rows; only operator changes are audited", n)
}
}
// gorm drops a false bool that has a `default:true` tag from an INSERT. The
// seed selects columns explicitly so a skill seeded off really is off.
func TestPGSkillSeededOffStaysOff(t *testing.T) {
db := testDB(t)
if skillRow(t, db, "dispatch_rebalance").Enabled {
t.Fatal("dispatch_rebalance came up enabled in the database")
}
}
func TestPGLoadReturnsTheInventory(t *testing.T) {
db := testDB(t)
snap, err := Load(db)
if err != nil {
t.Fatal(err)
}
if len(snap.Agents) != len(SeedAgents) || len(snap.Skills) != len(SeedSkills) || len(snap.Tools) != len(SeedTools) {
t.Fatalf("snapshot sizes %d/%d/%d", len(snap.Agents), len(snap.Skills), len(snap.Tools))
}
if snap.Agents[0].Agentid != "JARVIS" {
t.Errorf("agents not in sort order: first is %s", snap.Agents[0].Agentid)
}
for _, s := range snap.Skills {
if s.Skillid == "skill_cash_exposure" && s.Thresholds["maxCashPerRider"] != 10000 {
t.Errorf("jsonb thresholds did not round-trip: %v", s.Thresholds)
}
}
}
func TestPGUpdateSkillIsAuditedAndVersioned(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false)}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
s := skillRow(t, db, "skill_sla_guardian")
if s.Enabled || s.Version != 2 || s.Updatedby == nil || *s.Updatedby != 42 {
t.Fatalf("after disable: enabled=%v version=%d updatedby=%v", s.Enabled, s.Version, s.Updatedby)
}
// The same patch again is a no-op: no version bump, no second audit row.
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false)}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if v := skillRow(t, db, "skill_sla_guardian").Version; v != 2 {
t.Errorf("a no-op patch bumped the version to %d", v)
}
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Thresholds: map[string]any{"slaRiskWindowMin": 30.0}}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
got := ParseThresholds(skillRow(t, db, "skill_sla_guardian").Thresholds)
if got["slaRiskWindowMin"] != 30 || got["unassignedAgingMin"] != 60 {
t.Errorf("thresholds after patch = %v", got)
}
rows, err := ListAudit(db, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].Field != "thresholds" || rows[1].Field != "enabled" {
t.Fatalf("audit = %+v, want [thresholds, enabled] newest first", rows)
}
if string(rows[1].Oldvalue) != "true" || string(rows[1].Newvalue) != "false" || rows[1].Changedby != 42 || rows[1].Changedbyemail != "tester@doormile.test" {
t.Errorf("enabled audit row = old %s new %s by %d", rows[1].Oldvalue, rows[1].Newvalue, rows[1].Changedby)
}
}
// A refused patch writes nothing — including the valid half of it.
func TestPGRefusedPatchChangesNothing(t *testing.T) {
db := testDB(t)
err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false), Thresholds: map[string]any{"slaRiskWindowMin": 999.0}}, Actor{UserID: 42, Email: "tester@doormile.test"})
if !isValidation(err) {
t.Fatalf("want a ValidationError, got %v", err)
}
s := skillRow(t, db, "skill_sla_guardian")
if !s.Enabled || s.Version != 1 {
t.Errorf("refused patch leaked: enabled=%v version=%d", s.Enabled, s.Version)
}
if n := count(t, db, &models.AIRegistryAudit{}); n != 0 {
t.Errorf("refused patch wrote %d audit rows", n)
}
}
func TestPGUpdateUnknownSkillIsNotFound(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "no_such_skill", SkillPatch{Enabled: boolp(true)}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != ErrNotFound {
t.Errorf("got %v, want ErrNotFound", err)
}
}
// Re-seeding (every boot) must never undo an operator's decision.
func TestPGReseedKeepsOperatorChanges(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "skill_fleet_balancer", SkillPatch{Enabled: boolp(false), Thresholds: map[string]any{"riderActiveCap": 5.0}}, Actor{UserID: 7, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Model: strp("claude-haiku-4-5-20251001")}, Actor{UserID: 7, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if err := Seed(db); err != nil {
t.Fatal(err)
}
s := skillRow(t, db, "skill_fleet_balancer")
if s.Enabled || ParseThresholds(s.Thresholds)["riderActiveCap"] != 5 || s.Version != 2 {
t.Errorf("re-seed reverted the operator's skill change: enabled=%v thresholds=%s version=%d", s.Enabled, s.Thresholds, s.Version)
}
var a models.AIAgent
db.Where("agentid = ?", "EXCEPTION_AGENT").First(&a)
if a.Model != "claude-haiku-4-5-20251001" {
t.Errorf("re-seed reverted the agent model to %q", a.Model)
}
}
func TestPGAutonomyNeedsConfirmationAndIsAudited(t *testing.T) {
db := testDB(t)
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Autonomous: boolp(true)}, Actor{UserID: 1, Email: "tester@doormile.test"}); !isValidation(err) {
t.Fatalf("autonomy switched on without confirmation: %v", err)
}
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
var a models.AIAgent
db.Where("agentid = ?", "EXCEPTION_AGENT").First(&a)
if !a.Autonomous {
t.Fatal("autonomy was not saved")
}
rows, _ := ListAudit(db, 5)
if len(rows) != 1 || rows[0].Entity != "agent" || rows[0].Field != "autonomous" {
t.Errorf("autonomy change not audited: %+v", rows)
}
if err := UpdateAgent(db, "HUB_AGENT", AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}, Actor{UserID: 1, Email: "tester@doormile.test"}); !isValidation(err) {
t.Errorf("autonomy set on an ungated agent: %v", err)
}
}
func TestPGCreateSkill(t *testing.T) {
db := testDB(t)
n := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"scan_bookings", "notify_riders"}}
id, err := CreateSkill(db, n, Actor{UserID: 9, Email: "tester@doormile.test"})
if err != nil || id != "custom_night_shift_watch" {
t.Fatalf("create = %q, %v", id, err)
}
id2, err := CreateSkill(db, n, Actor{UserID: 9, Email: "tester@doormile.test"})
if err != nil || id2 != "custom_night_shift_watch_2" {
t.Fatalf("second create with the same title = %q, %v", id2, err)
}
s := skillRow(t, db, id)
if s.Source != SourceCustom || !s.Enabled {
t.Errorf("custom skill row = %+v", s)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "EXCEPTION_AGENT", Title: "x", Tools: []string{"scan_bookings"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) || !strings.Contains(err.Error(), "console agents only") {
t.Errorf("custom skill on an engine agent: %v", err)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "x", Tools: []string{"scan_bookings", "launch_rockets"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) || !strings.Contains(err.Error(), "launch_rockets") {
t.Errorf("unknown tool not named: %v", err)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "NOBODY", Title: "x", Tools: []string{"scan_bookings"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) {
t.Errorf("unknown agent accepted: %v", err)
}
// Custom skills survive a re-seed untouched.
if err := Seed(db); err != nil {
t.Fatal(err)
}
var links int64
db.Model(&models.AISkillTool{}).Where("skillid = ?", id).Count(&links)
if links != 2 {
t.Errorf("re-seed touched a custom skill's tools: %d links", links)
}
}
func TestPGETagMovesOnlyWithRealChanges(t *testing.T) {
db := testDB(t)
before, _ := Load(db)
if err := Seed(db); err != nil {
t.Fatal(err)
}
same, _ := Load(db)
if ETag(before) != ETag(same) {
t.Error("a re-seed with no code change moved the ETag; the engine would refetch on every boot")
}
if err := UpdateSkill(db, "skill_doorstep_stall", SkillPatch{Thresholds: map[string]any{"arrivedStalledMin": 30.0}}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
after, _ := Load(db)
if ETag(before) == ETag(after) {
t.Error("an operator change did not move the ETag")
}
}

View File

@@ -0,0 +1,152 @@
package registry
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// ThresholdSpec is one tunable number on a skill: its default and the range an
// operator may move it within. The ranges are the product's safety rails — a
// stall timeout of 0 minutes or a cash cap of ₹10 lakh is a typo, not a policy.
type ThresholdSpec struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Default float64 `json:"default"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Step float64 `json:"step"`
}
// onStep reports whether v sits on the spec's step grid, measured from Min.
// Tolerant of float noise: 0.7 must pass a 0.05 step starting at 0.5.
func (s ThresholdSpec) onStep(v float64) bool {
if s.Step <= 0 {
return true
}
n := (v - s.Min) / s.Step
return math.Abs(n-math.Round(n)) < 1e-6
}
func (s ThresholdSpec) check(v float64) error {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("%s must be a number", s.Key)
}
if v < s.Min || v > s.Max {
return fmt.Errorf("%s must be between %s and %s", s.Key, fmtNum(s.Min), fmtNum(s.Max))
}
if !s.onStep(v) {
return fmt.Errorf("%s must move in steps of %s from %s", s.Key, fmtNum(s.Step), fmtNum(s.Min))
}
return nil
}
func fmtNum(v float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", v), "0"), ".")
}
// EffectiveThresholds is what a skill actually runs with: the stored value for
// every key the schema still defines and that is still in range, the default
// for everything else. Keys the schema no longer has are dropped. A schema
// change in code therefore never strands a skill on a value it can no longer
// validate, and never needs a data migration.
func EffectiveThresholds(schema []ThresholdSpec, stored map[string]float64) map[string]float64 {
out := make(map[string]float64, len(schema))
for _, s := range schema {
v, ok := stored[s.Key]
if !ok || s.check(v) != nil {
v = s.Default
}
out[s.Key] = v
}
return out
}
// ApplyThresholdPatch validates an operator's patch against the schema and
// returns the full resulting set. Unknown keys and non-numbers are refused, not
// ignored: a misspelt key silently doing nothing is exactly the failure an
// operator cannot see. All keys are checked before any error is returned, so
// the message lists every problem at once.
func ApplyThresholdPatch(schema []ThresholdSpec, current map[string]float64, patch map[string]any) (map[string]float64, error) {
byKey := make(map[string]ThresholdSpec, len(schema))
for _, s := range schema {
byKey[s.Key] = s
}
next := EffectiveThresholds(schema, current)
var problems []string
keys := make([]string, 0, len(patch))
for k := range patch {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
spec, known := byKey[k]
if !known {
problems = append(problems, fmt.Sprintf("%s is not a threshold of this skill", k))
continue
}
v, isNum := patch[k].(float64)
if !isNum {
problems = append(problems, fmt.Sprintf("%s must be a number", k))
continue
}
if err := spec.check(v); err != nil {
problems = append(problems, err.Error())
continue
}
next[k] = v
}
if len(problems) > 0 {
return nil, &ValidationError{Msg: strings.Join(problems, "; ")}
}
return next, nil
}
// ParseThresholds reads a stored jsonb thresholds document. Empty, null or
// malformed reads as "nothing stored", which EffectiveThresholds turns into
// the defaults — a bad row degrades to defaults rather than failing a read.
func ParseThresholds(raw string) map[string]float64 {
out := map[string]float64{}
if strings.TrimSpace(raw) == "" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
if out == nil {
out = map[string]float64{}
}
return out
}
// ParseSchema reads a stored thresholds schema. Malformed reads as no schema.
func ParseSchema(raw string) []ThresholdSpec {
var out []ThresholdSpec
if strings.TrimSpace(raw) == "" {
return []ThresholdSpec{}
}
if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil {
return []ThresholdSpec{}
}
return out
}
// DefaultThresholds is the value set a freshly seeded skill starts with.
func DefaultThresholds(schema []ThresholdSpec) map[string]float64 {
return EffectiveThresholds(schema, nil)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
// Only ever called on values built in this package; a failure here is
// a programming error, and the seed tests exercise every one.
panic(err)
}
return string(b)
}

View File

@@ -0,0 +1,228 @@
package telemetry
import (
"context"
"encoding/json"
"sort"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// AgentRunStats is one agent's runs over the window.
type AgentRunStats struct {
Agentid string `json:"agentid"`
Runs int64 `json:"runs"`
Failed int64 `json:"failed"`
Avgdurationms float64 `json:"avgdurationms"`
Lastrunat *time.Time `json:"lastrunat"`
}
// DecisionCount is decisions of one type with one outcome over the window.
// Outcome is "pending" while none has been recorded.
type DecisionCount struct {
Decisiontype string `json:"decisiontype"`
Outcome string `json:"outcome"`
Count int64 `json:"count"`
}
// DecisionTypeStats rolls DecisionCount rows up per type.
type DecisionTypeStats struct {
Decisiontype string `json:"decisiontype"`
Total int64 `json:"total"`
Outcomes map[string]int64 `json:"outcomes"`
}
// Insights is everything the Insights tab shows.
type Insights struct {
Days int `json:"days"`
Since time.Time `json:"since"`
// Receiving is whether this backend is subscribed to AI_engine telemetry.
// False means an empty run list is "not connected", not "no activity".
Receiving bool `json:"receiving"`
Runs RunsSummary `json:"runs"`
Decisions DecisionSummary `json:"decisions"`
Live []AgentState `json:"live"`
}
type RunsSummary struct {
Total int64 `json:"total"`
Failed int64 `json:"failed"`
PerAgent []AgentRunStats `json:"peragent"`
}
type DecisionSummary struct {
Total int64 `json:"total"`
ByType []DecisionTypeStats `json:"bytype"`
}
// ClampDays keeps the window to what the table retains.
func ClampDays(days int) int {
switch {
case days < 1:
return 7
case days > RetentionDays:
return RetentionDays
default:
return days
}
}
// SummariseRuns totals per-agent rows, busiest agent first.
func SummariseRuns(rows []AgentRunStats) RunsSummary {
out := RunsSummary{PerAgent: append([]AgentRunStats{}, rows...)}
for _, r := range rows {
out.Total += r.Runs
out.Failed += r.Failed
}
sort.SliceStable(out.PerAgent, func(i, j int) bool {
if out.PerAgent[i].Runs != out.PerAgent[j].Runs {
return out.PerAgent[i].Runs > out.PerAgent[j].Runs
}
return out.PerAgent[i].Agentid < out.PerAgent[j].Agentid
})
return out
}
// SummariseDecisions rolls (type, outcome) counts up per type, largest first.
func SummariseDecisions(rows []DecisionCount) DecisionSummary {
byType := map[string]*DecisionTypeStats{}
var order []string
var total int64
for _, r := range rows {
st, ok := byType[r.Decisiontype]
if !ok {
st = &DecisionTypeStats{Decisiontype: r.Decisiontype, Outcomes: map[string]int64{}}
byType[r.Decisiontype] = st
order = append(order, r.Decisiontype)
}
st.Total += r.Count
st.Outcomes[r.Outcome] += r.Count
total += r.Count
}
out := DecisionSummary{Total: total, ByType: make([]DecisionTypeStats, 0, len(order))}
for _, k := range order {
out.ByType = append(out.ByType, *byType[k])
}
sort.SliceStable(out.ByType, func(i, j int) bool {
if out.ByType[i].Total != out.ByType[j].Total {
return out.ByType[i].Total > out.ByType[j].Total
}
return out.ByType[i].Decisiontype < out.ByType[j].Decisiontype
})
return out
}
// RunStats reads per-agent run statistics since the cutoff. A fixed single
// grouped query, whatever the volume.
func RunStats(db *gorm.DB, since time.Time) ([]AgentRunStats, error) {
var rows []AgentRunStats
err := db.Table("aiagentruns").
Select(`agentid,
COUNT(*) AS runs,
COUNT(*) FILTER (WHERE status <> 'completed') AS failed,
COALESCE(AVG(durationms), 0) AS avgdurationms,
MAX(receivedat) AS lastrunat`).
Where("receivedat >= ?", since).
Group("agentid").
Scan(&rows).Error
return rows, err
}
// DecisionCounts reads agent_decisions grouped by type and outcome. The table
// is written by the decision engine through POST /internal/agent-decisions.
func DecisionCounts(db *gorm.DB, since time.Time) ([]DecisionCount, error) {
var rows []DecisionCount
err := db.Table("agent_decisions").
Select("decision_type AS decisiontype, COALESCE(outcome, 'pending') AS outcome, COUNT(*) AS count").
Where("created_at >= ?", since).
Group("decision_type, COALESCE(outcome, 'pending')").
Scan(&rows).Error
return rows, err
}
// LiveStates reads the latest heartbeat of each agent that has one. Missing
// keys (an agent silent for over five minutes) are simply absent.
func LiveStates(rdb *redis.Client, agentIDs []string) []AgentState {
out := []AgentState{}
if rdb == nil || len(agentIDs) == 0 {
return out
}
keys := make([]string, len(agentIDs))
for i, id := range agentIDs {
keys[i] = StateKey(id)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
vals, err := rdb.MGet(ctx, keys...).Result()
if err != nil {
return out
}
for _, v := range vals {
s, ok := v.(string)
if !ok {
continue
}
var st AgentState
if json.Unmarshal([]byte(s), &st) == nil && st.AgentID != "" {
out = append(out, st)
}
}
return out
}
// DecisionRow is one recent decision, as the Insights list shows it.
type DecisionRow struct {
ID uint64 `json:"id"`
Decisiontype string `json:"decisiontype"`
Bookingid *uint64 `json:"bookingid"`
Decision json.RawMessage `json:"decision"`
Reasoning string `json:"reasoning"`
Outcome *string `json:"outcome"`
Createdat time.Time `json:"createdat"`
}
type decisionScan struct {
ID uint64
Decisiontype string
Bookingid *uint64
Decision string
Reasoning string
Outcome *string
Createdat time.Time
}
// RecentDecisions pages agent_decisions newest first. beforeID (0 = start)
// is a keyset cursor, so a page is stable while new rows arrive. The context
// column is deliberately not returned: it can be large and holds rider data.
func RecentDecisions(db *gorm.DB, decisionType string, beforeID uint64, limit int) ([]DecisionRow, error) {
if limit <= 0 || limit > 100 {
limit = 25
}
q := db.Table("agent_decisions").
Select("id, decision_type AS decisiontype, booking_id AS bookingid, COALESCE(decision::text, 'null') AS decision, reasoning, outcome, created_at AS createdat").
Order("id DESC").Limit(limit)
if decisionType != "" {
q = q.Where("decision_type = ?", decisionType)
}
if beforeID > 0 {
q = q.Where("id < ?", beforeID)
}
var rows []decisionScan
if err := q.Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]DecisionRow, 0, len(rows))
for _, r := range rows {
raw := json.RawMessage(r.Decision)
if !json.Valid(raw) {
raw = json.RawMessage("null")
}
out = append(out, DecisionRow{
ID: r.ID, Decisiontype: r.Decisiontype, Bookingid: r.Bookingid, Decision: raw,
Reasoning: truncate(r.Reasoning, 1000), Outcome: r.Outcome, Createdat: r.Createdat,
})
}
return out, nil
}

View File

@@ -0,0 +1,146 @@
// Package telemetry records what AI_engine's agents actually do.
//
// AI_engine publishes two fire-and-forget subjects on plain NATS
// (core/message_bus.py publish_telemetry):
//
// telemetry.task — after every task: agent, task id/type, status, error, duration
// telemetry.agent — every ~5 s per agent: status, current task, counters
//
// Tasks become rows in aiagentruns (Postgres); agent state goes to Redis with a
// short TTL. Both are observability only: nothing here may slow a booking or
// fail a request, and a malformed event is dropped, never guessed at.
package telemetry
import (
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"doormile/models"
)
const (
maxIDLen = 64
maxErrorLen = 2000
)
// ErrInvalid is returned for an event that cannot be recorded as-is.
var ErrInvalid = errors.New("invalid telemetry event")
type taskEvent struct {
TS string `json:"ts"`
AgentID string `json:"agent_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Status string `json:"status"`
Error *string `json:"error"`
DurationMS float64 `json:"duration_ms"`
}
// engineTimeLayouts are what Python's datetime.now().isoformat() produces:
// a naive local time, with or without microseconds.
var engineTimeLayouts = []string{"2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"}
func parseEngineTime(s string) *time.Time {
for _, layout := range engineTimeLayouts {
if t, err := time.Parse(layout, s); err == nil {
return &t
}
}
return nil
}
// truncate cuts s to at most n bytes without splitting a UTF-8 rune.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
s = s[:n]
for !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ParseTask turns a telemetry.task payload into a run row stamped with
// receivedAt. It refuses — rather than repairs — an event with no agent id or
// no status, or an id too long to be one: a run attributed to the wrong agent
// is worse than a missing one.
func ParseTask(body []byte, receivedAt time.Time) (models.AIAgentRun, error) {
var ev taskEvent
if err := json.Unmarshal(body, &ev); err != nil {
return models.AIAgentRun{}, ErrInvalid
}
agent := strings.TrimSpace(ev.AgentID)
status := strings.ToLower(strings.TrimSpace(ev.Status))
if agent == "" || len(agent) > maxIDLen || status == "" || len(status) > 20 {
return models.AIAgentRun{}, ErrInvalid
}
run := models.AIAgentRun{
Agentid: agent,
Tasktype: truncate(strings.TrimSpace(ev.TaskType), maxIDLen),
Status: status,
Durationms: int(ev.DurationMS),
Occurredat: parseEngineTime(ev.TS),
Receivedat: receivedAt,
}
if run.Durationms < 0 {
run.Durationms = 0
}
if id := strings.TrimSpace(ev.TaskID); id != "" {
id = truncate(id, maxIDLen)
run.Taskid = &id
}
if ev.Error != nil {
run.Error = truncate(*ev.Error, maxErrorLen)
}
return run, nil
}
// AgentState is an agent's latest telemetry.agent heartbeat, as kept in Redis.
type AgentState struct {
AgentID string `json:"agentid"`
Status string `json:"status"`
CurrentTask *string `json:"currenttask"`
TasksCompleted int64 `json:"taskscompleted"`
TasksFailed int64 `json:"tasksfailed"`
LastSeenAt time.Time `json:"lastseenat"`
}
type agentEvent struct {
AgentID string `json:"agent_id"`
Status string `json:"status"`
CurrentTask *string `json:"current_task"`
TasksCompleted int64 `json:"tasks_completed"`
TasksFailed int64 `json:"tasks_failed"`
}
// ParseAgent turns a telemetry.agent payload into the state kept for it.
func ParseAgent(body []byte, seenAt time.Time) (AgentState, error) {
var ev agentEvent
if err := json.Unmarshal(body, &ev); err != nil {
return AgentState{}, ErrInvalid
}
agent := strings.TrimSpace(ev.AgentID)
if agent == "" || len(agent) > maxIDLen {
return AgentState{}, ErrInvalid
}
st := AgentState{
AgentID: agent,
Status: truncate(strings.TrimSpace(ev.Status), 20),
TasksCompleted: ev.TasksCompleted,
TasksFailed: ev.TasksFailed,
LastSeenAt: seenAt,
}
if ev.CurrentTask != nil {
t := truncate(*ev.CurrentTask, maxIDLen)
st.CurrentTask = &t
}
return st, nil
}
// StateKey is the Redis key an agent's latest state lives under.
func StateKey(agentID string) string { return "ai:agent:state:" + agentID }

View File

@@ -0,0 +1,169 @@
package telemetry
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"doormile/models"
"doormile/utils"
"github.com/nats-io/nats.go"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
// QueueGroup makes each event land on exactly one backend replica, so a
// run is written once however many pods subscribe.
QueueGroup = "doormile-backend-telemetry"
bufferSize = 2000
flushEvery = 2 * time.Second
flushAt = 200
stateTTL = 5 * time.Minute
RetentionDays = 30
)
// Recorder buffers runs and writes them in batches. A NATS callback must never
// block on Postgres: a slow database would back up the subscription and, in
// nats.go, eventually mark it a slow consumer. So the callback only enqueues,
// and a full buffer drops (counted, logged) rather than waits.
type Recorder struct {
db *gorm.DB
rdb *redis.Client
runs chan models.AIAgentRun
now func() time.Time
dropped atomic.Int64
written atomic.Int64
once sync.Once
}
// NewRecorder builds a recorder. rdb may be nil: agent state is then not kept.
func NewRecorder(db *gorm.DB, rdb *redis.Client) *Recorder {
// time.Now, NOT utils.DBNow. DBNow returns IST digits labelled UTC, which
// is right only for the legacy timestamp-WITHOUT-time-zone columns. This
// table is created by AutoMigrate, so its columns are timestamptz, and a
// DBNow value lands 5h30m in the future — caught by the Phase 4 end-to-end
// run, where a run received at 20:57 IST read back as 02:27 next day.
return &Recorder{db: db, rdb: rdb, runs: make(chan models.AIAgentRun, bufferSize), now: time.Now}
}
// Enqueue offers a run to the writer without blocking. It reports whether the
// run was accepted.
func (r *Recorder) Enqueue(run models.AIAgentRun) bool {
select {
case r.runs <- run:
return true
default:
if n := r.dropped.Add(1); n == 1 || n%500 == 0 {
utils.Error("ai telemetry: run buffer full, dropping", "dropped_total", n)
}
return false
}
}
// HandleTask is the telemetry.task callback.
func (r *Recorder) HandleTask(body []byte) {
run, err := ParseTask(body, r.now())
if err != nil {
return
}
r.Enqueue(run)
}
// HandleAgent is the telemetry.agent callback. Best effort: Redis down means
// the Insights page shows no live state, never that a request fails.
func (r *Recorder) HandleAgent(body []byte) {
if r.rdb == nil {
return
}
st, err := ParseAgent(body, r.now())
if err != nil {
return
}
b, _ := json.Marshal(st)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = r.rdb.Set(ctx, StateKey(st.AgentID), b, stateTTL).Err()
}
// Flush writes whatever is buffered. Duplicates of an (agent, task id) pair —
// a redelivery — are ignored by the unique index.
func (r *Recorder) Flush(batch []models.AIAgentRun) {
if len(batch) == 0 {
return
}
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(batch, 200).Error; err != nil {
utils.Error("ai telemetry: writing runs failed", "count", len(batch), "error", err.Error())
return
}
r.written.Add(int64(len(batch)))
}
func (r *Recorder) writeLoop() {
ticker := time.NewTicker(flushEvery)
defer ticker.Stop()
batch := make([]models.AIAgentRun, 0, flushAt)
for {
select {
case run := <-r.runs:
batch = append(batch, run)
if len(batch) >= flushAt {
r.Flush(batch)
batch = batch[:0]
}
case <-ticker.C:
r.Flush(batch)
batch = batch[:0]
}
}
}
// Prune deletes runs older than the retention window. Returns rows removed.
func (r *Recorder) Prune() (int64, error) {
cutoff := r.now().AddDate(0, 0, -RetentionDays)
res := r.db.Where("receivedat < ?", cutoff).Delete(&models.AIAgentRun{})
return res.RowsAffected, res.Error
}
func (r *Recorder) pruneLoop() {
for {
if n, err := r.Prune(); err != nil {
utils.Error("ai telemetry: pruning old runs failed", "error", err.Error())
} else if n > 0 {
utils.Info("ai telemetry: pruned old runs", "count", n)
}
time.Sleep(24 * time.Hour)
}
}
// Start subscribes to AI_engine's telemetry and starts the writer. A nil NATS
// connection (NATS down, or not configured) logs and returns: the API serves
// without it, and the Insights page says no telemetry is being received.
func (r *Recorder) Start(nc *nats.Conn) {
if nc == nil {
utils.Info("ai telemetry: NATS not connected, agent runs will not be recorded")
return
}
r.once.Do(func() {
go r.writeLoop()
go r.pruneLoop()
if _, err := nc.QueueSubscribe("telemetry.task", QueueGroup, func(m *nats.Msg) { r.HandleTask(m.Data) }); err != nil {
utils.Error("ai telemetry: subscribe telemetry.task failed", "error", err.Error())
}
if _, err := nc.QueueSubscribe("telemetry.agent", QueueGroup, func(m *nats.Msg) { r.HandleAgent(m.Data) }); err != nil {
utils.Error("ai telemetry: subscribe telemetry.agent failed", "error", err.Error())
}
Receiving.Store(true)
utils.Info("ai telemetry: recording agent runs", "queue_group", QueueGroup)
})
}
// Receiving reports whether this process subscribed to telemetry at boot. The
// Insights endpoint returns it, so an empty page can say "not connected"
// rather than "no runs".
var Receiving atomic.Bool

View File

@@ -0,0 +1,135 @@
package telemetry
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// Real SQL against a THROWAWAY Postgres (REGISTRY_TEST_DSN); skipped otherwise.
// Drops and recreates aiagentruns and agent_decisions in its own schema.
func pgDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "aitelemetry_test")
all := []any{&models.AIAgentRun{}, &models.AgentDecision{}}
if err := db.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
return db
}
func strp(s string) *string { return &s }
func TestPGFlushStoresRunsOnceAndAggregates(t *testing.T) {
db := pgDB(t)
r := &Recorder{db: db, now: func() time.Time { return now }}
batch := []models.AIAgentRun{
{Agentid: "EXCEPTION_AGENT", Taskid: strp("t1"), Status: "completed", Durationms: 100, Receivedat: now},
{Agentid: "EXCEPTION_AGENT", Taskid: strp("t2"), Status: "failed", Error: "boom", Durationms: 300, Receivedat: now},
{Agentid: "DISPATCH_AGENT", Taskid: nil, Status: "completed", Durationms: 50, Receivedat: now},
{Agentid: "DISPATCH_AGENT", Taskid: nil, Status: "completed", Durationms: 70, Receivedat: now},
}
r.Flush(batch)
// A redelivered event (same agent + task id) is ignored, not double-counted.
r.Flush([]models.AIAgentRun{{Agentid: "EXCEPTION_AGENT", Taskid: strp("t1"), Status: "completed", Durationms: 999, Receivedat: now}})
var n int64
db.Model(&models.AIAgentRun{}).Count(&n)
if n != 4 {
t.Fatalf("stored %d runs, want 4 (redelivery ignored, null task ids both kept)", n)
}
stats, err := RunStats(db, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
s := SummariseRuns(stats)
if s.Total != 4 || s.Failed != 1 || len(s.PerAgent) != 2 {
t.Fatalf("summary %+v", s)
}
for _, a := range s.PerAgent {
if a.Agentid == "EXCEPTION_AGENT" && (a.Runs != 2 || a.Failed != 1 || a.Avgdurationms != 200 || a.Lastrunat == nil) {
t.Errorf("EXCEPTION_AGENT stats %+v", a)
}
}
// Outside the window, nothing.
if stats, _ := RunStats(db, now.Add(time.Hour)); len(stats) != 0 {
t.Errorf("runs before the window were counted: %+v", stats)
}
}
func TestPGPruneRemovesOnlyExpiredRuns(t *testing.T) {
db := pgDB(t)
r := &Recorder{db: db, now: func() time.Time { return now }}
r.Flush([]models.AIAgentRun{
{Agentid: "A", Status: "completed", Receivedat: now.AddDate(0, 0, -(RetentionDays + 1))},
{Agentid: "A", Status: "completed", Receivedat: now.AddDate(0, 0, -1)},
})
removed, err := r.Prune()
if err != nil || removed != 1 {
t.Fatalf("pruned %d, %v; want 1", removed, err)
}
}
func TestPGDecisionsCountAndPage(t *testing.T) {
db := pgDB(t)
ok, pend := "success", (*string)(nil)
b1 := uint64(501)
for i, d := range []models.AgentDecision{
{DecisionType: "miler_assignment", BookingID: &b1, Context: `{"rider":"secret"}`, Decision: `{"miler_id":8}`, Reasoning: "nearest", Outcome: &ok},
{DecisionType: "miler_assignment", Context: `{}`, Decision: `{"miler_id":9}`, Reasoning: "load", Outcome: pend},
{DecisionType: "stall_response", Context: `{}`, Decision: `{"action":"alert"}`, Reasoning: "stalled 12m", Outcome: pend},
} {
d.CreatedAt = now.Add(time.Duration(i) * time.Minute)
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
}
counts, err := DecisionCounts(db, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
s := SummariseDecisions(counts)
if s.Total != 3 || s.ByType[0].Decisiontype != "miler_assignment" || s.ByType[0].Outcomes["success"] != 1 || s.ByType[0].Outcomes["pending"] != 1 {
t.Fatalf("decision summary %+v", s)
}
page, err := RecentDecisions(db, "", 0, 2)
if err != nil || len(page) != 2 || page[0].Decisiontype != "stall_response" {
t.Fatalf("first page %+v, %v", page, err)
}
var dec map[string]any
if json.Unmarshal(page[0].Decision, &dec) != nil || dec["action"] != "alert" {
t.Errorf("decision jsonb not returned as JSON: %s", page[0].Decision)
}
next, err := RecentDecisions(db, "", page[1].ID, 2)
if err != nil || len(next) != 1 || next[0].Bookingid == nil || *next[0].Bookingid != 501 {
t.Fatalf("second page %+v, %v", next, err)
}
only, _ := RecentDecisions(db, "stall_response", 0, 10)
if len(only) != 1 {
t.Errorf("type filter returned %d rows", len(only))
}
b, _ := json.Marshal(next)
if strings.Contains(string(b), "secret") {
t.Error("the context column (rider data) leaked into the decisions list")
}
}

View File

@@ -0,0 +1,177 @@
package telemetry
import (
"strings"
"testing"
"time"
"unicode/utf8"
"doormile/models"
)
var now = time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
// The exact shape core/agent.py publishes after a task.
const engineTask = `{"kind":"task","ts":"2026-09-29T17:59:58.123456","agent_id":"EXCEPTION_AGENT",
"task_id":"3f2c9a","task_type":"handle_stall","status":"completed","error":null,"duration_ms":412}`
func TestParseTaskReadsTheEngineShape(t *testing.T) {
run, err := ParseTask([]byte(engineTask), now)
if err != nil {
t.Fatal(err)
}
if run.Agentid != "EXCEPTION_AGENT" || run.Tasktype != "handle_stall" || run.Status != "completed" || run.Durationms != 412 {
t.Errorf("parsed %+v", run)
}
if run.Taskid == nil || *run.Taskid != "3f2c9a" {
t.Errorf("task id = %v", run.Taskid)
}
if !run.Receivedat.Equal(now) {
t.Errorf("receivedat = %v, want the backend's clock", run.Receivedat)
}
if run.Occurredat == nil || run.Occurredat.Format("15:04:05") != "17:59:58" {
t.Errorf("occurredat = %v", run.Occurredat)
}
}
func TestParseTaskKeepsAFailureAndItsError(t *testing.T) {
run, err := ParseTask([]byte(`{"agent_id":"DISPATCH_AGENT","task_id":"x","status":"FAILED","error":"boom","duration_ms":5}`), now)
if err != nil || run.Status != "failed" || run.Error != "boom" {
t.Fatalf("got %+v, %v", run, err)
}
}
// A run attributed to the wrong agent is worse than a missing one.
func TestParseTaskRefusesWhatItCannotAttribute(t *testing.T) {
cases := map[string]string{
"not json": `{nope`,
"no agent": `{"status":"completed"}`,
"blank agent": `{"agent_id":" ","status":"completed"}`,
"no status": `{"agent_id":"A"}`,
"agent id too long": `{"agent_id":"` + strings.Repeat("A", 65) + `","status":"completed"}`,
}
for name, body := range cases {
if _, err := ParseTask([]byte(body), now); err != ErrInvalid {
t.Errorf("%s: want ErrInvalid, got %v", name, err)
}
}
}
func TestParseTaskCleansUpEdgeValues(t *testing.T) {
long := strings.Repeat("é", 1500) // 3000 bytes of two-byte runes
run, err := ParseTask([]byte(`{"agent_id":"A","status":"completed","duration_ms":-40,"ts":"garbage","error":"`+long+`"}`), now)
if err != nil {
t.Fatal(err)
}
if run.Durationms != 0 {
t.Errorf("negative duration kept: %d", run.Durationms)
}
if run.Taskid != nil {
t.Error("an absent task id must be stored as null, not an empty string")
}
if run.Occurredat != nil {
t.Error("an unparseable engine time must be null, not guessed")
}
if len(run.Error) > maxErrorLen || !utf8.ValidString(run.Error) {
t.Errorf("error not truncated safely: %d bytes, valid=%v", len(run.Error), utf8.ValidString(run.Error))
}
}
func TestParseAgent(t *testing.T) {
st, err := ParseAgent([]byte(`{"kind":"agent","agent_id":"JARVIS","status":"idle","current_task":null,"tasks_completed":12,"tasks_failed":1}`), now)
if err != nil {
t.Fatal(err)
}
if st.AgentID != "JARVIS" || st.Status != "idle" || st.TasksCompleted != 12 || st.TasksFailed != 1 || !st.LastSeenAt.Equal(now) {
t.Errorf("parsed %+v", st)
}
if _, err := ParseAgent([]byte(`{"status":"idle"}`), now); err != ErrInvalid {
t.Error("an agent event with no id was accepted")
}
}
// The NATS callback must never block: a full buffer drops and counts.
func TestEnqueueDropsInsteadOfBlocking(t *testing.T) {
r := &Recorder{runs: make(chan models.AIAgentRun, 1), now: func() time.Time { return now }}
if !r.Enqueue(models.AIAgentRun{Agentid: "A"}) {
t.Fatal("first run refused")
}
done := make(chan bool)
go func() { done <- r.Enqueue(models.AIAgentRun{Agentid: "B"}) }()
select {
case ok := <-done:
if ok || r.dropped.Load() != 1 {
t.Errorf("full buffer: accepted=%v dropped=%d", ok, r.dropped.Load())
}
case <-time.After(time.Second):
t.Fatal("Enqueue blocked on a full buffer")
}
}
func TestHandlersIgnoreBadInputAndMissingRedis(t *testing.T) {
r := &Recorder{runs: make(chan models.AIAgentRun, 4), now: func() time.Time { return now }}
r.HandleTask([]byte(`{bad`))
r.HandleAgent([]byte(`{"agent_id":"A","status":"idle"}`)) // rdb nil: must not panic
if len(r.runs) != 0 {
t.Error("an invalid task event was enqueued")
}
r.HandleTask([]byte(engineTask))
if len(r.runs) != 1 {
t.Error("a valid task event was not enqueued")
}
}
func TestSummariseRuns(t *testing.T) {
s := SummariseRuns([]AgentRunStats{
{Agentid: "B", Runs: 3, Failed: 1},
{Agentid: "A", Runs: 10, Failed: 0},
{Agentid: "C", Runs: 3, Failed: 2},
})
if s.Total != 16 || s.Failed != 3 {
t.Errorf("totals %d/%d", s.Total, s.Failed)
}
var order []string
for _, a := range s.PerAgent {
order = append(order, a.Agentid)
}
if strings.Join(order, ",") != "A,B,C" {
t.Errorf("order %v, want busiest first then by id", order)
}
if empty := SummariseRuns(nil); empty.PerAgent == nil || empty.Total != 0 {
t.Error("no runs must summarise to an empty list, not null")
}
}
func TestSummariseDecisions(t *testing.T) {
s := SummariseDecisions([]DecisionCount{
{Decisiontype: "miler_assignment", Outcome: "success", Count: 7},
{Decisiontype: "stall_response", Outcome: "pending", Count: 2},
{Decisiontype: "miler_assignment", Outcome: "pending", Count: 3},
})
if s.Total != 12 || len(s.ByType) != 2 {
t.Fatalf("summary %+v", s)
}
first := s.ByType[0]
if first.Decisiontype != "miler_assignment" || first.Total != 10 || first.Outcomes["success"] != 7 || first.Outcomes["pending"] != 3 {
t.Errorf("miler_assignment rolled up wrong: %+v", first)
}
}
func TestClampDays(t *testing.T) {
for in, want := range map[int]int{0: 7, -3: 7, 1: 1, 7: 7, 30: 30, 90: RetentionDays} {
if got := ClampDays(in); got != want {
t.Errorf("ClampDays(%d) = %d, want %d", in, got, want)
}
}
}
// aiagentruns is created by AutoMigrate, so its columns are timestamptz. The
// recorder must stamp a true instant; utils.DBNow (IST digits labelled UTC) is
// 5h30m off as an instant and made the Phase 4 end-to-end run read a 20:57 IST
// run back as 02:27 the next day.
func TestRecorderStampsARealInstant(t *testing.T) {
r := NewRecorder(nil, nil)
if d := r.now().Sub(time.Now()); d > time.Minute || d < -time.Minute {
t.Fatalf("recorder clock is %v off real time; it must not use utils.DBNow", d)
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@@ -16,6 +17,7 @@ import (
"doormile/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// ─── Request / response types ────────────────────────────────────────────────
@@ -68,18 +70,28 @@ type aiDecisionResponse struct {
// audit. Falls back to the original distance/load/rating formula when the AI
// layer is unreachable or times out. Returns (nil, nil, false) when there are no
// eligible candidates or when the AI layer escalates the booking.
func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, *uint64, bool) {
candidates, aiCandidates := collectEligibleCandidates(nearby)
if len(candidates) == 0 {
return nil, nil, false
func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, []*milerCandidate, *uint64, bool) {
// Never offer an order back to a rider it was released from, who
// rejected it or who cancelled it.
nearby = withoutRiders(nearby, ridersToSkip(booking.Bookingid))
pool, poolAI := collectEligibleCandidates(nearby)
if len(pool) == 0 {
return nil, nil, nil, false
}
// Balance: only the riders holding the fewest orders are considered. The
// AI (or the fallback) chooses among them, so the split stays even however
// many orders and riders there are. The full pool goes back to the caller
// for the final re-check at commit time (finalizeChoice).
candidates, aiCandidates := leastLoaded(pool, poolAI)
decision, err := callDecisionEngine(booking, aiCandidates)
if err != nil {
utils.Warn("AI_LAYER_FALLBACK: decide-assignment unreachable, using legacy scoring",
"booking_id", booking.Bookingid, "error", err)
best := pickBestFromCandidates(candidates)
return best, nil, best != nil
return best, pool, nil, best != nil
}
utils.Info("Assignment: AI layer responded",
@@ -95,7 +107,7 @@ func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation
"booking_id", booking.Bookingid,
"reasoning", decision.Reasoning,
)
return nil, nil, false
return nil, nil, nil, false
}
var decisionID *uint64
@@ -106,17 +118,42 @@ func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation
for _, c := range candidates {
if c.profile.Userid == decision.ChosenMilerID {
return c, decisionID, true
return c, pool, decisionID, true
}
}
// AI returned an ID that is not in our eligibility set — fall back safely.
// AI returned an ID that is not among the least-loaded riders — fall back.
utils.Warn("AI_LAYER_FALLBACK: chosen miler not in eligible set, using legacy scoring",
"booking_id", booking.Bookingid,
"chosen_miler_id", decision.ChosenMilerID,
)
best := pickBestFromCandidates(candidates)
return best, nil, best != nil
return best, pool, nil, best != nil
}
// leastLoaded keeps the candidates holding the fewest orders in hand (and
// the matching AI rows, which are parallel to them; ai may be nil).
func leastLoaded(cands []*milerCandidate, ai []aiCandidate) ([]*milerCandidate, []aiCandidate) {
if len(cands) == 0 {
return cands, ai
}
least := cands[0].activeBookings
for _, c := range cands[1:] {
if c.activeBookings < least {
least = c.activeBookings
}
}
var outC []*milerCandidate
var outAI []aiCandidate
for i, c := range cands {
if c.activeBookings == least {
outC = append(outC, c)
if i < len(ai) {
outAI = append(outAI, ai[i])
}
}
}
return outC, outAI
}
// ─── Candidate collection ────────────────────────────────────────────────────
@@ -140,19 +177,27 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
continue
}
if profile.Availabilitystatus != constants.MilerAvailable {
// Carrying an order is not a reason to be skipped — maxActive below
// is what decides how much one miler can hold. Testing for Available
// here made that cap unreachable: a rider was eligible only while
// idle, so activeCount was always 0 and the multi-stop round the cap
// was written for could never be built.
if !constants.MilerCanTakeWork(profile.Availabilitystatus) {
continue
}
var activeCount int64
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{
constants.AssignmentAssigned,
constants.AssignmentAccepted,
}).
Count(&activeCount)
// Only a rider whose app is actually reporting can take an order. A
// status left at "Assigned" by an app that stopped weeks ago used to
// make that rider look like the least busy one, so orders went to
// nobody. Compared in SQL against the database clock, which is right
// whatever type the column has.
if !milerHasFreshGPS(milerUserID) {
continue
}
if activeCount >= maxActive {
activeCount := openStopsToday(milerUserID)
if activeCount >= maxActiveBookings() {
continue
}
@@ -163,6 +208,7 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
profile: profile,
distanceKm: loc.Dist,
activeBookings: activeCount,
sessionStops: sessionStops(db.DB, milerUserID),
})
aiCandidates = append(aiCandidates, aiCandidate{
MilerID: milerUserID,
@@ -180,6 +226,113 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
return candidates, aiCandidates
}
// openStopsToday is what counts towards the per-rider cap: the rider's open
// assignments (Assigned / Accepted) made since midnight India time, on orders
// that are not cancelled. Before, every open record of any age counted, and
// nothing closed a record when ops cancelled its order — so a rider with a
// pile of old or cancelled orders sat at the cap for good and every new order
// stayed Pending.
func openStopsToday(milerUserID int) int64 {
return openStopsTodayIn(db.DB, milerUserID)
}
// openStopsTodayIn is openStopsToday on a given handle, so the commit can
// recount inside its own transaction.
func openStopsTodayIn(h *gorm.DB, milerUserID int) int64 {
var n int64
h.Table("bookingassignments AS ba").
Joins("JOIN pickupbookings pb ON pb.bookingid = ba.bookingid").
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ? AND ba.assignedat >= ? AND pb.status <> ?",
milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted},
startOfISTDay(time.Now()),
constants.BookingCancelled).
Count(&n)
return n
}
// milerHasFreshGPS reports whether the rider's last position is recent enough
// (ASSIGNMENT_MAX_GPS_AGE_MINUTES, default 15; 0 turns the check off).
func milerHasFreshGPS(milerUserID int) bool {
maxAge := maxGPSAgeMinutes()
if maxAge == 0 {
return true
}
var n int64
db.DB.Model(&models.MilerProfile{}).
Where("userid = ? AND lastlocationupdatedat >= NOW() - make_interval(mins => ?)", milerUserID, maxAge).
Count(&n)
return n > 0
}
// sessionStops counts the orders given to the rider since their current duty
// session started (the latest milerdutylogs row with no logout), excluding
// ones that fell through (cancelled, rejected, reassigned). 0 when the rider
// has no open session.
func sessionStops(h *gorm.DB, milerUserID int) int64 {
var n int64
h.Table("bookingassignments").
Where("mileruserid = ? AND assignmentstatus NOT IN ? AND assignedat >= "+
"(SELECT MAX(loginat) FROM milerdutylogs WHERE userid = ? AND logoutat IS NULL)",
milerUserID,
[]string{constants.AssignmentCancelled, constants.AssignmentRejected, constants.AssignmentReassigned},
milerUserID).
Count(&n)
return n
}
// errNoRiderCapacity: by the time the commit ran, every candidate had reached
// the per-rider ceiling. The attempt is retried later like "no rider found".
var errNoRiderCapacity = errors.New("every candidate rider is at the order limit")
// decisionLockKey serialises the final rider choice across attempts and
// replicas (a transaction-scoped Postgres advisory lock). Held only for a
// handful of quick counts and the commit, never across the AI call.
const decisionLockKey int64 = 7_270_001
// finalizeChoice re-checks the choice inside the commit transaction. Attempts
// for different orders run in parallel — a bulk upload of 50 starts 50 — and
// each picked "the least-loaded rider" from counts read before the others
// committed, so they would all pile onto the same one. Under the lock the
// loads are recounted: the chosen rider is kept if still among the least
// loaded and under the ceiling, otherwise the best of the least loaded is
// taken instead (same order as betterChoice).
func finalizeChoice(tx *gorm.DB, chosen *milerCandidate, pool []*milerCandidate) (*milerCandidate, error) {
if len(pool) == 0 {
pool = []*milerCandidate{chosen}
}
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", decisionLockKey).Error; err != nil {
return nil, fmt.Errorf("decision lock: %w", err)
}
ceiling := maxActiveBookings()
var open []*milerCandidate
for _, c := range pool {
c.activeBookings = openStopsTodayIn(tx, c.profile.Userid)
c.sessionStops = sessionStops(tx, c.profile.Userid)
if c.activeBookings < ceiling {
open = append(open, c)
}
}
if len(open) == 0 {
return nil, errNoRiderCapacity
}
least, _ := leastLoaded(open, nil)
for _, c := range least {
if c.profile.Userid == chosen.profile.Userid {
return c, nil // the original choice still holds
}
}
return pickBestFromCandidates(least), nil
}
// startOfISTDay is midnight today in India, the boundary for "today's"
// open stops. An absolute instant, so it compares correctly with assignedat
// whether the column stores IST wall-clock or a real timestamp.
func startOfISTDay(now time.Time) time.Time {
n := now.In(utils.ISTLocation())
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, utils.ISTLocation())
}
// ─── Per-miler stats ─────────────────────────────────────────────────────────
func fetchMilerStats(milerUserID int) (onTimeRate float64, completedToday int64) {
@@ -247,19 +400,31 @@ func fetchHubData(hubID *int) (resolvedHubID int, hubLoad int64, hubCapacity int
// eligible slice: score = distance_km*1.0 + active_bookings*2.0 - rating*0.5
func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate {
var best *milerCandidate
bestScore := 1e18
for _, c := range candidates {
score := c.distanceKm*1.0 + float64(c.activeBookings)*2.0 - c.profile.Rating*0.5
if score < bestScore {
bestScore = score
if best == nil || betterChoice(c, best) {
best = c
}
}
return best
}
// betterChoice is the balancing order: fewest orders in hand, then fewest
// orders this duty session, then nearest to the pickup, then best rated.
// It replaced a weighted score in which distance dominated, so riders near
// the pickups took most of the orders.
func betterChoice(a, b *milerCandidate) bool {
if a.activeBookings != b.activeBookings {
return a.activeBookings < b.activeBookings
}
if a.sessionStops != b.sessionStops {
return a.sessionStops < b.sessionStops
}
if a.distanceKm != b.distanceKm {
return a.distanceKm < b.distanceKm
}
return a.profile.Rating > b.profile.Rating
}
// ─── AI layer HTTP call ──────────────────────────────────────────────────────
func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) {

View File

@@ -0,0 +1,266 @@
package assignment
import (
"errors"
"fmt"
"sync"
"testing"
"time"
"doormile/constants"
"doormile/models"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Balanced auto-assignment (docs/balanced-assignment-plan.md, Phase A)
// against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set — see
// eligibility_pg_test.go.
// onDuty adds a rider with live GPS and an open duty session.
func onDuty(t *testing.T, gdb *gorm.DB, id int, sessionStart time.Time) {
t.Helper()
rider(t, gdb, id, constants.MilerAvailable, time.Minute)
mustDo(t, gdb.Create(&models.MilerDutyLog{Userid: id, Loginat: sessionStart}).Error)
}
// assignOne runs one attempt the way tryAssign does (minus the Redis GEO
// lookup, which `nearby` stands in for) and returns the rider it went to.
func assignOne(t *testing.T, gdb *gorm.DB, bookingID int, nearby []redis.GeoLocation) (int, error) {
t.Helper()
var b models.PickupBooking
if err := gdb.First(&b, bookingID).Error; err != nil {
return 0, err
}
cand, pool, dec, found := selectMilerWithAI(&b, nearby)
if !found {
return 0, nil
}
final, err := commitAssignment(&b, cand, pool, dec)
if err != nil {
return 0, err
}
return final.profile.Userid, nil
}
// near puts every rider at a different distance from the pickup, so a
// distance-first rule would pile everything onto rider ids[0].
func near(ids ...int) []redis.GeoLocation {
out := make([]redis.GeoLocation, len(ids))
for i, id := range ids {
out[i] = redis.GeoLocation{Name: fmt.Sprint(id), Dist: 0.5 + float64(i)*1.5}
}
return out
}
func perRider(t *testing.T, gdb *gorm.DB) map[int]int64 {
t.Helper()
type row struct {
Mileruserid int
N int64
}
var rows []row
mustDo(t, gdb.Table("bookingassignments").Select("mileruserid, COUNT(*) AS n").
Where("assignmentstatus IN ?", []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Group("mileruserid").Scan(&rows).Error)
out := map[int]int64{}
for _, r := range rows {
out[r.Mileruserid] = r.N
}
return out
}
func spread(m map[int]int64, ids ...int) (lo, hi int64) {
lo, hi = 1<<62, -1
for _, id := range ids {
n := m[id]
if n < lo {
lo = n
}
if n > hi {
hi = n
}
}
return lo, hi
}
func balanceSetup(t *testing.T) *gorm.DB {
gdb := eligibilityDB(t)
t.Setenv("AI_LAYER_BASE_URL", "http://127.0.0.1:1") // unreachable: the fallback chooses
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "20")
return gdb
}
// 5 riders, 50 orders one after another: 10 each, although rider 1 is the
// nearest to every pickup.
func TestBalancedSplitSequential(t *testing.T) {
gdb := balanceSetup(t)
ids := []int{1, 2, 3, 4, 5}
for _, id := range ids {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
}
for i := 0; i < 50; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
if _, err := assignOne(t, gdb, b, near(ids...)); err != nil {
t.Fatal(err)
}
}
got := perRider(t, gdb)
for _, id := range ids {
if got[id] != 10 {
t.Fatalf("per rider = %v, want 10 each", got)
}
}
}
// Any count splits within one: 23 orders over 5 riders -> 5,5,5,4,4.
func TestBalancedSplitUneven(t *testing.T) {
gdb := balanceSetup(t)
ids := []int{1, 2, 3, 4, 5}
for _, id := range ids {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
}
for i := 0; i < 23; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
if _, err := assignOne(t, gdb, b, near(ids...)); err != nil {
t.Fatal(err)
}
}
if lo, hi := spread(perRider(t, gdb), ids...); lo != 4 || hi != 5 {
t.Fatalf("spread %d..%d, want 4..5", lo, hi)
}
}
// A bulk upload: 50 orders attempted at the same moment. Without the final
// re-check under the lock they all saw the same counts and piled onto one
// rider. Still balanced, nobody over the ceiling, one rider per order.
func TestBalancedSplitConcurrentBurst(t *testing.T) {
gdb := balanceSetup(t)
ids := []int{1, 2, 3, 4, 5}
for _, id := range ids {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
}
bookings := make([]int, 50)
for i := range bookings {
bookings[i] = order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
}
var wg sync.WaitGroup
errs := make(chan error, len(bookings))
for _, b := range bookings {
wg.Add(1)
go func(b int) {
defer wg.Done()
if _, err := assignOne(t, gdb, b, near(ids...)); err != nil {
errs <- err
}
}(b)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
got := perRider(t, gdb)
if lo, hi := spread(got, ids...); hi-lo > 1 {
t.Fatalf("burst split %v: spread %d..%d, want within 1", got, lo, hi)
}
var total int64
for _, n := range got {
total += n
}
var perOrder int64
gdb.Raw("SELECT COALESCE(MAX(n),0) FROM (SELECT COUNT(*) n FROM bookingassignments GROUP BY bookingid) x").Scan(&perOrder)
if total != 50 || perOrder != 1 {
t.Fatalf("assigned %d (want 50), max riders on one order %d (want 1)", total, perOrder)
}
}
// Riders come and go: 3 riders hold 4 each, 2 log in fresh. The next 8 orders
// go to the newcomers (4 each), then everyone shares.
func TestNewRidersCatchUpThenShare(t *testing.T) {
gdb := balanceSetup(t)
busy := []int{1, 2, 3}
fresh := []int{4, 5}
for _, id := range busy {
onDuty(t, gdb, id, time.Now().Add(-3*time.Hour))
for i := 0; i < 4; i++ {
order(t, gdb, id, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now().Add(-time.Hour))
}
}
for _, id := range fresh {
onDuty(t, gdb, id, time.Now())
}
all := append(append([]int{}, busy...), fresh...)
for i := 0; i < 8; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
id, err := assignOne(t, gdb, b, near(all...))
if err != nil {
t.Fatal(err)
}
if id != 4 && id != 5 {
t.Fatalf("order %d went to busy rider %d while a newcomer had fewer", i, id)
}
}
if lo, hi := spread(perRider(t, gdb), all...); lo != 4 || hi != 4 {
t.Fatalf("after catch-up spread %d..%d, want all 4", lo, hi)
}
// From here on everyone shares: 5 more orders -> one each.
for i := 0; i < 5; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
if _, err := assignOne(t, gdb, b, near(all...)); err != nil {
t.Fatal(err)
}
}
if lo, hi := spread(perRider(t, gdb), all...); lo != 5 || hi != 5 {
t.Fatalf("after sharing spread %d..%d, want all 5", lo, hi)
}
}
// Tie on orders in hand: fewer orders this duty session wins, then distance.
func TestTieBreakSessionThenDistance(t *testing.T) {
gdb := balanceSetup(t)
// Rider 1: nearest, but has had 2 orders this session (both now closed).
onDuty(t, gdb, 1, time.Now().Add(-2*time.Hour))
for i := 0; i < 2; i++ {
order(t, gdb, 1, constants.BookingConvertedConsignment, constants.AssignmentCompleted, time.Now().Add(-time.Hour))
}
// Riders 2 and 3: no orders this session; 2 is nearer than 3.
onDuty(t, gdb, 2, time.Now().Add(-2*time.Hour))
onDuty(t, gdb, 3, time.Now().Add(-2*time.Hour))
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
id, err := assignOne(t, gdb, b, near(1, 2, 3))
if err != nil {
t.Fatal(err)
}
if id != 2 {
t.Fatalf("went to %d, want 2 (fewest this session, then nearest)", id)
}
}
// Everyone at the ceiling: the order waits (no assignment, no error that
// would stop the retries).
func TestAllRidersAtCeilingWaits(t *testing.T) {
gdb := balanceSetup(t)
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "2")
for _, id := range []int{1, 2} {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
for i := 0; i < 2; i++ {
order(t, gdb, id, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now())
}
}
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
id, err := assignOne(t, gdb, b, near(1, 2))
if err != nil && !errors.Is(err, errNoRiderCapacity) {
t.Fatal(err)
}
if id != 0 {
t.Fatalf("assigned to %d although everyone is at the ceiling", id)
}
var bk models.PickupBooking
mustDo(t, gdb.First(&bk, b).Error)
if bk.Status != constants.BookingPendingPickup || bk.Assignedmileruserid != nil {
t.Fatalf("booking should still be pending: %+v", bk.Status)
}
}

View File

@@ -0,0 +1,59 @@
package assignment
import (
"testing"
"doormile/models"
)
func cand(id int, inHand, session int64, km, rating float64) *milerCandidate {
return &milerCandidate{
profile: models.MilerProfile{Userid: id, Rating: rating},
distanceKm: km,
activeBookings: inHand,
sessionStops: session,
}
}
// The balancing order: in hand, then this session, then distance, then rating.
func TestPickBestBalancesBeforeDistance(t *testing.T) {
cases := []struct {
name string
in []*milerCandidate
want int
}{
{"fewer in hand beats nearer", []*milerCandidate{cand(1, 2, 0, 0.2, 5), cand(2, 1, 9, 9.0, 1)}, 2},
{"tie in hand: fewer this session", []*milerCandidate{cand(1, 1, 5, 0.2, 5), cand(2, 1, 2, 4.0, 3)}, 2},
{"tie in hand and session: nearer", []*milerCandidate{cand(1, 1, 2, 3.0, 5), cand(2, 1, 2, 1.0, 3)}, 2},
{"full tie: better rating", []*milerCandidate{cand(1, 1, 2, 1.0, 4.1), cand(2, 1, 2, 1.0, 4.8)}, 2},
{"single candidate", []*milerCandidate{cand(7, 3, 3, 3, 3)}, 7},
}
for _, c := range cases {
if got := pickBestFromCandidates(c.in); got == nil || got.profile.Userid != c.want {
t.Errorf("%s: got %v, want rider %d", c.name, got, c.want)
}
}
if pickBestFromCandidates(nil) != nil {
t.Error("no candidates must give nil")
}
}
// leastLoaded keeps only the riders with the fewest in hand, with their
// parallel AI rows.
func TestLeastLoaded(t *testing.T) {
cs := []*milerCandidate{cand(1, 2, 0, 1, 4), cand(2, 0, 0, 2, 4), cand(3, 1, 0, 3, 4), cand(4, 0, 5, 4, 4)}
ai := []aiCandidate{{MilerID: 1}, {MilerID: 2}, {MilerID: 3}, {MilerID: 4}}
gotC, gotAI := leastLoaded(cs, ai)
if len(gotC) != 2 || gotC[0].profile.Userid != 2 || gotC[1].profile.Userid != 4 {
t.Fatalf("least loaded = %v", gotC)
}
if len(gotAI) != 2 || gotAI[0].MilerID != 2 || gotAI[1].MilerID != 4 {
t.Fatalf("AI rows must follow: %v", gotAI)
}
if c, a := leastLoaded(nil, nil); len(c) != 0 || len(a) != 0 {
t.Fatal("empty in, empty out")
}
if c, _ := leastLoaded(cs, nil); len(c) != 2 {
t.Fatal("AI rows are optional")
}
}

View File

@@ -3,17 +3,23 @@ package assignment
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/internal/milergeo"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
@@ -21,13 +27,57 @@ const (
retryDelay = 2 * time.Minute
geoRadiusKm = 10.0
geoMaxCount = 10
maxActive = 3
// defaultMaxActive is how many open stops one miler may hold at once.
// Override with MILER_MAX_ACTIVE_BOOKINGS — how many parcels a rider can
// realistically run in one round is an operational call, not a constant,
// and it differs between a dense city round and an intercity leg.
defaultMaxActive = 3
)
// defaultMaxGPSAgeMinutes: a rider whose app has not reported a position for
// longer than this is not offered orders. Override with
// ASSIGNMENT_MAX_GPS_AGE_MINUTES; 0 turns the check off.
const defaultMaxGPSAgeMinutes = 15
// maxGPSAgeMinutes is read per call, like maxActiveBookings. A typo falls back
// to the default rather than switching the check off.
func maxGPSAgeMinutes() int {
if v := strings.TrimSpace(os.Getenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
utils.Warn("ASSIGNMENT_MAX_GPS_AGE_MINUTES is not a non-negative integer, using the default",
"value", v, "default", defaultMaxGPSAgeMinutes)
}
return defaultMaxGPSAgeMinutes
}
// maxActiveBookings reads the per-miler concurrent-stop cap, read per call so
// it can be changed without a redeploy. A non-numeric or non-positive value
// falls back to the default rather than uncapping the fleet by typo.
func maxActiveBookings() int64 {
if v := strings.TrimSpace(os.Getenv("MILER_MAX_ACTIVE_BOOKINGS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return int64(n)
}
utils.Warn("MILER_MAX_ACTIVE_BOOKINGS is not a positive integer, using the default",
"value", v, "default", defaultMaxActive)
}
return defaultMaxActive
}
type milerCandidate struct {
profile models.MilerProfile
distanceKm float64
profile models.MilerProfile
distanceKm float64
// activeBookings is the rider's load "in hand": today's open orders that
// are not cancelled (openStopsToday). Balancing gives the next order to
// the rider with the fewest; it is also what the ceiling caps.
activeBookings int64
// sessionStops counts orders given to the rider since they started duty —
// the first tie-break, so a rider who just came online is not passed over
// for one who has been busy all day.
sessionStops int64
}
// AssignCRMMiler finds the best available nearby miler for an express booking
@@ -47,6 +97,28 @@ func AssignCRMMiler(bookingID int) {
enqueue(bookingID, kindExpress)
}
// errBookingTaken: the booking got a rider (or was cancelled) between this
// attempt reading it and committing. Not a failure — the work is done.
var errBookingTaken = errors.New("booking already assigned or cancelled")
// claimBooking sets the booking's rider only if it still has none and is not
// cancelled, in the caller's transaction. Two attempts can run for one booking
// at once — the retry queue, the pending-order sweeper, a hub "auto-assign"
// tap — and without this both would commit and the booking would end up with
// two riders.
func claimBooking(tx *gorm.DB, bookingID int, updates map[string]interface{}) error {
res := tx.Model(&models.PickupBooking{}).
Where("bookingid = ? AND assignedmileruserid IS NULL AND status <> ?", bookingID, constants.BookingCancelled).
Updates(updates)
if res.Error != nil {
return fmt.Errorf("update PickupBooking: %w", res.Error)
}
if res.RowsAffected == 0 {
return errBookingTaken
}
return nil
}
// tryAssign performs a single attempt: queries Redis GEO, scores candidates, commits.
// Returns (true, nil) on success or when the booking no longer needs assignment.
// Returns (false, nil) when no eligible miler was found (retry warranted).
@@ -79,12 +151,18 @@ func tryAssign(bookingID int) (bool, error) {
return false, nil
}
candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
candidate, pool, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
if !found {
return false, nil
}
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
if _, err := commitAssignment(&booking, candidate, pool, agentDecisionID); err != nil {
switch {
case errors.Is(err, errBookingTaken):
return true, nil
case errors.Is(err, errNoRiderCapacity):
return false, nil // everyone filled up meanwhile; retry later
}
return false, fmt.Errorf("commit: %w", err)
}
@@ -132,7 +210,7 @@ func TryAssignOnce(bookingID int) (AutoAssignResult, error) {
return AutoAssignResult{Escalated: true, Reasoning: "no milers within search radius", SearchedRadiusKm: geoRadiusKm}, nil
}
candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
candidate, pool, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
if !found {
return AutoAssignResult{
Escalated: true,
@@ -142,9 +220,25 @@ func TryAssignOnce(bookingID int) (AutoAssignResult, error) {
}, nil
}
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
chosenID := candidate.profile.Userid
candidate, err = commitAssignment(&booking, candidate, pool, agentDecisionID)
if err != nil {
switch {
case errors.Is(err, errBookingTaken):
return AutoAssignResult{Assigned: true}, nil
case errors.Is(err, errNoRiderCapacity):
return AutoAssignResult{
Escalated: true,
Reasoning: "every nearby rider is at the order limit",
SearchedRadiusKm: geoRadiusKm,
CandidatesFound: len(nearby),
}, nil
}
return AutoAssignResult{}, fmt.Errorf("commit: %w", err)
}
if candidate.profile.Userid != chosenID {
agentDecisionID = nil // balancing overrode the AI's pick; its reasoning no longer applies
}
reasoning := ""
if agentDecisionID != nil {
@@ -174,30 +268,40 @@ func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: lon,
Latitude: lat,
Radius: geoRadiusKm,
RadiusUnit: "km",
Sort: "ASC",
Count: geoMaxCount,
},
WithDist: true,
}).Result()
if err != nil {
return nil, err
}
return locs, nil
// milergeo falls back to GEORADIUS on Redis older than 6.2, where
// GEOSEARCH does not exist and every order would find "no riders".
return milergeo.Search(ctx, db.Rdb, lat, lon, geoRadiusKm, geoMaxCount)
}
// commitAssignment writes the BookingAssignment row, updates the booking and the
// miler's availability status in a single transaction, then publishes to NATS.
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, agentDecisionID *uint64) error {
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, pool []*milerCandidate, agentDecisionID *uint64) (*milerCandidate, error) {
tx := db.DB.Begin()
// Re-check the choice with fresh loads under the decision lock, so a burst
// of orders spreads across riders instead of all landing on the one that
// looked least loaded when each attempt started. See finalizeChoice.
final, err := finalizeChoice(tx, candidate, pool)
if err != nil {
tx.Rollback()
return nil, err
}
if final != candidate {
agentDecisionID = nil
}
candidate = final
milerUserID := candidate.profile.Userid
tx := db.DB.Begin()
// Claim the booking first, and only if it is still unassigned and not
// cancelled — see claimBooking.
if err := claimBooking(tx, booking.Bookingid, map[string]interface{}{
"assignedmileruserid": milerUserID,
"status": constants.BookingMilerAssigned,
"updatedat": time.Now(),
}); err != nil {
tx.Rollback()
return nil, err
}
assignment := models.BookingAssignment{
Bookingid: booking.Bookingid,
@@ -205,27 +309,18 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
Assignmentstatus: constants.AssignmentAssigned,
Assignedat: time.Now(),
AgentDecisionID: agentDecisionID,
Remarks: autoAssignedRemark,
}
if err := tx.Create(&assignment).Error; err != nil {
tx.Rollback()
return fmt.Errorf("create BookingAssignment: %w", err)
}
now := time.Now()
if err := tx.Model(booking).Updates(map[string]interface{}{
"assignedmileruserid": milerUserID,
"status": constants.BookingMilerAssigned,
"updatedat": now,
}).Error; err != nil {
tx.Rollback()
return fmt.Errorf("update PickupBooking: %w", err)
return nil, fmt.Errorf("create BookingAssignment: %w", err)
}
if err := tx.Model(&models.MilerProfile{}).
Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
tx.Rollback()
return fmt.Errorf("update MilerProfile availability: %w", err)
return nil, fmt.Errorf("update MilerProfile availability: %w", err)
}
// The customer's "Miler assigned" milestone, in the same transaction as the
@@ -239,7 +334,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
Source: "internal/assignment.commitAssignment",
}); err != nil {
tx.Rollback()
return fmt.Errorf("record assigned stage: %w", err)
return nil, fmt.Errorf("record assigned stage: %w", err)
}
tx.Commit()
@@ -264,7 +359,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
// best-effort and off this goroutine's critical path.
routing.SequenceMilerStopsAsync(milerUserID)
return nil
return candidate, nil
}
// publishAssignment sends the booking.assigned event to NATS JetStream.

View File

@@ -2,6 +2,7 @@ package assignment
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
@@ -75,7 +76,7 @@ func tryCustomerAssign(bookingID int) (bool, error) {
return false, nil
}
miler, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
miler, pool, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
if !found {
return false, nil
}
@@ -96,11 +97,15 @@ func tryCustomerAssign(bookingID int) (bool, error) {
provider = providerResult{company: "Doormile"}
}
// Step 3 — ETA based on miler-to-pickup distance.
etaMinutes := calculateETA(miler.distanceKm)
// Steps 4–6 — Commit to DB and publish to NATS.
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes, agentDecisionID); err != nil {
// Steps 3–6 — final balance check, ETA from the chosen rider's distance,
// commit to DB and publish to NATS.
if err := commitCustomerAssignment(&booking, miler, pool, provider, agentDecisionID); err != nil {
switch {
case errors.Is(err, errBookingTaken):
return true, nil
case errors.Is(err, errNoRiderCapacity):
return false, nil // everyone filled up meanwhile; retry later
}
return false, fmt.Errorf("commit: %w", err)
}
@@ -184,25 +189,24 @@ func calculateETA(distanceKm float64) float64 {
func commitCustomerAssignment(
booking *models.PickupBooking,
miler *milerCandidate,
pool []*milerCandidate,
provider providerResult,
etaMinutes float64,
agentDecisionID *uint64,
) error {
milerUserID := miler.profile.Userid
tx := db.DB.Begin()
ba := models.BookingAssignment{
Bookingid: booking.Bookingid,
Mileruserid: milerUserID,
Assignmentstatus: constants.AssignmentAssigned,
Assignedat: time.Now(),
AgentDecisionID: agentDecisionID,
}
if err := tx.Create(&ba).Error; err != nil {
// Final balance check under the decision lock (finalizeChoice).
final, err := finalizeChoice(tx, miler, pool)
if err != nil {
tx.Rollback()
return fmt.Errorf("create BookingAssignment: %w", err)
return err
}
if final != miler {
agentDecisionID = nil
}
miler = final
milerUserID := miler.profile.Userid
etaMinutes := calculateETA(miler.distanceKm)
bookingUpdates := map[string]interface{}{
"assignedmileruserid": milerUserID,
@@ -212,10 +216,23 @@ func commitCustomerAssignment(
if provider.company != "" {
bookingUpdates["providercompany"] = provider.company
}
if err := tx.Model(booking).Updates(bookingUpdates).Error; err != nil {
// Claim first, only if still unassigned and not cancelled (claimBooking).
if err := claimBooking(tx, booking.Bookingid, bookingUpdates); err != nil {
tx.Rollback()
return fmt.Errorf("update PickupBooking: %w", err)
return err
}
ba := models.BookingAssignment{
Bookingid: booking.Bookingid,
Mileruserid: milerUserID,
Assignmentstatus: constants.AssignmentAssigned,
Assignedat: time.Now(),
AgentDecisionID: agentDecisionID,
Remarks: autoAssignedRemark,
}
if err := tx.Create(&ba).Error; err != nil {
tx.Rollback()
return fmt.Errorf("create BookingAssignment: %w", err)
}
if err := tx.Model(&models.MilerProfile{}).

View File

@@ -0,0 +1,253 @@
package assignment
import (
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Who auto-assignment may offer an order to, against a real Postgres. Skipped
// unless REGISTRY_TEST_DSN is set; the DSN must be a THROWAWAY database — the
// tables below are dropped and recreated in their own schema. See
// internal/ai/registry/store_integration_test.go for how to start one.
func eligibilityDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres assignment test")
}
gdb := testpg.Open(t, dsn, "assignment_eligibility_test")
all := []any{&models.PickupBooking{}, &models.BookingAssignment{}, &models.MilerProfile{},
&models.BookingStageEvent{}, &models.BookingDestination{}, &models.MilerDutyLog{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
// commitAssignment fires customer notifications in a goroutine that can
// outlive the test. Putting back a nil db.DB would make it panic; leaving
// the closed test handle makes it fail quietly instead.
t.Cleanup(func() {
if prev != nil {
db.DB = prev
}
})
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "")
t.Setenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES", "")
return gdb
}
func mustDo(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
var nextBookingID = 1000
// order creates a booking and, when rider != 0, an assignment to that rider.
func order(t *testing.T, gdb *gorm.DB, rider int, bookingStatus, asgStatus string, assignedAt time.Time) int {
t.Helper()
nextBookingID++
id := nextBookingID
b := models.PickupBooking{Bookingid: id, Bookingno: fmt.Sprintf("DM-T%06d", id), Status: bookingStatus,
Bookingsource: constants.BookingSourceExpress, Pickuplatitude: 11.0053, Pickuplongitude: 76.9511}
if rider != 0 {
r := rider
b.Assignedmileruserid = &r
}
mustDo(t, gdb.Create(&b).Error)
if rider != 0 {
mustDo(t, gdb.Create(&models.BookingAssignment{Bookingid: id, Mileruserid: rider,
Assignmentstatus: asgStatus, Assignedat: assignedAt}).Error)
}
return id
}
func rider(t *testing.T, gdb *gorm.DB, id int, status string, gpsAge time.Duration) {
t.Helper()
seen := time.Now().Add(-gpsAge)
mustDo(t, gdb.Create(&models.MilerProfile{Userid: id, Displayname: fmt.Sprintf("rider %d", id),
Phone: fmt.Sprintf("90000%05d", id), Availabilitystatus: status, Lastlocationupdatedat: &seen}).Error)
}
// The question that started this: Rajan has 10 orders from earlier days still
// open, and a cancelled one from today. A new order must still be offered to
// him — only today's real, open work counts.
func TestOldAndCancelledOrdersDoNotBlockARider(t *testing.T) {
gdb := eligibilityDB(t)
const rajan = 38
rider(t, gdb, rajan, constants.MilerAssigned, time.Minute)
for i := 0; i < 10; i++ {
order(t, gdb, rajan, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now().AddDate(0, 0, -(i+1)))
}
order(t, gdb, rajan, constants.BookingCancelled, constants.AssignmentAssigned, time.Now())
order(t, gdb, rajan, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now())
if n := openStopsToday(rajan); n != 1 {
t.Fatalf("open stops today = %d, want 1 (10 old + 1 cancelled must not count)", n)
}
got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: fmt.Sprint(rajan), Dist: 0.03}})
if len(got) != 1 {
t.Fatal("Rajan must be offered the new order")
}
}
// Today's real work still counts towards the cap.
func TestTodaysOpenOrdersStillCountTowardsTheCap(t *testing.T) {
gdb := eligibilityDB(t)
rider(t, gdb, 6, constants.MilerAssigned, time.Minute)
for i := 0; i < 3; i++ {
order(t, gdb, 6, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now())
}
if got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: "6"}}); len(got) != 0 {
t.Fatal("a rider with 3 open orders today is at the cap")
}
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "5")
if got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: "6"}}); len(got) != 1 {
t.Fatal("raising the cap must let them take more")
}
}
// A rider whose app stopped reporting is not offered orders, whatever their
// status says — but only while the check is on.
func TestStaleGPSRiderIsSkipped(t *testing.T) {
gdb := eligibilityDB(t)
rider(t, gdb, 23, constants.MilerOnDelivery, 6*24*time.Hour) // last GPS 6 days ago
rider(t, gdb, 38, constants.MilerAssigned, 2*time.Minute)
nearby := []redis.GeoLocation{{Name: "23", Dist: 0.5}, {Name: "38", Dist: 0.9}}
got, _ := collectEligibleCandidates(nearby)
if len(got) != 1 || got[0].profile.Userid != 38 {
t.Fatalf("only the rider with live GPS may be offered the order, got %d candidates", len(got))
}
t.Setenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES", "0")
if got, _ := collectEligibleCandidates(nearby); len(got) != 2 {
t.Fatal("with the check off both riders are candidates")
}
}
// An offline rider stays out, fresh GPS or not.
func TestOfflineRiderIsSkipped(t *testing.T) {
gdb := eligibilityDB(t)
rider(t, gdb, 46, constants.MilerOffline, time.Minute)
if got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: "46"}}); len(got) != 0 {
t.Fatal("offline rider must not be offered orders")
}
}
// Two attempts racing on one booking — queue retry, sweeper, hub button —
// must end with exactly one rider.
func TestConcurrentAttemptsAssignOnce(t *testing.T) {
gdb := eligibilityDB(t)
rider(t, gdb, 38, constants.MilerAvailable, time.Minute)
rider(t, gdb, 21, constants.MilerAvailable, time.Minute)
id := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
var booking models.PickupBooking
mustDo(t, gdb.First(&booking, id).Error)
var wg sync.WaitGroup
var mu sync.Mutex
won, taken, other := 0, 0, 0
for i, r := range []int{38, 21, 38, 21, 38, 21} {
wg.Add(1)
go func(i, r int) {
defer wg.Done()
b := booking
_, err := commitAssignment(&b, &milerCandidate{profile: models.MilerProfile{Userid: r}}, nil, nil)
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
won++
case errors.Is(err, errBookingTaken):
taken++
default:
other++
t.Errorf("attempt %d: %v", i, err)
}
}(i, r)
}
wg.Wait()
if won != 1 || taken != 5 || other != 0 {
t.Fatalf("won=%d taken=%d other=%d, want exactly one winner", won, taken, other)
}
var n int64
gdb.Model(&models.BookingAssignment{}).Where("bookingid = ?", id).Count(&n)
if n != 1 {
t.Fatalf("assignment rows = %d, want 1", n)
}
}
// A cancelled booking is never assigned, even by an attempt that read it
// before the cancel.
func TestCancelledBookingIsNotAssigned(t *testing.T) {
gdb := eligibilityDB(t)
rider(t, gdb, 38, constants.MilerAvailable, time.Minute)
id := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
var stale models.PickupBooking
mustDo(t, gdb.First(&stale, id).Error)
mustDo(t, gdb.Model(&models.PickupBooking{}).Where("bookingid = ?", id).Update("status", constants.BookingCancelled).Error)
_, err := commitAssignment(&stale, &milerCandidate{profile: models.MilerProfile{Userid: 38}}, nil, nil)
if !errors.Is(err, errBookingTaken) {
t.Fatalf("want errBookingTaken, got %v", err)
}
var n int64
gdb.Model(&models.BookingAssignment{}).Where("bookingid = ?", id).Count(&n)
if n != 0 {
t.Fatal("a cancelled booking must get no assignment")
}
}
// The sweeper retries every unassigned pending booking in its window — no
// matter how many — and nothing else.
func TestSweeperPicksEveryUnassignedPendingBooking(t *testing.T) {
gdb := eligibilityDB(t)
t.Setenv("EXPRESS_AGENT_ENABLED", "")
t.Setenv("ASSIGNMENT_SWEEP_MAX_AGE_HOURS", "")
now := time.Now()
mk := func(no string, status string, rider *int, lat float64, age time.Duration, source string) {
nextBookingID++
mustDo(t, gdb.Create(&models.PickupBooking{Bookingid: nextBookingID, Bookingno: no, Status: status,
Assignedmileruserid: rider, Bookingsource: source, Pickuplatitude: lat, Pickuplongitude: 76.95,
Createdat: now.Add(-age)}).Error)
}
r := 38
for i := 0; i < 25; i++ { // a pile of pending orders: all of them are retried
mk(fmt.Sprintf("DM-P%02d", i), constants.BookingPendingPickup, nil, 11.0, time.Duration(10+i)*time.Minute, constants.BookingSourceExpress)
}
mk("DM-CX", constants.BookingPendingPickup, nil, 11.0, 30*time.Minute, constants.BookingSourceCustomerApp)
mk("DM-NEW", constants.BookingPendingPickup, nil, 11.0, 30*time.Second, constants.BookingSourceExpress) // its own first attempt runs
mk("DM-OLD", constants.BookingPendingPickup, nil, 11.0, 5*24*time.Hour, constants.BookingSourceExpress) // abandoned
mk("DM-ASG", constants.BookingMilerAssigned, &r, 11.0, time.Hour, constants.BookingSourceExpress) // has a rider
mk("DM-CAN", constants.BookingCancelled, nil, 11.0, time.Hour, constants.BookingSourceExpress) // cancelled
mk("DM-NOLOC", constants.BookingPendingPickup, nil, 0, time.Hour, constants.BookingSourceExpress) // no pickup point
got, err := pendingForSweep(now)
mustDo(t, err)
if len(got) != 26 {
t.Fatalf("swept %d bookings, want 26 (25 console + 1 customer)", len(got))
}
t.Setenv("EXPRESS_AGENT_ENABLED", "true")
got, _ = pendingForSweep(now)
if len(got) != 1 || got[0].Bookingsource != constants.BookingSourceCustomerApp {
t.Fatalf("with the express agent on, only customer bookings are swept; got %d", len(got))
}
}

View File

@@ -0,0 +1,54 @@
package assignment
import (
"testing"
"time"
)
// The per-miler concurrent-stop cap.
//
// This is the knob that now decides how much one rider carries, because
// eligibility no longer stops at the first order. It is read per call so it
// can be changed without a redeploy — and a typo must never uncap the fleet.
func TestMaxActiveBookings(t *testing.T) {
cases := []struct {
env string
want int64
why string
}{
{"", defaultMaxActive, "unset falls back to the default"},
{"10", 10, "a plain number is honoured"},
{" 8 ", 8, "surrounding whitespace is tolerated"},
{"1", 1, "one stop at a time is a legitimate policy"},
{"0", defaultMaxActive, "zero would assign to nobody — treated as unset, not as a cap"},
{"-4", defaultMaxActive, "negative is meaningless here"},
{"lots", defaultMaxActive, "a typo must not uncap the fleet"},
{"3.5", defaultMaxActive, "not an integer"},
}
for _, tc := range cases {
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", tc.env)
if got := maxActiveBookings(); got != tc.want {
t.Errorf("MILER_MAX_ACTIVE_BOOKINGS=%q gave %d, want %d (%s)", tc.env, got, tc.want, tc.why)
}
}
}
// The cap counts only stops assigned since midnight India time, so an order
// left open yesterday or last month no longer blocks a working rider.
func TestStartOfISTDay(t *testing.T) {
ist := time.FixedZone("IST", 5*3600+30*60)
cases := []struct{ now, want time.Time }{
// 11:43 IST on 6 Oct -> 00:00 IST 6 Oct
{time.Date(2026, 10, 6, 11, 43, 0, 0, ist), time.Date(2026, 10, 6, 0, 0, 0, 0, ist)},
// 20:00 UTC on 5 Oct is already 01:30 on 6 Oct in India
{time.Date(2026, 10, 5, 20, 0, 0, 0, time.UTC), time.Date(2026, 10, 6, 0, 0, 0, 0, ist)},
// 18:29 UTC on 5 Oct is still 23:59 on 5 Oct in India
{time.Date(2026, 10, 5, 18, 29, 0, 0, time.UTC), time.Date(2026, 10, 5, 0, 0, 0, 0, ist)},
}
for _, c := range cases {
if got := startOfISTDay(c.now); !got.Equal(c.want) {
t.Errorf("startOfISTDay(%v) = %v, want %v", c.now, got, c.want)
}
}
}

View File

@@ -2,6 +2,9 @@ package assignment
import (
"encoding/json"
"os"
"strconv"
"strings"
"time"
"doormile/db"
@@ -43,6 +46,102 @@ const (
type assignmentRequest struct {
BookingID int `json:"booking_id"`
Kind string `json:"kind"`
// Extended retry. One "round" is one JetStream message with up to
// maxRetries deliveries (~8 minutes). A round that ends with no miler
// found re-queues the booking as a new round, until the retry window
// runs out. All three are omitempty so messages already on the stream
// (published before this field existed) still decode: they are treated
// as round 1 and their window starts when first seen.
FirstQueuedAt int64 `json:"first_queued_at,omitempty"` // unix seconds, first enqueue
Round int `json:"round,omitempty"` // 1-based
NotBefore int64 `json:"not_before,omitempty"` // unix seconds; wait until then before attempting
}
// ---- Extended retry --------------------------------------------------------
//
// Auto-assignment used to give up for good after one round — 5 attempts over
// ~8 minutes. A booking created while every nearby rider was full, on a
// break, or not yet broadcasting GPS then sat in pending_pickup forever, even
// after riders freed up minutes later: nothing ever looked at it again, and
// nothing on the booking said so. That is how DM-664517 got stuck.
//
// Now a failed round re-queues the booking and keeps trying every
// extendedRetryDelay until the retry window closes (default 2h, env
// ASSIGNMENT_RETRY_WINDOW_MINUTES). Each attempt re-reads the booking and
// stops as soon as it is cancelled or has a rider — including one assigned by
// hand — so a manual assignment ends the loop.
//
// maxRetries is deliberately NOT raised to get this. It is also the durable
// consumer's MaxDeliver, which is stored on the NATS server; subscribing with
// a different value fails ("subscribe failed") and the worker would then stop
// assigning anything at all. Re-queuing new rounds keeps the consumer config
// identical.
//
// booking.assignment_failed still fires once, at the end of the FIRST round,
// exactly when it did before — the DispatchAgent and ops alerting keep their
// timing and aren't sent a duplicate for every later round.
const (
extendedRetryDelay = 5 * time.Minute
defaultRetryWindowMinutes = 120
)
// retryWindow reads ASSIGNMENT_RETRY_WINDOW_MINUTES per call, like
// maxActiveBookings, so it can be tuned without a redeploy. 0 restores the old
// single-round behaviour; a non-numeric or negative value falls back to the
// default rather than disabling retries by typo.
func retryWindow() time.Duration {
if v := strings.TrimSpace(os.Getenv("ASSIGNMENT_RETRY_WINDOW_MINUTES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return time.Duration(n) * time.Minute
}
utils.Warn("ASSIGNMENT_RETRY_WINDOW_MINUTES is not a non-negative integer, using the default",
"value", v, "default_minutes", defaultRetryWindowMinutes)
}
return defaultRetryWindowMinutes * time.Minute
}
// withinRetryWindow reports whether another round may start now.
func withinRetryWindow(firstQueuedAt int64, now time.Time) bool {
if firstQueuedAt == 0 {
return true
}
return now.Sub(time.Unix(firstQueuedAt, 0)) < retryWindow()
}
// requeueNextRound publishes the booking as a new round that waits
// extendedRetryDelay before its first attempt. Returns false when it could not
// be queued (JetStream down or publish error) — the caller then gives up as
// the old code did, rather than dropping it silently.
func requeueNextRound(req assignmentRequest, now time.Time) bool {
if db.Js == nil {
return false
}
next := req
if next.FirstQueuedAt == 0 {
next.FirstQueuedAt = now.Unix()
}
if next.Round < 1 {
next.Round = 1
}
next.Round++
next.NotBefore = now.Add(extendedRetryDelay).Unix()
data, err := json.Marshal(next)
if err != nil {
utils.Error("Assignment: marshal next round failed", "booking_id", req.BookingID, "error", err)
return false
}
if _, err := db.Js.Publish(subjectAssignmentRequested, data); err != nil {
utils.Error("Assignment: publish next round failed", "booking_id", req.BookingID, "error", err)
return false
}
utils.Warn("Assignment: no miler this round, retrying later",
"booking_id", req.BookingID,
"next_round", next.Round,
"retry_in", extendedRetryDelay.String(),
)
return true
}
// enqueue publishes an assignment request, falling back to the old in-process
@@ -60,7 +159,12 @@ func enqueue(bookingID int, kind string) {
return
}
data, err := json.Marshal(assignmentRequest{BookingID: bookingID, Kind: kind})
data, err := json.Marshal(assignmentRequest{
BookingID: bookingID,
Kind: kind,
FirstQueuedAt: time.Now().Unix(),
Round: 1,
})
if err != nil {
utils.Error("Assignment: marshal failed, retrying in-process",
"booking_id", bookingID, "error", err)
@@ -149,6 +253,16 @@ func handleAssignmentMessage(msg *nats.Msg) {
return
}
// A later round waits before its first attempt. JetStream has no delayed
// publish, so the wait is a NAK — it uses one of the round's deliveries,
// leaving maxRetries-1 attempts, which is fine at this cadence.
if req.NotBefore > 0 {
if wait := time.Until(time.Unix(req.NotBefore, 0)); wait > time.Second {
_ = msg.NakWithDelay(wait)
return
}
}
// NumDelivered counts this delivery, so it runs 1..maxRetries.
attempt := 1
if md, err := msg.Metadata(); err == nil {
@@ -167,52 +281,99 @@ func handleAssignmentMessage(msg *nats.Msg) {
return
}
// Last delivery: JetStream will not redeliver past MaxDeliver, so the
// terminal failure has to be published here or it never fires at all. Ack
// rather than Nak so the message is not left to expire silently.
// Last delivery of this round: JetStream will not redeliver past
// MaxDeliver, so what happens next is decided here. Ack rather than Nak so
// the message is not left to expire silently.
if attempt >= maxRetries {
utils.Error("AssignmentWorker: NO_MILER_AVAILABLE — all attempts exhausted",
"booking_id", req.BookingID, "attempts", attempt)
publishAssignmentFailed(req.BookingID, reasonNoMilerAvailable)
round := req.Round
if round < 1 {
round = 1
}
if round == 1 {
utils.Error("AssignmentWorker: NO_MILER_AVAILABLE — first round exhausted",
"booking_id", req.BookingID, "attempts", attempt)
publishAssignmentFailed(req.BookingID, reasonNoMilerAvailable)
}
// Only "no miler found" earns another round. A hard error (booking
// gone, no pickup coordinates) won't fix itself by waiting.
now := time.Now()
if err == nil && withinRetryWindow(req.FirstQueuedAt, now) && requeueNextRound(req, now) {
_ = msg.Ack()
return
}
utils.Error("AssignmentWorker: NO_MILER_AVAILABLE — giving up",
"booking_id", req.BookingID,
"rounds", round,
"retry_window", retryWindow().String(),
"last_error", err,
)
_ = msg.Ack()
return
}
delay := retryDelay
if req.Round > 1 {
delay = extendedRetryDelay
}
utils.Warn("AssignmentWorker: no eligible miler, will retry",
"booking_id", req.BookingID,
"round", req.Round,
"attempt", attempt,
"remaining", maxRetries-attempt,
"retry_in", retryDelay.String(),
"retry_in", delay.String(),
)
_ = msg.NakWithDelay(retryDelay)
_ = msg.NakWithDelay(delay)
}
// runInline is the pre-JetStream behaviour, kept only as the fallback path when
// the event bus is down. It holds its retries in memory and does not survive a
// restart — which is exactly the weakness the queue exists to fix.
func runInline(bookingID int, kind string) {
for attempt := 1; attempt <= maxRetries; attempt++ {
firstQueuedAt := time.Now().Unix()
failurePublished := false
for attempt := 1; ; attempt++ {
if attempt > 1 {
time.Sleep(retryDelay)
if attempt <= maxRetries {
time.Sleep(retryDelay)
} else {
time.Sleep(extendedRetryDelay)
}
}
utils.Info("Assignment(inline): attempting", "booking_id", bookingID, "attempt", attempt)
done, err := attemptOnce(bookingID, kind)
if err != nil {
// Same rule as the queue: a hard error doesn't earn the extended
// window, but it still gets the original first-round attempts.
utils.Error("Assignment(inline): attempt error",
"booking_id", bookingID, "attempt", attempt, "error", err)
if attempt >= maxRetries {
break
}
continue
}
if done {
return
}
utils.Warn("Assignment(inline): no eligible miler",
"booking_id", bookingID, "attempt", attempt, "remaining", maxRetries-attempt)
if attempt == maxRetries && !failurePublished {
utils.Error("Assignment(inline): NO_MILER_AVAILABLE — first round exhausted",
"booking_id", bookingID, "attempts", attempt)
publishAssignmentFailed(bookingID, reasonNoMilerAvailable)
failurePublished = true
}
if attempt >= maxRetries && !withinRetryWindow(firstQueuedAt, time.Now()) {
break
}
utils.Warn("Assignment(inline): no eligible miler", "booking_id", bookingID, "attempt", attempt)
}
utils.Error("Assignment(inline): NO_MILER_AVAILABLE — all retries exhausted",
"booking_id", bookingID, "max_retries", maxRetries)
publishAssignmentFailed(bookingID, reasonNoMilerAvailable)
utils.Error("Assignment(inline): NO_MILER_AVAILABLE — giving up",
"booking_id", bookingID, "retry_window", retryWindow().String())
if !failurePublished {
publishAssignmentFailed(bookingID, reasonNoMilerAvailable)
}
}

View File

@@ -0,0 +1,232 @@
package assignment
import (
"math"
"os"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/models"
"doormile/utils"
"github.com/redis/go-redis/v9"
)
// Balanced assignment, Phase B (docs/balanced-assignment-plan.md): riders come
// and go during the day.
//
// - A rider whose app dies right after being given an order never accepts it,
// and the order used to sit with them for good. releaseUnaccepted hands it
// back to the pool after ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES.
// - A rider who starts duty used to wait for the next sweep (up to five
// minutes) before getting anything. SweepNear assigns the pending orders
// around them straight away.
// - An order released by, rejected by or cancelled by a rider is never offered
// to that rider again (ridersToSkip).
const defaultAcceptTimeoutMinutes = 10
// autoAssignedRemark labels the assignments this package makes. Only those
// are ever released: a rider hand-picked by ops or hub staff (whose manual
// path records no "assigned by" for the hub console) is never taken away, and
// nor is anything assigned before the label existed.
const autoAssignedRemark = "Auto-assigned"
// acceptTimeout: ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES, default 10; 0 turns the
// release off. A typo falls back to the default.
func acceptTimeout() int {
if v := strings.TrimSpace(os.Getenv("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
utils.Warn("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES is not a non-negative integer, using the default",
"value", v, "default", defaultAcceptTimeoutMinutes)
}
return defaultAcceptTimeoutMinutes
}
// releaseUnaccepted returns orders to the pool when the rider they were given
// to by auto-assignment has not accepted within the timeout. Only an
// auto-assignment (autoAssignedRemark) still "Assigned"
// on an order still "Miler_Assigned" qualifies: once the rider accepts
// (Accepted / Pickup_Scheduled) or starts the pickup, the order is theirs and
// is never taken away. Returns how many orders were released.
func releaseUnaccepted() int {
timeout := acceptTimeout()
if timeout == 0 || db.DB == nil {
return 0
}
type stale struct {
Bookingassignmentid int
Bookingid int
Mileruserid int
}
var rows []stale
if err := db.DB.Table("bookingassignments AS ba").
Select("ba.bookingassignmentid, ba.bookingid, ba.mileruserid").
Joins("JOIN pickupbookings pb ON pb.bookingid = ba.bookingid").
Where("ba.assignmentstatus = ? AND ba.remarks = ? AND pb.status = ? AND pb.assignedmileruserid = ba.mileruserid",
constants.AssignmentAssigned, autoAssignedRemark, constants.BookingMilerAssigned).
Where("ba.assignedat < NOW() - make_interval(mins => ?)", timeout).
Order("ba.assignedat ASC").Limit(sweepBatch).
Scan(&rows).Error; err != nil {
utils.Error("ReleaseUnaccepted: could not list stale assignments", "error", err)
return 0
}
released := 0
for _, r := range rows {
if releaseOne(r.Bookingassignmentid, r.Bookingid, r.Mileruserid, timeout) {
released++
}
}
if released > 0 {
utils.Info("ReleaseUnaccepted: orders returned to the pool", "released", released, "timeout_minutes", timeout)
}
return released
}
// releaseOne returns one order to the pool in a single transaction. Every
// write is conditional on the state it was read in, so a rider accepting at
// the same moment wins and nothing is released.
func releaseOne(assignmentID, bookingID, milerUserID, timeout int) bool {
tx := db.DB.Begin()
now := time.Now()
res := tx.Model(&models.PickupBooking{}).
Where("bookingid = ? AND assignedmileruserid = ? AND status = ?", bookingID, milerUserID, constants.BookingMilerAssigned).
Updates(map[string]interface{}{
"assignedmileruserid": nil,
"status": constants.BookingPendingPickup,
"updatedat": now,
})
if res.Error != nil || res.RowsAffected == 0 {
tx.Rollback()
return false
}
res = tx.Model(&models.BookingAssignment{}).
Where("bookingassignmentid = ? AND assignmentstatus = ?", assignmentID, constants.AssignmentAssigned).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentReassigned,
"remarks": "Not accepted within " + strconv.Itoa(timeout) + " min; returned to the pool",
})
if res.Error != nil || res.RowsAffected == 0 {
tx.Rollback()
return false
}
// The customer was told "rider assigned"; walk that back so their app does
// not show a rider who is not coming. No-op for console bookings.
if err := cxstage.Release(tx, bookingID, "Rider did not accept in time",
constants.CxActorSystem, nil, "internal/assignment.releaseUnaccepted"); err != nil {
tx.Rollback()
utils.Error("ReleaseUnaccepted: could not update the customer projection", "booking_id", bookingID, "error", err)
return false
}
// Free the rider if this was the only order they held.
var stillOpen int64
tx.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Count(&stillOpen)
if stillOpen == 0 {
tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus = ?", milerUserID, constants.MilerAssigned).
Update("availabilitystatus", constants.MilerAvailable)
}
if err := tx.Commit().Error; err != nil {
utils.Error("ReleaseUnaccepted: commit failed", "booking_id", bookingID, "error", err)
return false
}
utils.Info("ReleaseUnaccepted: order returned to the pool", "booking_id", bookingID, "miler_id", milerUserID)
return true
}
// ridersToSkip lists the riders an order must not go back to: the ones it was
// released from, who rejected it, or who cancelled their assignment on it.
func ridersToSkip(bookingID int) map[int]bool {
skip := map[int]bool{}
if db.DB == nil {
return skip
}
var ids []int
db.DB.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND assignmentstatus IN ?", bookingID,
[]string{constants.AssignmentReassigned, constants.AssignmentRejected, constants.AssignmentCancelled}).
Distinct().Pluck("mileruserid", &ids)
for _, id := range ids {
skip[id] = true
}
return skip
}
// withoutRiders drops the given riders from a GEO search result.
func withoutRiders(nearby []redis.GeoLocation, skip map[int]bool) []redis.GeoLocation {
if len(skip) == 0 {
return nearby
}
out := nearby[:0:0]
for _, loc := range nearby {
if id, err := strconv.Atoi(loc.Name); err == nil && skip[id] {
continue
}
out = append(out, loc)
}
return out
}
// SweepNear assigns the pending orders around a rider who just started duty,
// instead of leaving them for the next periodic sweep. Call it in a goroutine.
func SweepNear(lat, lon float64) {
defer func() {
if r := recover(); r != nil {
utils.Error("SweepNear: panic recovered", "error", r)
}
}()
if db.DB == nil || (lat == 0 && lon == 0) {
return
}
pending, err := pendingNear(lat, lon, time.Now())
if err != nil {
utils.Error("SweepNear: could not list pending bookings", "error", err)
return
}
assigned := 0
for _, b := range pending {
if ok, err := attemptOnce(b.Bookingid, kindFor(b.Bookingsource)); err == nil && ok {
assigned++
}
}
if len(pending) > 0 {
utils.Info("SweepNear: rider came online, swept nearby pending bookings",
"pending", len(pending), "assigned_or_done", assigned)
}
}
// pendingNear: the sweeper's pending bookings whose pickup lies within the
// assignment search radius of (lat, lon).
func pendingNear(lat, lon float64, now time.Time) ([]models.PickupBooking, error) {
all, err := pendingForSweep(now)
if err != nil {
return nil, err
}
var near []models.PickupBooking
for _, b := range all {
if distanceKm(lat, lon, b.Pickuplatitude, b.Pickuplongitude) <= geoRadiusKm {
near = append(near, b)
}
}
return near, nil
}
// distanceKm is the great-circle distance between two points.
func distanceKm(lat1, lon1, lat2, lon2 float64) float64 {
const r = 6371.0
toRad := func(d float64) float64 { return d * math.Pi / 180 }
dLat, dLon := toRad(lat2-lat1), toRad(lon2-lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * r * math.Asin(math.Sqrt(a))
}

View File

@@ -0,0 +1,175 @@
package assignment
import (
"testing"
"time"
"doormile/constants"
"doormile/models"
"gorm.io/gorm"
)
// Balanced assignment, Phase B, against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN is set — see eligibility_pg_test.go.
// autoOrder is order() for an assignment made by auto-assignment (labelled),
// the only kind the release touches.
func autoOrder(t *testing.T, gdb *gorm.DB, rider int, bookingStatus, asgStatus string, at time.Time) int {
t.Helper()
id := order(t, gdb, rider, bookingStatus, asgStatus, at)
mustDo(t, gdb.Model(&models.BookingAssignment{}).Where("bookingid = ?", id).Update("remarks", autoAssignedRemark).Error)
return id
}
func bookingState(t *testing.T, gdb *gorm.DB, id int) (string, *int) {
t.Helper()
var b models.PickupBooking
mustDo(t, gdb.First(&b, id).Error)
return b.Status, b.Assignedmileruserid
}
func assignmentState(t *testing.T, gdb *gorm.DB, bookingID int) string {
t.Helper()
var a models.BookingAssignment
mustDo(t, gdb.Where("bookingid = ?", bookingID).Order("bookingassignmentid DESC").First(&a).Error)
return a.Assignmentstatus
}
// An order the rider never accepted goes back to the pool after the timeout;
// one they accepted, or one still within the timeout, stays with them.
func TestReleaseUnacceptedOrders(t *testing.T) {
gdb := balanceSetup(t)
t.Setenv("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES", "10")
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
mustDo(t, gdb.Model(&models.MilerProfile{}).Where("userid = 1").Update("availabilitystatus", constants.MilerAssigned).Error)
stale := autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-15*time.Minute))
fresh := autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-3*time.Minute))
accepted := autoOrder(t, gdb, 1, constants.BookingPickupScheduled, constants.AssignmentAccepted, time.Now().Add(-40*time.Minute))
if n := releaseUnaccepted(); n != 1 {
t.Fatalf("released %d, want 1", n)
}
if st, r := bookingState(t, gdb, stale); st != constants.BookingPendingPickup || r != nil {
t.Fatalf("stale order: status %s rider %v, want Pending_Pickup with no rider", st, r)
}
if s := assignmentState(t, gdb, stale); s != constants.AssignmentReassigned {
t.Fatalf("stale assignment = %s, want Reassigned", s)
}
if st, r := bookingState(t, gdb, fresh); st != constants.BookingMilerAssigned || r == nil {
t.Fatal("an order still inside the timeout must stay with the rider")
}
if st, r := bookingState(t, gdb, accepted); st != constants.BookingPickupScheduled || r == nil {
t.Fatal("an accepted order must never be released")
}
// The rider still holds two orders, so stays Assigned.
var p models.MilerProfile
mustDo(t, gdb.Where("userid = 1").First(&p).Error)
if p.Availabilitystatus != constants.MilerAssigned {
t.Fatalf("rider status %s, want Assigned (still holding orders)", p.Availabilitystatus)
}
// A rider hand-picked by staff (no auto label) is never taken away.
manual := order(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-2*time.Hour))
if n := releaseUnaccepted(); n != 0 {
t.Fatal("a manually assigned order must never be released")
}
if st, r := bookingState(t, gdb, manual); st != constants.BookingMilerAssigned || r == nil {
t.Fatal("manual order changed")
}
t.Setenv("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES", "0")
autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-2*time.Hour))
if n := releaseUnaccepted(); n != 0 {
t.Fatal("timeout 0 must switch the release off")
}
}
// A rider freed of their only order goes back to Available.
func TestReleaseFreesARiderWithNothingElse(t *testing.T) {
gdb := balanceSetup(t)
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
mustDo(t, gdb.Model(&models.MilerProfile{}).Where("userid = 1").Update("availabilitystatus", constants.MilerAssigned).Error)
autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-30*time.Minute))
if n := releaseUnaccepted(); n != 1 {
t.Fatalf("released %d, want 1", n)
}
var p models.MilerProfile
mustDo(t, gdb.Where("userid = 1").First(&p).Error)
if p.Availabilitystatus != constants.MilerAvailable {
t.Fatalf("rider status %s, want Available", p.Availabilitystatus)
}
}
// A rider accepting at the same moment wins: nothing is released.
func TestReleaseLosesToAConcurrentAccept(t *testing.T) {
gdb := balanceSetup(t)
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
b := order(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-30*time.Minute))
var a models.BookingAssignment
mustDo(t, gdb.Where("bookingid = ?", b).First(&a).Error)
// The rider accepts between the release listing and its write.
mustDo(t, gdb.Model(&models.PickupBooking{}).Where("bookingid = ?", b).Update("status", constants.BookingPickupScheduled).Error)
if releaseOne(a.Bookingassignmentid, b, 1, 10) {
t.Fatal("must not release an order the rider has just accepted")
}
if s := assignmentState(t, gdb, b); s != constants.AssignmentAssigned {
t.Fatalf("assignment changed to %s", s)
}
}
// A released (or rejected) order is never offered back to that rider: it goes
// to someone else even if the first rider is now the least loaded.
func TestReleasedOrderGoesToAnotherRider(t *testing.T) {
gdb := balanceSetup(t)
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
onDuty(t, gdb, 2, time.Now().Add(-time.Hour))
// Rider 2 is busier, so a plain balance would pick rider 1.
order(t, gdb, 2, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now())
b := autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-30*time.Minute))
if n := releaseUnaccepted(); n != 1 {
t.Fatalf("released %d, want 1", n)
}
id, err := assignOne(t, gdb, b, near(1, 2))
if err != nil {
t.Fatal(err)
}
if id != 2 {
t.Fatalf("released order went to rider %d, want 2 (never back to rider 1)", id)
}
// Same rule for a rider who rejected it.
r := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
mustDo(t, gdb.Create(&models.BookingAssignment{Bookingid: r, Mileruserid: 2, Assignmentstatus: constants.AssignmentRejected, Assignedat: time.Now()}).Error)
if id, _ := assignOne(t, gdb, r, near(1, 2)); id != 1 {
t.Fatalf("rejected order went to rider %d, want 1", id)
}
}
// When a rider starts duty, only the pending orders within reach are swept.
func TestPendingNearOnlyPicksNearbyOrders(t *testing.T) {
gdb := balanceSetup(t)
now := time.Now()
mk := func(no string, lat, lon float64) {
nextBookingID++
mustDo(t, gdb.Create(&models.PickupBooking{Bookingid: nextBookingID, Bookingno: no, Status: constants.BookingPendingPickup,
Bookingsource: constants.BookingSourceExpress, Pickuplatitude: lat, Pickuplongitude: lon,
Createdat: now.Add(-10 * time.Minute)}).Error)
}
mk("DM-NEAR1", 11.0090, 76.9500) // at the rider
mk("DM-NEAR2", 11.0500, 76.9800) // ~5.5 km
mk("DM-FAR", 11.3000, 77.2000) // ~40 km
mk("DM-CHENNAI", 13.0827, 80.2707) // another city
got, err := pendingNear(11.0090, 76.9500, now)
mustDo(t, err)
names := map[string]bool{}
for _, b := range got {
var full models.PickupBooking
mustDo(t, gdb.First(&full, b.Bookingid).Error)
names[full.Bookingno] = true
}
if len(got) != 2 || !names["DM-NEAR1"] || !names["DM-NEAR2"] {
t.Fatalf("swept %v, want only the two nearby orders", names)
}
}

View File

@@ -0,0 +1,48 @@
package assignment
import (
"math"
"testing"
"github.com/redis/go-redis/v9"
)
func TestAcceptTimeout(t *testing.T) {
cases := map[string]int{
"": defaultAcceptTimeoutMinutes,
"0": 0, // off
"5": 5,
"-1": defaultAcceptTimeoutMinutes,
"soon": defaultAcceptTimeoutMinutes, // a typo must not switch it off
}
for env, want := range cases {
t.Setenv("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES", env)
if got := acceptTimeout(); got != want {
t.Errorf("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES=%q: %d, want %d", env, got, want)
}
}
}
func TestWithoutRiders(t *testing.T) {
nearby := []redis.GeoLocation{{Name: "1"}, {Name: "2"}, {Name: "3"}, {Name: "x"}}
got := withoutRiders(nearby, map[int]bool{2: true})
if len(got) != 3 || got[0].Name != "1" || got[1].Name != "3" || got[2].Name != "x" {
t.Fatalf("got %v", got)
}
if len(nearby) != 4 || nearby[1].Name != "2" {
t.Fatal("the input slice must not be modified")
}
if got := withoutRiders(nearby, nil); len(got) != 4 {
t.Fatal("nothing to skip, nothing removed")
}
}
func TestDistanceKm(t *testing.T) {
// RS Puram to Gandhipuram, Coimbatore: about 2.2 km.
if d := distanceKm(11.0090, 76.9500, 11.0182714, 76.9677744); math.Abs(d-2.17) > 0.2 {
t.Fatalf("distance = %.2f km", d)
}
if d := distanceKm(11, 77, 11, 77); d != 0 {
t.Fatalf("same point = %v", d)
}
}

View File

@@ -0,0 +1,78 @@
package assignment
import (
"encoding/json"
"testing"
"time"
)
func TestRetryWindowDefaultAndOverride(t *testing.T) {
t.Setenv("ASSIGNMENT_RETRY_WINDOW_MINUTES", "")
if got := retryWindow(); got != 120*time.Minute {
t.Fatalf("default window = %v, want 2h", got)
}
t.Setenv("ASSIGNMENT_RETRY_WINDOW_MINUTES", "45")
if got := retryWindow(); got != 45*time.Minute {
t.Fatalf("override window = %v, want 45m", got)
}
// 0 restores the old single-round behaviour.
t.Setenv("ASSIGNMENT_RETRY_WINDOW_MINUTES", "0")
if got := retryWindow(); got != 0 {
t.Fatalf("zero window = %v, want 0", got)
}
// A typo must not disable retries.
for _, bad := range []string{"abc", "-5", "1.5"} {
t.Setenv("ASSIGNMENT_RETRY_WINDOW_MINUTES", bad)
if got := retryWindow(); got != 120*time.Minute {
t.Fatalf("bad value %q gave %v, want the 2h default", bad, got)
}
}
}
func TestWithinRetryWindow(t *testing.T) {
t.Setenv("ASSIGNMENT_RETRY_WINDOW_MINUTES", "120")
now := time.Now()
if !withinRetryWindow(now.Add(-10*time.Minute).Unix(), now) {
t.Fatal("10 minutes in should still retry")
}
if withinRetryWindow(now.Add(-121*time.Minute).Unix(), now) {
t.Fatal("121 minutes in should give up")
}
// Messages queued before this change carry no timestamp; they get a
// window starting now rather than being dropped.
if !withinRetryWindow(0, now) {
t.Fatal("a message without first_queued_at should retry")
}
t.Setenv("ASSIGNMENT_RETRY_WINDOW_MINUTES", "0")
if withinRetryWindow(now.Add(-time.Second).Unix(), now) {
t.Fatal("window 0 should never start another round")
}
}
// Messages already on the ASSIGNMENTS stream when this ships were published
// with only booking_id and kind. They must still decode and act as round 1.
func TestAssignmentRequestDecodesOldPayload(t *testing.T) {
var req assignmentRequest
if err := json.Unmarshal([]byte(`{"booking_id":664517,"kind":"express"}`), &req); err != nil {
t.Fatalf("old payload failed to decode: %v", err)
}
if req.BookingID != 664517 || req.Kind != kindExpress {
t.Fatalf("decoded %+v", req)
}
if req.Round != 0 || req.FirstQueuedAt != 0 || req.NotBefore != 0 {
t.Fatalf("new fields should be zero on an old payload, got %+v", req)
}
}
// requeueNextRound must refuse (return false) rather than panic when
// JetStream is not connected, so the caller falls back to giving up cleanly.
func TestRequeueWithoutJetStream(t *testing.T) {
if requeueNextRound(assignmentRequest{BookingID: 1, Kind: kindExpress, Round: 1}, time.Now()) {
t.Fatal("requeue should report false with no JetStream connection")
}
}

View File

@@ -0,0 +1,168 @@
package assignment
import (
"context"
"os"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
)
// The pending-order sweeper.
//
// A booking gets assignment attempts when it is created, and retries for a
// limited window (ASSIGNMENT_RETRY_WINDOW_MINUTES on the queue; about ten
// minutes on the in-process fallback when NATS is down). A booking whose window
// closed while every rider was busy, off duty or blocked then sat in Pending
// for good — nothing looked at it again, even once riders were free.
//
// The sweeper closes that gap: every ASSIGNMENT_SWEEP_SECONDS (default 300) it
// makes one assignment attempt for every unassigned pending booking, however
// many there are. One attempt per booking per sweep, run in-process, so a
// sweep never multiplies queue messages. claimBooking makes a booking that is
// assigned meanwhile — by the queue, by hand, by another replica — a no-op.
const (
defaultSweepSeconds = 300
defaultSweepMaxAgeHours = 72
// sweepBatch caps one sweep's work; the rest are next sweep's.
sweepBatch = 200
// sweepMinAge leaves a just-created booking to its own first attempt.
sweepMinAge = 2 * time.Minute
sweepLockKey = "assignment:pending-sweep:lock"
)
// sweepInterval: ASSIGNMENT_SWEEP_SECONDS, default 300; 0 turns the sweeper
// off. Read once at start.
func sweepInterval() time.Duration {
if v := strings.TrimSpace(os.Getenv("ASSIGNMENT_SWEEP_SECONDS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
if n > 0 && n < 30 {
n = 30 // a sweep makes one attempt per pending booking; keep it sane
}
return time.Duration(n) * time.Second
}
utils.Warn("ASSIGNMENT_SWEEP_SECONDS is not a non-negative integer, using the default",
"value", v, "default_seconds", defaultSweepSeconds)
}
return defaultSweepSeconds * time.Second
}
// sweepMaxAge: ASSIGNMENT_SWEEP_MAX_AGE_HOURS, default 72. Older pending
// bookings are left alone — they are almost certainly abandoned, and offering
// them to a rider now would send someone to a pickup nobody is waiting at.
func sweepMaxAge() time.Duration {
if v := strings.TrimSpace(os.Getenv("ASSIGNMENT_SWEEP_MAX_AGE_HOURS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Hour
}
utils.Warn("ASSIGNMENT_SWEEP_MAX_AGE_HOURS is not a positive integer, using the default",
"value", v, "default_hours", defaultSweepMaxAgeHours)
}
return defaultSweepMaxAgeHours * time.Hour
}
// kindFor picks the assignment path for a booking source: customer-app
// bookings go through the B2C path, everything else through the express one —
// the same split the create handlers make.
func kindFor(source string) string {
if source == constants.BookingSourceCustomerApp {
return kindCustomer
}
return kindExpress
}
// sweepSkipsExpress: with EXPRESS_AGENT_ENABLED=true, bulk console bookings
// are deliberately left for the ExpressDispatchAgent to batch, so the sweeper
// must not assign console bookings behind its back.
func sweepSkipsExpress() bool {
return strings.EqualFold(os.Getenv("EXPRESS_AGENT_ENABLED"), "true")
}
// StartPendingSweeper runs the sweep loop. Call once at boot, in a goroutine.
func StartPendingSweeper() {
interval := sweepInterval()
if interval == 0 {
utils.Info("PendingSweeper: disabled (ASSIGNMENT_SWEEP_SECONDS=0)")
return
}
utils.Info("PendingSweeper: started", "interval", interval.String(), "max_age", sweepMaxAge().String())
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
sweepOnce(interval)
}
}
// sweepOnce makes one attempt for each eligible pending booking. Only one
// replica sweeps at a time (a Redis lock that expires before the next tick);
// without Redis every replica sweeps, which claimBooking keeps correct.
func sweepOnce(interval time.Duration) {
defer func() {
if r := recover(); r != nil {
utils.Error("PendingSweeper: panic recovered", "error", r)
}
}()
if db.DB == nil {
return
}
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
got, err := db.Rdb.SetNX(ctx, sweepLockKey, "1", interval-10*time.Second).Result()
cancel()
if err == nil && !got {
return // another replica has this sweep
}
}
// Orders a rider never accepted go back to the pool first, so this same
// sweep can hand them to someone else.
releaseUnaccepted()
pending, err := pendingForSweep(time.Now())
if err != nil {
utils.Error("PendingSweeper: could not list pending bookings", "error", err)
return
}
if len(pending) == 0 {
return
}
assigned, failed := 0, 0
for _, b := range pending {
ok, err := attemptOnce(b.Bookingid, kindFor(b.Bookingsource))
switch {
case err != nil:
failed++
utils.Warn("PendingSweeper: attempt failed", "booking_id", b.Bookingid, "error", err)
case ok:
assigned++
}
}
utils.Info("PendingSweeper: swept pending bookings",
"pending", len(pending), "assigned_or_done", assigned, "errors", failed,
"still_waiting", len(pending)-assigned-failed)
}
// pendingForSweep lists the bookings a sweep retries: pending, no rider, with
// a pickup location, created between sweepMaxAge ago and sweepMinAge ago,
// oldest first, at most sweepBatch. Console bookings are left out while the
// ExpressDispatchAgent owns them.
func pendingForSweep(now time.Time) ([]models.PickupBooking, error) {
q := db.DB.Model(&models.PickupBooking{}).
Select("bookingid", "bookingsource", "pickuplatitude", "pickuplongitude").
Where("status = ? AND assignedmileruserid IS NULL", constants.BookingPendingPickup).
Where("pickuplatitude <> 0 AND pickuplongitude <> 0").
Where("createdat <= ? AND createdat >= ?", now.Add(-sweepMinAge), now.Add(-sweepMaxAge()))
if sweepSkipsExpress() {
q = q.Where("bookingsource = ?", constants.BookingSourceCustomerApp)
}
var pending []models.PickupBooking
err := q.Order("createdat ASC").Limit(sweepBatch).Find(&pending).Error
return pending, err
}

View File

@@ -0,0 +1,81 @@
package assignment
import (
"testing"
"time"
"doormile/constants"
)
func TestSweepInterval(t *testing.T) {
cases := []struct {
env string
want time.Duration
}{
{"", defaultSweepSeconds * time.Second},
{"0", 0}, // off
{"120", 120 * time.Second},
{"5", 30 * time.Second}, // floored: one attempt per pending booking per sweep
{"soon", defaultSweepSeconds * time.Second},
{"-1", defaultSweepSeconds * time.Second},
}
for _, c := range cases {
t.Setenv("ASSIGNMENT_SWEEP_SECONDS", c.env)
if got := sweepInterval(); got != c.want {
t.Errorf("ASSIGNMENT_SWEEP_SECONDS=%q: %v, want %v", c.env, got, c.want)
}
}
}
func TestSweepMaxAge(t *testing.T) {
cases := map[string]time.Duration{
"": defaultSweepMaxAgeHours * time.Hour,
"24": 24 * time.Hour,
"0": defaultSweepMaxAgeHours * time.Hour, // 0 would sweep nothing
"two": defaultSweepMaxAgeHours * time.Hour,
}
for env, want := range cases {
t.Setenv("ASSIGNMENT_SWEEP_MAX_AGE_HOURS", env)
if got := sweepMaxAge(); got != want {
t.Errorf("ASSIGNMENT_SWEEP_MAX_AGE_HOURS=%q: %v, want %v", env, got, want)
}
}
}
func TestKindFor(t *testing.T) {
if kindFor(constants.BookingSourceCustomerApp) != kindCustomer {
t.Error("customer-app bookings take the customer path")
}
for _, s := range []string{constants.BookingSourceExpress, "", "anything"} {
if kindFor(s) != kindExpress {
t.Errorf("%q should take the express path", s)
}
}
}
func TestSweepSkipsExpressFollowsTheAgentFlag(t *testing.T) {
t.Setenv("EXPRESS_AGENT_ENABLED", "")
if sweepSkipsExpress() {
t.Error("agent off: console bookings are swept")
}
t.Setenv("EXPRESS_AGENT_ENABLED", "true")
if !sweepSkipsExpress() {
t.Error("agent on: console bookings are left to the agent")
}
}
func TestMaxGPSAgeMinutes(t *testing.T) {
cases := map[string]int{
"": defaultMaxGPSAgeMinutes,
"0": 0, // check off
"30": 30,
"-5": defaultMaxGPSAgeMinutes,
"half": defaultMaxGPSAgeMinutes, // a typo must not switch the check off
}
for env, want := range cases {
t.Setenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES", env)
if got := maxGPSAgeMinutes(); got != want {
t.Errorf("ASSIGNMENT_MAX_GPS_AGE_MINUTES=%q: %d, want %d", env, got, want)
}
}
}

View File

@@ -0,0 +1,131 @@
// Package milergeo is the one place that reads and writes the live rider
// positions in Redis (the `milers:locations` GEO set). Auto-assignment, the
// customer pickup-slot check and the agent playground all search it; the rider
// location ping and duty start write it.
//
// Why it exists: every caller used GEOSEARCH, which Redis only has from 6.2.
// On an older server the writes (GEOADD) succeed but every search fails, and
// the callers treated the failure as "no riders nearby" — so orders sat in
// Pending with riders standing next to the pickup and nothing in the console
// said why. Search now falls back to GEORADIUS (Redis 3.2+), which answers the
// same question, and Probe reports at boot whether the search works at all.
package milergeo
import (
"context"
"fmt"
"reflect"
"strings"
"sync/atomic"
"github.com/redis/go-redis/v9"
)
// Key is the GEO set holding each rider's last reported position, member =
// the rider's userid as a decimal string.
const Key = "milers:locations"
// Client is the slice of the Redis client this package needs; *redis.Client
// satisfies it. An interface so the fallback can be tested without a server.
type Client interface {
GeoAdd(ctx context.Context, key string, geoLocation ...*redis.GeoLocation) *redis.IntCmd
GeoSearchLocation(ctx context.Context, key string, q *redis.GeoSearchLocationQuery) *redis.GeoSearchLocationCmd
GeoRadius(ctx context.Context, key string, longitude, latitude float64, query *redis.GeoRadiusQuery) *redis.GeoLocationCmd
}
// legacyOnly flips to true the first time the server rejects GEOSEARCH as an
// unknown command, so later searches go straight to GEORADIUS instead of
// paying for a failed round trip every time.
var legacyOnly atomic.Bool
// Search returns the riders within radiusKm of (lat, lon), nearest first, at
// most count of them, each with Dist (km) set. It uses GEOSEARCH and falls back
// to GEORADIUS when the server is older than Redis 6.2.
//
// Any other error (timeout, WRONGTYPE on the key, ...) is returned as is: the
// caller must not mistake a broken search for an empty street.
func Search(ctx context.Context, rdb Client, lat, lon, radiusKm float64, count int) ([]redis.GeoLocation, error) {
if isNil(rdb) {
return nil, fmt.Errorf("redis not available")
}
if !legacyOnly.Load() {
locs, err := rdb.GeoSearchLocation(ctx, Key, &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: lon,
Latitude: lat,
Radius: radiusKm,
RadiusUnit: "km",
Sort: "ASC",
Count: count,
},
WithDist: true,
}).Result()
if err == nil {
return locs, nil
}
if !isUnknownCommand(err) {
return nil, fmt.Errorf("GEOSEARCH %s: %w", Key, err)
}
legacyOnly.Store(true)
}
locs, err := rdb.GeoRadius(ctx, Key, lon, lat, &redis.GeoRadiusQuery{
Radius: radiusKm,
Unit: "km",
WithDist: true,
Count: count,
Sort: "ASC",
}).Result()
if err != nil {
return nil, fmt.Errorf("GEORADIUS %s: %w", Key, err)
}
return locs, nil
}
// Index records a rider's position. The error is returned so callers can log
// it — a failed write here is a rider auto-assignment can never find.
func Index(ctx context.Context, rdb Client, milerUserID int, lat, lon float64) error {
if isNil(rdb) {
return fmt.Errorf("redis not available")
}
if err := rdb.GeoAdd(ctx, Key, &redis.GeoLocation{
Name: fmt.Sprint(milerUserID),
Latitude: lat,
Longitude: lon,
}).Err(); err != nil {
return fmt.Errorf("GEOADD %s: %w", Key, err)
}
return nil
}
// Probe runs one search to report, at boot and on /ready, whether rider
// search works on this Redis: "ok", "ok (GEORADIUS fallback: Redis older than
// 6.2)", or "error: ...". It never fails the caller.
func Probe(ctx context.Context, rdb Client) string {
if isNil(rdb) {
return "error: redis not available"
}
if _, err := Search(ctx, rdb, 11.0168, 76.9558, 1, 1); err != nil {
return "error: " + err.Error()
}
if legacyOnly.Load() {
return "ok (GEORADIUS fallback: Redis older than 6.2)"
}
return "ok"
}
// isNil also catches a nil *redis.Client inside the interface (db.Rdb before
// InitRedis), which a plain == nil does not.
func isNil(rdb Client) bool {
if rdb == nil {
return true
}
v := reflect.ValueOf(rdb)
return v.Kind() == reflect.Ptr && v.IsNil()
}
// isUnknownCommand: how Redis < 6.2 (and proxies that filter commands) answer
// a command they do not have, e.g. "ERR unknown command 'GEOSEARCH'".
func isUnknownCommand(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown command")
}

View File

@@ -0,0 +1,120 @@
package milergeo
import (
"context"
"errors"
"strings"
"testing"
"github.com/redis/go-redis/v9"
)
// fakeRedis answers the three geo commands the way a given Redis would.
type fakeRedis struct {
searchErr error // GEOSEARCH result; nil = supported
radiusErr error
addErr error
locs []redis.GeoLocation
searchHits int
radiusHits int
}
func (f *fakeRedis) GeoSearchLocation(ctx context.Context, _ string, q *redis.GeoSearchLocationQuery) *redis.GeoSearchLocationCmd {
f.searchHits++
cmd := redis.NewGeoSearchLocationCmd(ctx, q)
if f.searchErr != nil {
cmd.SetErr(f.searchErr)
} else {
cmd.SetVal(f.locs)
}
return cmd
}
func (f *fakeRedis) GeoRadius(_ context.Context, _ string, _, _ float64, _ *redis.GeoRadiusQuery) *redis.GeoLocationCmd {
f.radiusHits++
return redis.NewGeoLocationCmdResult(f.locs, f.radiusErr)
}
func (f *fakeRedis) GeoAdd(ctx context.Context, _ string, _ ...*redis.GeoLocation) *redis.IntCmd {
cmd := redis.NewIntCmd(ctx)
if f.addErr != nil {
cmd.SetErr(f.addErr)
} else {
cmd.SetVal(1)
}
return cmd
}
var nearby = []redis.GeoLocation{{Name: "38", Dist: 0.03}, {Name: "23", Dist: 0.4}}
func TestSearchUsesGeosearchOnRedis62(t *testing.T) {
legacyOnly.Store(false)
f := &fakeRedis{locs: nearby}
got, err := Search(context.Background(), f, 11.0053, 76.9511, 10, 10)
if err != nil || len(got) != 2 || got[0].Name != "38" {
t.Fatalf("got %v, %v", got, err)
}
if f.searchHits != 1 || f.radiusHits != 0 {
t.Fatalf("GEOSEARCH must be used when supported: search=%d radius=%d", f.searchHits, f.radiusHits)
}
}
// The production failure: Redis < 6.2 has no GEOSEARCH, so every order found
// "no riders". The fallback must find them, and stop retrying GEOSEARCH.
func TestSearchFallsBackOnOldRedis(t *testing.T) {
legacyOnly.Store(false)
t.Cleanup(func() { legacyOnly.Store(false) })
f := &fakeRedis{searchErr: errors.New("ERR unknown command 'GEOSEARCH', with args beginning with: 'milers:locations'"), locs: nearby}
for i := 0; i < 3; i++ {
got, err := Search(context.Background(), f, 11.0053, 76.9511, 10, 10)
if err != nil || len(got) != 2 {
t.Fatalf("call %d: got %v, %v", i, got, err)
}
}
if f.searchHits != 1 || f.radiusHits != 3 {
t.Fatalf("after the first rejection only GEORADIUS should run: search=%d radius=%d", f.searchHits, f.radiusHits)
}
if p := Probe(context.Background(), f); !strings.Contains(p, "GEORADIUS fallback") {
t.Fatalf("probe = %q", p)
}
}
// Any other failure must surface, not read as an empty street.
func TestSearchReturnsOtherErrors(t *testing.T) {
legacyOnly.Store(false)
f := &fakeRedis{searchErr: errors.New("WRONGTYPE Operation against a key holding the wrong kind of value")}
if _, err := Search(context.Background(), f, 11, 76, 10, 10); err == nil || !strings.Contains(err.Error(), "WRONGTYPE") {
t.Fatalf("want the WRONGTYPE error, got %v", err)
}
if f.radiusHits != 0 || legacyOnly.Load() {
t.Fatal("a non-'unknown command' error must not switch to the fallback")
}
if p := Probe(context.Background(), f); !strings.HasPrefix(p, "error: ") {
t.Fatalf("probe = %q", p)
}
}
func TestIndexReportsWriteFailure(t *testing.T) {
if err := Index(context.Background(), &fakeRedis{}, 38, 11.0, 76.9); err != nil {
t.Fatalf("ok write: %v", err)
}
err := Index(context.Background(), &fakeRedis{addErr: errors.New("WRONGTYPE Operation")}, 38, 11.0, 76.9)
if err == nil || !strings.Contains(err.Error(), "WRONGTYPE") {
t.Fatalf("want the write error, got %v", err)
}
}
// db.Rdb is a *redis.Client; before InitRedis it is a nil pointer, which in
// an interface is not == nil. It must be refused, not dereferenced.
func TestNilClientIsRefused(t *testing.T) {
var rdb *redis.Client
if _, err := Search(context.Background(), rdb, 11, 76, 10, 10); err == nil {
t.Fatal("nil client must be an error")
}
if err := Index(context.Background(), rdb, 1, 11, 76); err == nil {
t.Fatal("nil client must be an error")
}
if p := Probe(context.Background(), rdb); !strings.HasPrefix(p, "error") {
t.Fatalf("probe = %q", p)
}
}

48
internal/testpg/testpg.go Normal file
View File

@@ -0,0 +1,48 @@
// Package testpg opens throwaway Postgres schemas for integration tests.
// Imported only from _test.go files, so it never reaches the server binary.
package testpg
import (
"regexp"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var schemaName = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
// Open connects to a THROWAWAY Postgres (the DSN from REGISTRY_TEST_DSN)
// inside a schema of its own, created if missing.
//
// Each test package passes a different schema: `go test ./...` runs packages
// in parallel, and two packages dropping and recreating the same tables in the
// same schema at once fail each other at random.
func Open(t testing.TB, dsn, schema string) *gorm.DB {
t.Helper()
if !schemaName.MatchString(schema) {
t.Fatalf("bad test schema name %q", schema)
}
cfg := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
base, err := gorm.Open(postgres.Open(dsn), cfg)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := base.Exec("CREATE SCHEMA IF NOT EXISTS " + schema).Error; err != nil {
t.Fatalf("create schema %s: %v", schema, err)
}
if sqlDB, err := base.DB(); err == nil {
_ = sqlDB.Close()
}
db, err := gorm.Open(postgres.Open(dsn+" search_path="+schema), cfg)
if err != nil {
t.Fatalf("connect to schema %s: %v", schema, err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}

40
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"errors"
"net/url"
"os"
@@ -12,7 +13,10 @@ import (
"doormile/config"
"doormile/controllers"
"doormile/db"
"doormile/internal/ai/playground"
"doormile/internal/ai/telemetry"
"doormile/internal/assignment"
"doormile/internal/milergeo"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/internal/sms"
@@ -85,11 +89,30 @@ func main() {
_ = godotenv.Load()
cfg := config.Load()
// Refuse to boot production on a committed fallback secret. Checked before
// anything connects, so a misconfigured deploy fails loudly at start rather
// than serving traffic with a JWT secret that is in the git history.
if missing := cfg.MissingProductionSecrets(); len(missing) > 0 {
utils.Error("Refusing to start: required secrets are not set in production", "missing", strings.Join(missing, ","))
os.Exit(1)
}
utils.Info("Starting Doormile Backend...")
// 2. Connect to Postgres, Redis & NATS
db.Connect(cfg)
db.InitRedis(cfg)
// Auto-assignment finds riders with a Redis GEO search. When that search
// fails every order silently finds "no riders", so say so at boot.
if db.Rdb != nil {
geoCtx, geoCancel := context.WithTimeout(context.Background(), 3*time.Second)
if geo := milergeo.Probe(geoCtx, db.Rdb); strings.HasPrefix(geo, "error") {
utils.Error("❌ Rider location search is broken: auto-assignment will find no riders", "redis_geo", geo)
} else {
utils.Info("✅ Rider location search ready", "redis_geo", geo)
}
geoCancel()
}
db.InitNATS(cfg)
notify.InitFCM()
@@ -216,10 +239,27 @@ func main() {
// 8. Start the assignment worker. Every replica runs one; they share a
// durable consumer, so JetStream hands each booking to exactly one of them.
go assignment.StartAssignmentWorker()
// Retries every unassigned pending booking on a timer, so a booking whose
// retry window closed while no rider was free is still picked up later.
go assignment.StartPendingSweeper()
// 9. Point the stop sequencer at the Route Optimization API.
routing.BaseURL = cfg.RouteOptimizerURL
// 10. Record AI_engine agent runs (telemetry.task) and live state
// (telemetry.agent). Observability only: a nil NATS connection logs and
// skips, and nothing here can block a request.
if db.DB != nil {
telemetry.NewRecorder(db.DB, db.Rdb).Start(db.Nc)
}
// Agent Studio Test playground: switched on only when a model API key is
// configured. Without one the endpoint answers 503 and the console says so.
if cfg.PlaygroundLLMAPIKey != "" {
controllers.PlaygroundModel = playground.NewOpenAICompat(cfg.PlaygroundLLMBaseURL, cfg.PlaygroundLLMAPIKey, cfg.PlaygroundLLMModel)
utils.Info("ai playground: enabled", "base_url", cfg.PlaygroundLLMBaseURL, "model", cfg.PlaygroundLLMModel)
}
// 7. Startup server in a background thread
go func() {
utils.Info("Server starting", "port", cfg.Port)

View File

@@ -0,0 +1,39 @@
package middlewares
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// ClientOnboardingOwnerOnly admits only the console logins named in
// CLIENT_ONBOARDING_OWNERS (default admin@doormile.com): the token's email must
// be on that list, AND it must be Doormile staff (tenant 0) with roleid 1.
//
// Onboarding mints a client's console credentials, so it is narrower than
// "any admin". Everything else refuses with the same 403 — a partner login,
// another Doormile admin, a manager — and the refusal names no allowed email.
//
// Must run after AuthMiddleware. Missing locals fail closed. The handler also
// re-reads the owner's doormile_auth row, so a token that outlives a removed
// or demoted account stops working at once.
func ClientOnboardingOwnerOnly(owners []string) fiber.Handler {
allowed := make(map[string]bool, len(owners))
for _, e := range owners {
if e = strings.ToLower(strings.TrimSpace(e)); e != "" {
allowed[e] = true
}
}
return func(c *fiber.Ctx) error {
email, _ := c.Locals("email").(string)
tenantID, tenantOK := c.Locals("tenantid").(int)
roleID, roleOK := c.Locals("roleid").(int)
if !tenantOK || !roleOK || tenantID != 0 || roleID != 1 || !allowed[strings.ToLower(strings.TrimSpace(email))] {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"success": false,
"message": "client onboarding is restricted to the designated onboarding account",
})
}
return c.Next()
}
}

21
middlewares/staff_only.go Normal file
View File

@@ -0,0 +1,21 @@
package middlewares
import "github.com/gofiber/fiber/v2"
// DoormileStaffOnly admits only Doormile's own console staff: a login whose
// token carries tenant 0. A partner-tenant login is refused with 403, not
// handed an empty result — an empty list would read as "nothing configured"
// and hide that the caller is in the wrong place.
//
// Must run after AuthMiddleware, which sets the tenantid local. A missing
// local is treated as not staff: fail closed.
func DoormileStaffOnly(c *fiber.Ctx) error {
tenantID, ok := c.Locals("tenantid").(int)
if !ok || tenantID != 0 {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"success": false,
"message": "available to Doormile staff only",
})
}
return c.Next()
}

View File

@@ -1,6 +1,7 @@
package migrations
import (
"doormile/internal/ai/registry"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
@@ -60,6 +61,18 @@ func Migrate(db *gorm.DB) error {
&models.BookingStageEvent{},
&models.CustomerRefreshToken{},
&models.CustomerDevice{},
// AI agent registry (agent-platform-plan Phase 1). Five new tables,
// nothing existing touched.
&models.AIAgent{},
&models.AITool{},
&models.AISkill{},
&models.AISkillTool{},
&models.AIRegistryAudit{},
// Agent runs recorded from AI_engine telemetry (plan Phase 4). One new
// append-only table, pruned after 30 days.
&models.AIAgentRun{},
)
if err != nil {
@@ -69,6 +82,13 @@ func Migrate(db *gorm.DB) error {
utils.Info("✅ Database migration completed successfully!")
// Upsert the code-defined agent registry. A failure is logged, not fatal:
// the registry is read by Agent Studio and AI_engine, and neither is on a
// booking's path, so it must not stop the API from serving orders.
if err := registry.Seed(db); err != nil {
utils.Error("⚠️ AI registry seed failed", "error", err.Error())
}
if res := db.Exec(`ALTER TABLE agent_decisions ADD COLUMN IF NOT EXISTS context_embedding vector(1536)`); res.Error != nil {
utils.Error("❌ Failed to add context_embedding column", "error", res.Error)
} else {

123
models/ai_registry.go Normal file
View File

@@ -0,0 +1,123 @@
package models
import "time"
// The AI agent registry: the one source of truth for which agents exist, which
// skills they run and which tools those skills may call. The console's Agent
// Studio reads it (and edits the few operator-owned columns); the AI_engine
// will read it too. Plan: krow_talent_app/docs/agent-platform-plan.md.
//
// Agents, tools and seeded skills are DEFINED IN CODE (internal/ai/registry)
// and upserted on every boot. Only the columns marked "operator-owned" below
// change at runtime, only by roleid 1, and every change writes an
// AIRegistryAudit row in the same transaction. A tool can never be invented
// from the console: a tool with no implementation behind it is a lie on screen.
//
// JSON documents are stored as jsonb in string fields, the same way
// AgentDecision stores its context — the controllers re-emit them as raw JSON.
// AIAgent is one agent, in AI_engine or in the console.
type AIAgent struct {
Agentid string `json:"agentid" gorm:"primaryKey;column:agentid;size:64"`
Name string `json:"name" gorm:"column:name;not null"`
Runtime string `json:"runtime" gorm:"column:runtime;size:20;not null"` // engine, console
Classref string `json:"classref" gorm:"column:classref"` // where it is implemented, file:line
Purpose string `json:"purpose" gorm:"column:purpose"`
// Wakeon is what makes the agent run: a NATS subject, a direct task, an
// operator prompt. Free text for people, not parsed.
Wakeon string `json:"wakeon" gorm:"column:wakeon"`
// Status is what the code does today, not what the docs claim:
// live, partial, simulation, broken, unmerged, retired.
Status string `json:"status" gorm:"column:status;size:20;not null"`
// Llmdecision names the model call the agent makes, empty when it makes none.
Llmdecision string `json:"llmdecision" gorm:"column:llmdecision"`
// Hasautonomygate is true for the agents whose writes are behind an
// autonomy switch in AI_engine (Dispatch, Exception, Express). Autonomy can
// only be set on these.
Hasautonomygate bool `json:"hasautonomygate" gorm:"column:hasautonomygate;default:false"`
Sortorder int `json:"sortorder" gorm:"column:sortorder;default:0"`
// Operator-owned.
Autonomous bool `json:"autonomous" gorm:"column:autonomous;default:false"`
Model string `json:"model" gorm:"column:model;size:64"` // empty = the engine's own default
Updatedby *int `json:"updatedby" gorm:"column:updatedby"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (AIAgent) TableName() string { return "aiagents" }
// AITool is one capability a skill may call. Entirely code-defined.
type AITool struct {
Toolname string `json:"toolname" gorm:"primaryKey;column:toolname;size:64"`
Description string `json:"description" gorm:"column:description;not null"`
// Kind is read, write or notify. A write or notify tool always requires
// confirmation unless its agent is autonomous.
Kind string `json:"kind" gorm:"column:kind;size:10;not null"`
Target string `json:"target" gorm:"column:target"` // the system it touches
Implementedat string `json:"implementedat" gorm:"column:implementedat"` // file:line today
Inputschema string `json:"-" gorm:"column:inputschema;type:jsonb"`
Requiresconfirmation bool `json:"requiresconfirmation" gorm:"column:requiresconfirmation;default:false"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (AITool) TableName() string { return "aitools" }
// AISkill is a named behaviour of one agent, using one or more tools.
type AISkill struct {
Skillid string `json:"skillid" gorm:"primaryKey;column:skillid;size:64"`
Agentid string `json:"agentid" gorm:"column:agentid;size:64;not null;index"`
Title string `json:"title" gorm:"column:title;not null"`
Category string `json:"category" gorm:"column:category;size:40"`
Description string `json:"description" gorm:"column:description"`
Sampleprompt string `json:"sampleprompt" gorm:"column:sampleprompt"`
// Source is engine, console (the ops-layer skills), or custom (created in
// Agent Studio). Custom skills are never touched by the seed.
Source string `json:"source" gorm:"column:source;size:20;not null"`
Thresholdsschema string `json:"-" gorm:"column:thresholdsschema;type:jsonb"`
// Operator-owned.
// No gorm default on purpose: with `default:true` gorm omits a false value
// from the INSERT and the database default wins, so a skill seeded OFF came
// up ON (caught by TestPGSkillSeededOffStaysOff). Every insert sets it.
Enabled bool `json:"enabled" gorm:"column:enabled;not null"`
Thresholds string `json:"-" gorm:"column:thresholds;type:jsonb"`
// Version increases on every operator change, so a consumer holding a copy
// can tell it is stale.
Version int `json:"version" gorm:"column:version;default:1"`
Updatedby *int `json:"updatedby" gorm:"column:updatedby"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (AISkill) TableName() string { return "aiskills" }
// AISkillTool links a skill to a tool it may call.
type AISkillTool struct {
Skillid string `json:"skillid" gorm:"primaryKey;column:skillid;size:64"`
Toolname string `json:"toolname" gorm:"primaryKey;column:toolname;size:64"`
}
func (AISkillTool) TableName() string { return "aiskilltools" }
// AIRegistryAudit is one operator change to the registry. Written in the same
// transaction as the change, so a change without its audit row cannot exist.
type AIRegistryAudit struct {
Auditid int64 `json:"auditid" gorm:"primaryKey;column:auditid;autoIncrement"`
Entity string `json:"entity" gorm:"column:entity;size:20;not null;index:idx_airegistryaudit_entity"` // agent, skill
Entityid string `json:"entityid" gorm:"column:entityid;size:64;not null;index:idx_airegistryaudit_entity"`
Field string `json:"field" gorm:"column:field;size:40;not null"`
Oldvalue string `json:"-" gorm:"column:oldvalue;type:jsonb"`
Newvalue string `json:"-" gorm:"column:newvalue;type:jsonb"`
Changedby int `json:"changedby" gorm:"column:changedby;not null"`
// Changedbyemail is the login's email from its token. Recorded alongside
// the id because an admin login with no appusers row carries user id 0 —
// found in the Phase 2 end-to-end run, where every audit row said "0".
Changedbyemail string `json:"changedbyemail" gorm:"column:changedbyemail;size:255"`
Changedat time.Time `json:"changedat" gorm:"column:changedat;default:CURRENT_TIMESTAMP;index"`
}
func (AIRegistryAudit) TableName() string { return "airegistryaudit" }

35
models/ai_runs.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import "time"
// AIAgentRun is one task an AI_engine agent finished, recorded from its
// `telemetry.task` event (internal/ai/telemetry). Phase 4 of
// krow_talent_app/docs/agent-platform-plan.md: before this, a run existed only
// as a log line and an ephemeral NATS message, so nothing could say how often
// an agent ran or how often it failed.
//
// Append-only, and pruned after 30 days. Live agent STATE (the 5-second
// `telemetry.agent` heartbeat) is deliberately not stored here — it is
// ephemeral and lives in Redis.
type AIAgentRun struct {
Runid int64 `json:"runid" gorm:"primaryKey;column:runid;autoIncrement"`
Agentid string `json:"agentid" gorm:"column:agentid;size:64;not null;index:idx_aiagentruns_agent_received,priority:1;uniqueIndex:uq_aiagentruns_agent_task,priority:1"`
// Taskid is the engine's task id. Null when the event carried none; the
// unique index then does not apply (Postgres treats NULLs as distinct), so
// a redelivered event with an id is stored once and one without is not lost.
Taskid *string `json:"taskid" gorm:"column:taskid;size:64;uniqueIndex:uq_aiagentruns_agent_task,priority:2"`
Tasktype string `json:"tasktype" gorm:"column:tasktype;size:64"`
Status string `json:"status" gorm:"column:status;size:20;not null"` // completed, failed
Error string `json:"error" gorm:"column:error;type:text"`
// Durationms is how long the task ran, as the engine measured it.
Durationms int `json:"durationms" gorm:"column:durationms"`
// Occurredat is the engine's own timestamp, kept for display only: it is a
// naive local time from whatever clock and zone the engine host runs.
// Windows and ordering use Receivedat, which is this backend's clock as a
// true instant (time.Now) — the column is timestamptz, so utils.DBNow's
// IST-digits-labelled-UTC value would be 5h30m off here.
Occurredat *time.Time `json:"occurredat" gorm:"column:occurredat"`
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat;not null;index:idx_aiagentruns_agent_received,priority:2;index:idx_aiagentruns_received"`
}
func (AIAgentRun) TableName() string { return "aiagentruns" }

View File

@@ -35,6 +35,22 @@ type Hub struct {
Createdby int `json:"createdby" gorm:"column:createdby"`
Updatedby int `json:"updatedby" gorm:"column:updatedby"`
Deletedat *time.Time `json:"deletedat,omitempty" gorm:"column:deletedat"`
// City is the applocations row this hub sits in, resolved on read and never
// stored (`gorm:"-"` keeps it out of both the SELECT and the INSERT).
//
// It exists because the console asks a hub what city it is in, and until now
// nothing answered: the hub response carried `applocationid` and no name, so
// `hub.city` was undefined on every screen that reached for it. That is not a
// cosmetic gap:
//
// - ZoneContext.matchesZone falls back to comparing an order's address text
// against the hub's city. An empty city makes that branch unreachable,
// leaving a 35km radius as the only thing still matching.
// - fetchAppLocations names each city `hub.city || hub.hubname`, so the
// Pricing and report pickers offered "Coimbatore Jupiter Hub" where they
// meant "Coimbatore".
City string `json:"city" gorm:"-"`
}
func (Hub) TableName() string {

View File

@@ -7,6 +7,7 @@ import (
"doormile/config"
"doormile/controllers"
"doormile/db"
"doormile/internal/milergeo"
"doormile/internal/sms"
"doormile/internal/ws"
"doormile/middlewares"
@@ -69,11 +70,21 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
status = fiber.StatusServiceUnavailable
}
// Whether rider search works on this Redis. Reported, not gating:
// a broken search stops auto-assignment, not the API.
redisGeo := "error: redis not available"
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
redisGeo = milergeo.Probe(ctx, db.Rdb)
}
return c.Status(status).JSON(fiber.Map{
"status": "ready",
"checks": fiber.Map{
"postgres": dbStatus,
"redis": redisStatus,
"postgres": dbStatus,
"redis": redisStatus,
"redis_geo": redisGeo,
// Reported, but deliberately NOT gating readiness: the miler and
// console surfaces work perfectly without SMS. It is here because
// 'the OTP never arrived' was answerable only by reading code, and
@@ -268,6 +279,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
// Rider hands a returned (RTO) parcel back to the sender. Refused unless
// MILER_RTO_FLOW_ENABLED=true.
milerAuth.Post("/consignments/:id/return-complete", middlewares.Idempotency(), controllers.MilerCompleteReturn)
// Rider handover at a base. The authoritative record that a hub-routed parcel
// physically changed hands; answers with the resulting state rather than a
// bare 200, and carries the shared idempotency middleware because riders retry
@@ -307,10 +321,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Put("/profile/password", controllers.AdminChangePassword)
// App Users Management
adminAuth.Get("/users", controllers.GetAppUsers)
adminAuth.Post("/users", controllers.CreateAppUser)
adminAuth.Put("/users/:id", controllers.UpdateAppUser)
adminAuth.Delete("/users/:id", controllers.DeleteAppUser)
adminAuth.Get("/users", middlewares.DoormileStaffOnly, controllers.GetAppUsers)
adminAuth.Post("/users", middlewares.DoormileStaffOnly, controllers.CreateAppUser)
adminAuth.Put("/users/:id", middlewares.DoormileStaffOnly, controllers.UpdateAppUser)
adminAuth.Delete("/users/:id", middlewares.DoormileStaffOnly, controllers.DeleteAppUser)
// Partner management (fleet/rider suppliers — distinct from tenants,
// which are the client companies Doormile delivers for)
@@ -322,10 +336,22 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Tenant management
adminAuth.Get("/tenants", controllers.GetTenants)
adminAuth.Post("/tenants", controllers.CreateTenant)
adminAuth.Post("/tenants", middlewares.DoormileStaffOnly, controllers.CreateTenant)
adminAuth.Get("/tenants/:id", controllers.GetTenantDetails)
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
// Client onboarding: a tenant + its console login in one transaction. Only
// the CLIENT_ONBOARDING_OWNERS logins (default admin@doormile.com), and
// only as Doormile staff with roleid 1 — onboarding mints credentials.
onboarding := adminAuth.Group("/clients", middlewares.ClientOnboardingOwnerOnly(cfg.ClientOnboardingOwners))
onboarding.Post("/onboard", controllers.OnboardClient)
onboarding.Get("/onboarded", controllers.GetOnboardedClients)
onboarding.Get("/cities", controllers.GetOnboardingCities)
// Edit and remove a client's console login (:id = doormile_auth id). Remove
// deletes the login only and marks the client Inactive; history is kept.
onboarding.Put("/:id", controllers.UpdateOnboardedClient)
onboarding.Delete("/:id", controllers.DeleteOnboardedClient)
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
@@ -347,17 +373,17 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Hubs
adminAuth.Get("/hubs", controllers.GetHubs)
adminAuth.Post("/hubs", controllers.CreateHub)
adminAuth.Post("/hubs", middlewares.DoormileStaffOnly, controllers.CreateHub)
adminAuth.Get("/hubs/:id", controllers.GetHubDetails)
adminAuth.Put("/hubs/:id", controllers.UpdateHub)
adminAuth.Delete("/hubs/:id", controllers.DeleteHub)
adminAuth.Put("/hubs/:id", middlewares.DoormileStaffOnly, controllers.UpdateHub)
adminAuth.Delete("/hubs/:id", middlewares.DoormileStaffOnly, controllers.DeleteHub)
// Vehicles
adminAuth.Get("/vehicles", controllers.GetVehicles)
adminAuth.Post("/vehicles", controllers.CreateVehicle)
adminAuth.Post("/vehicles", middlewares.DoormileStaffOnly, controllers.CreateVehicle)
adminAuth.Get("/vehicles/:id", controllers.GetVehicleDetails)
adminAuth.Put("/vehicles/:id", controllers.UpdateVehicle)
adminAuth.Delete("/vehicles/:id", controllers.DeleteVehicle)
adminAuth.Put("/vehicles/:id", middlewares.DoormileStaffOnly, controllers.UpdateVehicle)
adminAuth.Delete("/vehicles/:id", middlewares.DoormileStaffOnly, controllers.DeleteVehicle)
// Milers
adminAuth.Get("/milers", controllers.GetMilers)
@@ -392,38 +418,48 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Consignments
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
adminAuth.Get("/consignments/:id/history", controllers.GetConsignmentHistory)
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
// Reverse logistics (RTO). Starting, cancelling and closing a return are
// Doormile-staff actions; a client login can read its own returns.
adminAuth.Post("/consignments/:id/rto", middlewares.DoormileStaffOnly, controllers.InitiateConsignmentRTO)
adminAuth.Post("/consignments/:id/rto/cancel", middlewares.DoormileStaffOnly, controllers.CancelConsignmentRTO)
adminAuth.Post("/consignments/:id/rto/complete", middlewares.DoormileStaffOnly, controllers.CompleteConsignmentRTO)
adminAuth.Get("/returns", controllers.GetReturns)
adminAuth.Get("/returns/summary", controllers.GetReturnsSummary)
// Tripsheets
adminAuth.Get("/tripsheets", controllers.GetTripsheets)
adminAuth.Post("/tripsheets", controllers.CreateTripsheet)
adminAuth.Get("/tripsheets/:id", controllers.GetTripsheetDetails)
adminAuth.Post("/tripsheets/:id/items", controllers.AddTripsheetItem)
adminAuth.Delete("/tripsheets/:id/items/:itemid", controllers.DeleteTripsheetItem)
adminAuth.Put("/tripsheets/:id/dispatch", controllers.DispatchTripsheet)
adminAuth.Put("/tripsheets/:id/arrive", controllers.ArriveTripsheet)
adminAuth.Get("/tripsheets", middlewares.DoormileStaffOnly, controllers.GetTripsheets)
adminAuth.Post("/tripsheets", middlewares.DoormileStaffOnly, controllers.CreateTripsheet)
adminAuth.Get("/tripsheets/:id", middlewares.DoormileStaffOnly, controllers.GetTripsheetDetails)
adminAuth.Post("/tripsheets/:id/items", middlewares.DoormileStaffOnly, controllers.AddTripsheetItem)
adminAuth.Delete("/tripsheets/:id/items/:itemid", middlewares.DoormileStaffOnly, controllers.DeleteTripsheetItem)
adminAuth.Put("/tripsheets/:id/dispatch", middlewares.DoormileStaffOnly, controllers.DispatchTripsheet)
adminAuth.Put("/tripsheets/:id/arrive", middlewares.DoormileStaffOnly, controllers.ArriveTripsheet)
// Competitor branch survey data (from Enquiry.xlsx Sheet 1)
adminAuth.Get("/competitor-branches", controllers.GetCompetitorBranches)
adminAuth.Post("/competitor-branches", controllers.CreateCompetitorBranch)
adminAuth.Put("/competitor-branches/:id", controllers.UpdateCompetitorBranch)
adminAuth.Delete("/competitor-branches/:id", controllers.DeleteCompetitorBranch)
adminAuth.Get("/competitor-branches", middlewares.DoormileStaffOnly, controllers.GetCompetitorBranches)
adminAuth.Post("/competitor-branches", middlewares.DoormileStaffOnly, controllers.CreateCompetitorBranch)
adminAuth.Put("/competitor-branches/:id", middlewares.DoormileStaffOnly, controllers.UpdateCompetitorBranch)
adminAuth.Delete("/competitor-branches/:id", middlewares.DoormileStaffOnly, controllers.DeleteCompetitorBranch)
// Carrier pricing benchmarks (from Enquiry.xlsx Sheet 2)
adminAuth.Get("/carrier-pricing", controllers.GetCarrierPricing)
adminAuth.Post("/carrier-pricing", controllers.CreateCarrierPricing)
adminAuth.Put("/carrier-pricing/:id", controllers.UpdateCarrierPricing)
adminAuth.Delete("/carrier-pricing/:id", controllers.DeleteCarrierPricing)
adminAuth.Get("/carrier-pricing", middlewares.DoormileStaffOnly, controllers.GetCarrierPricing)
adminAuth.Post("/carrier-pricing", middlewares.DoormileStaffOnly, controllers.CreateCarrierPricing)
adminAuth.Put("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.UpdateCarrierPricing)
adminAuth.Delete("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.DeleteCarrierPricing)
// Pricing
// A client login reads its own rate card (GetPricing is tenant-scoped); every
// change, and the quote (which picks a rule across ALL tenants), is staff-only.
adminAuth.Get("/pricing", controllers.GetPricing)
adminAuth.Post("/pricing", controllers.CreatePricing)
adminAuth.Put("/pricing/:id", controllers.UpdatePricing)
adminAuth.Delete("/pricing/:id", controllers.DeletePricing)
adminAuth.Post("/pricing/simulate", controllers.GetPricingQuoteSimulate)
adminAuth.Post("/pricing/quote", controllers.GetPricingQuoteSimulate)
adminAuth.Post("/pricing", middlewares.DoormileStaffOnly, controllers.CreatePricing)
adminAuth.Put("/pricing/:id", middlewares.DoormileStaffOnly, controllers.UpdatePricing)
adminAuth.Delete("/pricing/:id", middlewares.DoormileStaffOnly, controllers.DeletePricing)
adminAuth.Post("/pricing/simulate", middlewares.DoormileStaffOnly, controllers.GetPricingQuoteSimulate)
adminAuth.Post("/pricing/quote", middlewares.DoormileStaffOnly, controllers.GetPricingQuoteSimulate)
// Doormile pricing bands
adminAuth.Get("/doormile-pricing", controllers.GetDoormilePricing)
@@ -433,9 +469,33 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Exceptions
adminAuth.Get("/exceptions", controllers.GetExceptions)
adminAuth.Post("/exceptions", controllers.CreateException)
adminAuth.Post("/exceptions", middlewares.DoormileStaffOnly, controllers.CreateException)
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
adminAuth.Put("/exceptions/:id/status", middlewares.DoormileStaffOnly, controllers.ResolveException)
// AI agent registry (krow_talent_app/docs/agent-platform-plan.md, Phase 1).
// Doormile staff only — a partner-tenant login gets 403. Reads are open to
// roles 1/3/4; every write is roleid 1 only and is audited.
aiRegistry := adminAuth.Group("/ai", middlewares.DoormileStaffOnly)
aiRegistry.Get("/agents", controllers.GetAIAgents)
aiRegistry.Get("/agents/:id", controllers.GetAIAgent)
aiRegistry.Patch("/agents/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAIAgent)
aiRegistry.Get("/skills", controllers.GetAISkills)
aiRegistry.Post("/skills", middlewares.RoleCheckMiddleware(1), controllers.CreateAISkill)
aiRegistry.Patch("/skills/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAISkill)
aiRegistry.Get("/tools", controllers.GetAITools)
aiRegistry.Get("/audit", controllers.GetAIRegistryAudit)
// What the agents did (Phase 4): runs from AI_engine telemetry, decisions
// from agent_decisions, live heartbeat from Redis. Read-only.
aiRegistry.Get("/insights", controllers.GetAIInsights)
aiRegistry.Get("/decisions", controllers.GetAIDecisions)
// What is actually wired (engine reading the registry, Test tab model), for
// the Skills & Tools banner.
aiRegistry.Get("/status", controllers.GetAIStatus)
// Test tab (Phase 6): one prompt through Claude with a skill's tools. Reads
// run redacted; writes only become proposals. Admin only — every run is a
// paid API call — and rate-limited per user in the handler.
aiRegistry.Post("/playground/run", middlewares.RoleCheckMiddleware(1), controllers.RunAIPlayground)
// --------------------
// HUB CONSOLE APIS
@@ -532,6 +592,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome)
// The agent registry, for AI_engine to poll (ETag / If-None-Match → 304).
internal.Get("/ai/registry", controllers.GetInternalAIRegistry)
// Express-batch dispatch: the ExpressDispatchAgent reads a tenant's riders
// and the batch's bookings, then writes back the assignments it decided.

View File

@@ -0,0 +1,151 @@
package routes_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"doormile/db"
"doormile/internal/ai/registry"
"doormile/internal/testpg"
"doormile/models"
)
// End to end through the real router and real handlers, against a real
// Postgres. Skipped unless REGISTRY_TEST_DSN is set; the DSN must be a
// THROWAWAY database — the five registry tables are dropped and recreated.
// See internal/ai/registry/store_integration_test.go for how to start one.
func registryApp(t *testing.T) func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres end-to-end test")
}
gdb := testpg.Open(t, dsn, "airegistry_routes_test")
all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
if err := registry.Seed(gdb); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
app := newApp()
return func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) {
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, resp.Header, out
}
}
func TestPGRegistryEndToEnd(t *testing.T) {
call := registryApp(t)
admin := token(t, 1, 1)
// Read the inventory.
code, _, body := call(http.MethodGet, "/api/v1/admin/ai/agents", admin, "")
if code != http.StatusOK {
t.Fatalf("GET agents = %d %v", code, body)
}
if total, _ := body["total"].(float64); int(total) != len(registry.SeedAgents) {
t.Errorf("GET agents total = %v, want %d (body %v)", body["total"], len(registry.SeedAgents), body)
}
code, _, body = call(http.MethodGet, "/api/v1/admin/ai/skills?agent=CONSOLE_OPS_AGENT", token(t, 1, 3), "")
if code != http.StatusOK {
t.Fatalf("manager GET skills = %d %v", code, body)
}
// Patch a skill as admin: 200, version 2, thresholds as a JSON object.
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"enabled":false,"thresholds":{"arrivedStalledMin":30}}`)
if code != http.StatusOK {
t.Fatalf("PATCH skill = %d %v", code, body)
}
data, _ := body["data"].(map[string]any)
th, _ := data["thresholds"].(map[string]any)
if data["enabled"] != false || data["version"] != float64(2) || th["arrivedStalledMin"] != float64(30) {
t.Errorf("PATCH response = %v", data)
}
// A bad value is a 400 naming the problem, and changes nothing.
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"thresholds":{"arrivedStalledMin":999}}`)
if code != http.StatusBadRequest {
t.Errorf("out-of-range PATCH = %d %v, want 400", code, body)
}
code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/skills/nope", admin, `{"enabled":true}`)
if code != http.StatusNotFound {
t.Errorf("PATCH unknown skill = %d, want 404", code)
}
// Autonomy: refused without the typed confirmation, accepted with it.
code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":true}`)
if code != http.StatusBadRequest {
t.Errorf("autonomy without confirm = %d, want 400", code)
}
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":false,"model":"claude-haiku-4-5-20251001"}`)
if code != http.StatusOK {
t.Errorf("model PATCH = %d %v", code, body)
}
// Create a custom skill.
code, _, body = call(http.MethodPost, "/api/v1/admin/ai/skills", admin, `{"agentid":"CONSOLE_OPS_AGENT","title":"Night shift watch","tools":["scan_bookings"]}`)
if code != http.StatusCreated {
t.Fatalf("POST skill = %d %v", code, body)
}
// The audit shows all three changes.
code, _, body = call(http.MethodGet, "/api/v1/admin/ai/audit", admin, "")
if total, _ := body["total"].(float64); code != http.StatusOK || int(total) != 4 {
t.Errorf("audit = %d, total %v, want 4 rows (enabled, thresholds, model, created)", code, body["total"])
}
// Every row names who made the change, even when the login has no appusers row.
for _, row := range body["data"].([]any) {
if email := row.(map[string]any)["changedbyemail"]; email != "test@doormile.com" {
t.Errorf("audit row changedbyemail = %v, want the token's email", email)
}
}
// The engine's endpoint: 200 with an ETag, then 304 for the same tag.
t.Setenv("INTERNAL_API_KEY", "engine-key")
code, hdr, body := call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key")
tag := hdr.Get("ETag")
if code != http.StatusOK || tag == "" {
t.Fatalf("internal registry = %d, etag %q", code, tag)
}
if snap, _ := body["data"].(map[string]any); len(snap["agents"].([]any)) != len(registry.SeedAgents) {
t.Errorf("internal registry agents = %v", len(snap["agents"].([]any)))
}
code, _, _ = call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key", "If-None-Match", tag)
if code != http.StatusNotModified {
t.Errorf("If-None-Match with the current tag = %d, want 304", code)
}
}

View File

@@ -0,0 +1,191 @@
package routes_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"doormile/controllers"
"doormile/internal/ai/playground"
"doormile/utils"
)
// The AI agent registry's gates, over real HTTP (see routes_logistics_test.go
// for what this style of test does and does not prove). Every refusal below
// happens in middleware, before a handler could touch the database.
func tenantToken(t *testing.T, userID, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(userID, "client@partner.example", roleID, tenantID, 1001, jwtSecret)
if err != nil {
t.Fatalf("could not mint a tenant token: %v", err)
}
return tok
}
var aiReads = []string{
"/api/v1/admin/ai/agents",
"/api/v1/admin/ai/agents/EXCEPTION_AGENT",
"/api/v1/admin/ai/skills",
"/api/v1/admin/ai/tools",
"/api/v1/admin/ai/audit",
"/api/v1/admin/ai/insights",
"/api/v1/admin/ai/insights?days=30",
"/api/v1/admin/ai/decisions",
"/api/v1/admin/ai/status",
}
var aiWrites = []struct{ method, path, body string }{
{http.MethodPatch, "/api/v1/admin/ai/skills/skill_sla_guardian", `{"enabled":false}`},
{http.MethodPost, "/api/v1/admin/ai/skills", `{"agentid":"CONSOLE_OPS_AGENT","title":"x","tools":["scan_bookings"]}`},
{http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", `{"autonomous":true,"confirm":"EXCEPTION_AGENT"}`},
{http.MethodPost, "/api/v1/admin/ai/playground/run", `{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`},
}
func TestAIRegistryRequiresALogin(t *testing.T) {
app := newApp()
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, "", ""); code != http.StatusUnauthorized {
t.Errorf("GET %s with no token = %d, want 401", p, code)
}
}
}
func TestAIRegistryRefusesNonConsoleRoles(t *testing.T) {
app := newApp()
for _, role := range []int{5, 6, 9} { // miler, hub staff, customer
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, token(t, 1, role), ""); code != http.StatusForbidden {
t.Errorf("role %d GET %s = %d, want 403", role, p, code)
}
}
}
}
// A partner-tenant login is refused outright — even an admin-role one — rather
// than shown an empty registry.
func TestAIRegistryRefusesPartnerTenantLogins(t *testing.T) {
app := newApp()
tok := tenantToken(t, 50, 1, 7)
for _, p := range aiReads {
code, body := do(t, app, http.MethodGet, p, tok, "")
if code != http.StatusForbidden {
t.Errorf("tenant GET %s = %d, want 403", p, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "Doormile staff only") {
t.Errorf("tenant GET %s refused with the wrong message: %s", p, body)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code != http.StatusForbidden {
t.Errorf("tenant %s %s = %d, want 403", w.method, w.path, code)
}
}
}
// Managers (3) and executives (4) may read the registry but not change it.
func TestAIRegistryWritesAreAdminOnly(t *testing.T) {
app := newApp()
for _, role := range []int{3, 4} {
for _, w := range aiWrites {
code, body := do(t, app, w.method, w.path, token(t, 1, role), w.body)
if code != http.StatusForbidden {
t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "insufficient permissions") {
t.Errorf("role %d %s %s refused by the wrong gate: %s", role, w.method, w.path, body)
}
}
}
}
// Doormile staff with roleid 1 get through every gate. There is no database
// in this test, so the handler's first query panics and recover answers 500 —
// which is the proof the route exists and nothing in front of it refused.
func TestAIRegistryAdminPassesEveryGate(t *testing.T) {
app := newApp()
tok := token(t, 1, 1)
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, tok, ""); code == 401 || code == 403 || code == 404 {
t.Errorf("admin GET %s = %d; a gate refused or the route is missing", p, code)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code == 401 || code == 403 || code == 404 {
t.Errorf("admin %s %s = %d; a gate refused or the route is missing", w.method, w.path, code)
}
}
// Read roles get through the read gates too.
for _, role := range []int{3, 4} {
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/ai/agents", token(t, 1, role), ""); code == 401 || code == 403 {
t.Errorf("role %d was refused a registry read (%d)", role, code)
}
}
}
func TestInternalRegistryNeedsTheInternalKey(t *testing.T) {
t.Setenv("INTERNAL_API_KEY", "engine-key-for-tests")
app := newApp()
for _, key := range []string{"", "wrong-key"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/internal/ai/registry", nil)
if key != "" {
req.Header.Set("X-Internal-Key", key)
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("internal registry with key %q = %d, want 401", key, resp.StatusCode)
}
}
// A console JWT is not an internal key.
if code, _ := do(t, app, http.MethodGet, "/api/v1/internal/ai/registry", token(t, 1, 1), ""); code != http.StatusUnauthorized {
t.Errorf("internal registry with an admin JWT = %d, want 401", code)
}
}
// The Test playground (Phase 6). With no model client wired the endpoint
// says so plainly (503 with a code the console branches on) — it never
// pretends to run. With one wired, bad input is refused before any database
// or model call.
func TestAIPlaygroundWithoutAClientIs503(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = nil
defer func() { controllers.PlaygroundModel = prev }()
code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1),
`{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`)
if code != http.StatusServiceUnavailable || !strings.Contains(body, "PLAYGROUND_NOT_CONFIGURED") {
t.Fatalf("no client: %d %s, want 503 PLAYGROUND_NOT_CONFIGURED", code, body)
}
}
type noCallModel struct{ t *testing.T }
func (m noCallModel) Next(context.Context, playground.Request) (playground.Reply, error) {
m.t.Fatal("the model was called for a request that should have been refused")
return playground.Reply{}, nil
}
func TestAIPlaygroundRefusesBadInput(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = noCallModel{t}
defer func() { controllers.PlaygroundModel = prev }()
for _, b := range []string{
`{"agentid":"EXCEPTION_AGENT","prompt":" "}`,
`{"prompt":"hi"}`,
`{"agentid":"EXCEPTION_AGENT","prompt":"` + strings.Repeat("x", 2001) + `"}`,
`not json`,
} {
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1), b); code != http.StatusBadRequest {
t.Errorf("body %.40q = %d %s, want 400", b, code, body)
}
}
}

View File

@@ -0,0 +1,146 @@
package routes_test
import (
"net/http"
"os"
"strconv"
"strings"
"testing"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
"doormile/utils"
)
// A client (tenant) login can open the console's Fleet Ops menu, so every
// create/edit/delete behind it, and every internal list, must refuse a client
// token on the server, not just be hidden in the UI.
var staffOnlyFleetRoutes = []struct{ method, path string }{
{http.MethodPost, "/api/v1/admin/tenants"},
{http.MethodPost, "/api/v1/admin/pricing"},
{http.MethodPut, "/api/v1/admin/pricing/1"},
{http.MethodDelete, "/api/v1/admin/pricing/1"},
{http.MethodPost, "/api/v1/admin/pricing/simulate"},
{http.MethodPost, "/api/v1/admin/pricing/quote"},
{http.MethodGet, "/api/v1/admin/users"},
{http.MethodPost, "/api/v1/admin/users"},
{http.MethodPut, "/api/v1/admin/users/1"},
{http.MethodDelete, "/api/v1/admin/users/1"},
{http.MethodPost, "/api/v1/admin/hubs"},
{http.MethodPut, "/api/v1/admin/hubs/1"},
{http.MethodDelete, "/api/v1/admin/hubs/1"},
{http.MethodPost, "/api/v1/admin/vehicles"},
{http.MethodPut, "/api/v1/admin/vehicles/1"},
{http.MethodDelete, "/api/v1/admin/vehicles/1"},
{http.MethodGet, "/api/v1/admin/tripsheets"},
{http.MethodPost, "/api/v1/admin/tripsheets"},
{http.MethodGet, "/api/v1/admin/tripsheets/1"},
{http.MethodPost, "/api/v1/admin/tripsheets/1/items"},
{http.MethodDelete, "/api/v1/admin/tripsheets/1/items/1"},
{http.MethodPut, "/api/v1/admin/tripsheets/1/dispatch"},
{http.MethodPut, "/api/v1/admin/tripsheets/1/arrive"},
{http.MethodGet, "/api/v1/admin/competitor-branches"},
{http.MethodPost, "/api/v1/admin/competitor-branches"},
{http.MethodPut, "/api/v1/admin/competitor-branches/1"},
{http.MethodDelete, "/api/v1/admin/competitor-branches/1"},
{http.MethodGet, "/api/v1/admin/carrier-pricing"},
{http.MethodPost, "/api/v1/admin/carrier-pricing"},
{http.MethodPut, "/api/v1/admin/carrier-pricing/1"},
{http.MethodDelete, "/api/v1/admin/carrier-pricing/1"},
{http.MethodPost, "/api/v1/admin/exceptions"},
{http.MethodPut, "/api/v1/admin/exceptions/1/status"},
}
func TestFleetOpsWritesRefuseAClientLogin(t *testing.T) {
app := onboardingApp()
client := consoleToken(t, "ops@client.test", 3, 42)
for _, r := range staffOnlyFleetRoutes {
code, body := do(t, app, r.method, r.path, client, `{}`)
if code != http.StatusForbidden || !strings.Contains(body, "Doormile staff only") {
t.Errorf("%s %s as a client = %d %s, want 403", r.method, r.path, code, body)
}
}
}
func TestFleetOpsGuardsLetStaffThrough(t *testing.T) {
app := onboardingApp()
staff := consoleToken(t, "ops@doormile.com", 1, 0)
for _, r := range staffOnlyFleetRoutes {
// Past the guard the handler runs (and, with no database, may fail);
// all that matters here is that the guard did not refuse.
func() {
defer func() { _ = recover() }()
if code, body := do(t, app, r.method, r.path, staff, `{}`); strings.Contains(body, "Doormile staff only") {
t.Errorf("%s %s as staff = %d %s, the guard refused", r.method, r.path, code, body)
}
}()
}
}
// Hubs are scoped to the client's own city, from the server. Postgres-gated
// like the other route tests; the DSN must be a THROWAWAY database.
func TestClientSeesOnlyItsOwnCityHubs(t *testing.T) {
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres hub scoping test")
}
gdb := testpg.Open(t, dsn, "client_fleetops_routes_test")
all := []any{&models.Hub{}, &models.AppUser{}, &models.AppLocation{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
gdb.Create(&models.AppLocation{Applocationid: 1, Applocationname: "Coimbatore", Status: "Active"})
gdb.Create(&models.AppLocation{Applocationid: 2, Applocationname: "Chennai", Status: "Active"})
cbe := models.Hub{Hubname: "Coimbatore Neptune Hub", Hubtype: "delivery_hub", Applocationid: 1, Status: "Active"}
chn := models.Hub{Hubname: "Chennai Guindy Hub", Hubtype: "delivery_hub", Applocationid: 2, Status: "Active"}
gdb.Create(&cbe)
gdb.Create(&chn)
withCity := models.AppUser{Authname: "Priya", Email: "ops@peelamedu.test", Contactno: "9876543210", Password: "x", Roleid: 3, Applocationid: 1}
noCity := models.AppUser{Authname: "Nocity", Email: "ops@nocity.test", Contactno: "9876543211", Password: "x", Roleid: 3}
gdb.Create(&withCity)
gdb.Create(&noCity)
app := onboardingApp()
mint := func(uid int, email string, tenant int) string {
tok, err := utils.GenerateToken(uid, email, 3, tenant, 1, jwtSecret)
if err != nil {
t.Fatal(err)
}
return tok
}
client := mint(withCity.Userid, withCity.Email, 42)
code, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs", client, "")
if code != 200 || !strings.Contains(body, "Coimbatore Neptune Hub") || strings.Contains(body, "Chennai Guindy Hub") {
t.Fatalf("client hub list = %d %s, want Coimbatore only", code, body)
}
// Asking for another city by query string changes nothing.
if _, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs?applocationid=2", client, ""); strings.Contains(body, "Chennai Guindy Hub") {
t.Fatalf("?applocationid=2 leaked another city: %s", body)
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/hubs/"+strconv.Itoa(chn.Hubid), client, ""); code != 404 {
t.Fatalf("another city's hub detail = %d, want 404", code)
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/hubs/"+strconv.Itoa(cbe.Hubid), client, ""); code != 200 {
t.Fatalf("own city's hub detail = %d, want 200", code)
}
// A client with no city on file sees no hubs, never every hub.
if _, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs", mint(noCity.Userid, noCity.Email, 43), ""); strings.Contains(body, "Hub\"") {
t.Fatalf("client without a city = %s, want an empty list", body)
}
// Staff still see every hub.
_, body = do(t, app, http.MethodGet, "/api/v1/admin/hubs", consoleToken(t, "ops@doormile.com", 1, 0), "")
if !strings.Contains(body, "Coimbatore Neptune Hub") || !strings.Contains(body, "Chennai Guindy Hub") {
t.Fatalf("staff hub list = %s, want both cities", body)
}
}

View File

@@ -0,0 +1,169 @@
package routes_test
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Client onboarding with an address, end to end through the real router and
// handlers against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set;
// the DSN must be a THROWAWAY database — these tables are dropped and
// recreated in their own schema.
func onboardingDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres onboarding test")
}
gdb := testpg.Open(t, dsn, "client_onboarding_routes_test")
all := []any{&models.Tenant{}, &models.DoormileAuth{}, &models.AppUser{}, &models.TenantLocation{}, &models.AppLocation{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
// Put the previous handle back, nil included: other tests in this package
// rely on there being no database (the gate tests expect the handler to
// fail without one, not to query a closed test schema).
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
// The owner must exist as Doormile staff (onboardingOwnerStillValid).
hash, _ := utils.HashPassword("owner-pass-123")
if err := gdb.Create(&models.DoormileAuth{Email: onboardingOwner, PasswordHash: hash, Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&models.AppLocation{Applocationid: 1, Applocationname: "Coimbatore", Status: "Active"}).Error; err != nil {
t.Fatal(err)
}
return gdb
}
func onboardBody(extra string) string {
return `{"companyname":"Peelamedu Provisions","contactname":"Priya Raman","email":"ops@peelamedu.test",
"phone":"9876543210","password":"Strong-pass-1","applocationid":1` + extra + `}`
}
const goodAddress = `,"address":"14 DB Road, RS Puram, Coimbatore","city":"Coimbatore","state":"Tamil Nadu",
"pincode":"641002","latitude":11.0090,"longitude":76.9500`
func TestOnboardingRequiresAnAddressWithAMapLocation(t *testing.T) {
gdb := onboardingDB(t)
app := onboardingApp()
tok := consoleToken(t, onboardingOwner, 1, 0)
cases := []struct{ name, extra, want string }{
{"no address", "", "enter the client's address"},
{"bad pincode", `,"address":"14 DB Road, RS Puram","pincode":"64100","latitude":11.0,"longitude":76.9`, "6-digit pincode"},
{"typed, not picked", `,"address":"14 DB Road, RS Puram","pincode":"641002"`, "pick the address from the suggestions"},
}
for _, c := range cases {
code, body := do(t, app, http.MethodPost, "/api/v1/admin/clients/onboard", tok, onboardBody(c.extra))
if code != 400 || !strings.Contains(body, c.want) {
t.Errorf("%s: %d %s, want 400 containing %q", c.name, code, body, c.want)
}
}
var n int64
gdb.Model(&models.Tenant{}).Count(&n)
if n != 0 {
t.Fatal("a refused onboarding must create nothing")
}
}
func TestOnboardingSavesTheAddressAsThePrimaryLocation(t *testing.T) {
gdb := onboardingDB(t)
app := onboardingApp()
tok := consoleToken(t, onboardingOwner, 1, 0)
code, body := do(t, app, http.MethodPost, "/api/v1/admin/clients/onboard", tok, onboardBody(goodAddress))
if code != 201 {
t.Fatalf("onboard = %d %s", code, body)
}
var loc models.TenantLocation
if err := gdb.First(&loc).Error; err != nil {
t.Fatalf("no location created: %v", err)
}
if !loc.Isprimary || loc.Address != "14 DB Road, RS Puram, Coimbatore" || loc.Pincode != "641002" ||
loc.City != "Coimbatore" || loc.Latitude != 11.009 || loc.Locationname != "Peelamedu Provisions" {
t.Fatalf("location saved as %+v", loc)
}
// The list shows it.
code, body = do(t, app, http.MethodGet, "/api/v1/admin/clients/onboarded", tok, "")
if code != 200 || !strings.Contains(body, `"pincode":"641002"`) || !strings.Contains(body, `"address":"14 DB Road, RS Puram, Coimbatore"`) {
t.Fatalf("list = %d %s", code, body)
}
// The client signs in; the login now carries their city for the zone list.
code, body = do(t, app, http.MethodPost, "/api/v1/admin/login", "", `{"email":"ops@peelamedu.test","password":"Strong-pass-1"}`)
if code != 200 {
t.Fatalf("client login = %d %s", code, body)
}
var login struct {
User struct {
Applocationid int `json:"applocationid"`
Tenantid *int `json:"tenantid"`
} `json:"user"`
}
if err := json.Unmarshal([]byte(body), &login); err != nil || login.User.Applocationid != 1 || login.User.Tenantid == nil {
t.Fatalf("login user = %+v (%v)", login.User, err)
}
}
func TestEditingTheAddressUpdatesOrCreatesTheMainLocation(t *testing.T) {
gdb := onboardingDB(t)
app := onboardingApp()
tok := consoleToken(t, onboardingOwner, 1, 0)
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/clients/onboard", tok, onboardBody(goodAddress)); code != 201 {
t.Fatalf("onboard = %d %s", code, body)
}
var auth models.DoormileAuth
if err := gdb.Where("email = ?", "ops@peelamedu.test").First(&auth).Error; err != nil {
t.Fatal(err)
}
move := `{"location":{"address":"5 Avinashi Road, Peelamedu","city":"Coimbatore","state":"Tamil Nadu",
"pincode":"641004","latitude":11.0300,"longitude":76.9900}}`
if code, body := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/clients/%d", auth.ID), tok, move); code != 200 {
t.Fatalf("edit = %d %s", code, body)
}
var locs []models.TenantLocation
gdb.Find(&locs)
if len(locs) != 1 || locs[0].Address != "5 Avinashi Road, Peelamedu" || locs[0].Pincode != "641004" || !locs[0].Isprimary {
t.Fatalf("after edit: %+v", locs)
}
// An edit with a bad address is refused and changes nothing.
bad := `{"location":{"address":"x","pincode":"641004","latitude":11.03,"longitude":76.99}}`
if code, _ := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/clients/%d", auth.ID), tok, bad); code != 400 {
t.Fatalf("bad address edit = %d, want 400", code)
}
// A client onboarded before addresses existed: the edit creates the location.
hash, _ := utils.HashPassword("Old-pass-123")
old := models.Tenant{Tenantname: "Old Client", Primaryemail: "old@client.test", Primarycontact: "9876500000", Status: "Active"}
gdb.Create(&old)
tid := old.Tenantid
oldAuth := models.DoormileAuth{Email: "old@client.test", PasswordHash: hash, Role: "manager", Tenantid: &tid}
gdb.Create(&oldAuth)
if code, body := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/clients/%d", oldAuth.ID), tok, move); code != 200 {
t.Fatalf("edit old client = %d %s", code, body)
}
var created models.TenantLocation
if err := gdb.Where("tenantid = ?", tid).First(&created).Error; err != nil || !created.Isprimary || created.Locationname != "Old Client" {
t.Fatalf("old client location = %+v (%v)", created, err)
}
}

View File

@@ -0,0 +1,105 @@
package routes_test
import (
"net/http"
"strings"
"testing"
"doormile/config"
"doormile/routes"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/recover"
)
// Client onboarding's gate, over real HTTP. Only the configured owner login —
// as Doormile staff with roleid 1 — reaches the handler; everyone else is
// refused in middleware, before any database access.
const onboardingOwner = "admin@doormile.com"
var onboardingRoutes = []struct{ method, path, body string }{
{http.MethodPost, "/api/v1/admin/clients/onboard", `{"companyname":"Acme"}`},
{http.MethodGet, "/api/v1/admin/clients/onboarded", ""},
{http.MethodGet, "/api/v1/admin/clients/cities", ""},
{http.MethodPut, "/api/v1/admin/clients/5", `{"status":"Inactive"}`},
{http.MethodDelete, "/api/v1/admin/clients/5", ""},
}
func onboardingApp() *fiber.App {
app := fiber.New()
app.Use(recover.New())
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret, ClientOnboardingOwners: []string{onboardingOwner}})
return app
}
func consoleToken(t *testing.T, email string, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(1, email, roleID, tenantID, 1, jwtSecret)
if err != nil {
t.Fatalf("mint token: %v", err)
}
return tok
}
func TestClientOnboardingNeedsALogin(t *testing.T) {
app := onboardingApp()
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, "", r.body); code != http.StatusUnauthorized {
t.Errorf("%s %s with no token = %d, want 401", r.method, r.path, code)
}
}
}
func TestClientOnboardingRefusesEveryoneButTheOwner(t *testing.T) {
app := onboardingApp()
cases := []struct {
name string
token string
}{
{"another Doormile admin", consoleToken(t, "suriya@doormile.com", 1, 0)},
{"owner email as a manager", consoleToken(t, onboardingOwner, 3, 0)},
{"owner email as an executive", consoleToken(t, onboardingOwner, 4, 0)},
{"owner email on a client tenant", consoleToken(t, onboardingOwner, 1, 7)},
{"a client login", consoleToken(t, "ops@acme.example", 3, 7)},
{"a miler", consoleToken(t, onboardingOwner, 5, 0)},
{"a customer", consoleToken(t, onboardingOwner, 9, 0)},
}
for _, c := range cases {
for _, r := range onboardingRoutes {
code, body := do(t, app, r.method, r.path, c.token, r.body)
if code != http.StatusForbidden {
t.Errorf("%s: %s %s = %d, want 403", c.name, r.method, r.path, code)
}
if strings.Contains(body, onboardingOwner) {
t.Errorf("%s: the refusal leaked the owner email: %s", c.name, body)
}
}
}
}
// With no owners configured, nobody gets in — not even admin@doormile.com.
func TestClientOnboardingFailsClosedWithoutOwners(t *testing.T) {
app := newApp() // config without ClientOnboardingOwners
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, consoleToken(t, onboardingOwner, 1, 0), r.body); code != http.StatusForbidden {
t.Errorf("%s %s with no owners configured = %d, want 403", r.method, r.path, code)
}
}
}
// The owner passes every gate. There is no database here, so the handler's
// first query panics and recover answers 500 — proof that the route exists
// and nothing in front of it refused. Email matching ignores case.
func TestClientOnboardingOwnerPassesTheGate(t *testing.T) {
app := onboardingApp()
for _, email := range []string{onboardingOwner, "Admin@Doormile.com"} {
tok := consoleToken(t, email, 1, 0)
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, tok, r.body); code == 401 || code == 403 || code == 404 {
t.Errorf("owner %s %s %s = %d; a gate refused or the route is missing", email, r.method, r.path, code)
}
}
}
}

View File

@@ -0,0 +1,86 @@
package routes_test
import (
"net/http"
"os"
"strings"
"testing"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
"doormile/utils"
)
// A rider whose duty from earlier today is still open (reinstall, crash,
// signing in again) and who starts duty again. The server used to answer 400
// "already on duty" and leave the profile Offline, while the app showed the
// rider online: the console listed them Offline and auto-assignment skipped
// them. Postgres-gated; the DSN must be a THROWAWAY database.
func TestStartDutyResumesAnOpenDutyAndMarksTheRiderAvailable(t *testing.T) {
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres duty test")
}
gdb := testpg.Open(t, dsn, "miler_duty_routes_test")
all := []any{&models.AppUser{}, &models.MilerProfile{}, &models.MilerDutyLog{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
app := onboardingApp()
rider := func(name, phone, status string) (models.AppUser, string) {
u := models.AppUser{Authname: name, Email: name + "@riders.test", Contactno: phone, Password: "x", Roleid: 5, Status: "Active", Configid: 1001}
if err := gdb.Create(&u).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&models.MilerProfile{Userid: u.Userid, Displayname: name, Phone: phone, Availabilitystatus: status}).Error; err != nil {
t.Fatal(err)
}
// The duty left open from earlier today.
if err := gdb.Create(&models.MilerDutyLog{Userid: u.Userid, Loginat: time.Now(), Onduty: true}).Error; err != nil {
t.Fatal(err)
}
tok, err := utils.GenerateToken(u.Userid, u.Email, 5, 0, 1001, jwtSecret)
if err != nil {
t.Fatal(err)
}
return u, tok
}
statusOf := func(uid int) string {
var p models.MilerProfile
gdb.Where("userid = ?", uid).First(&p)
return p.Availabilitystatus
}
offline, tok := rider("nagalakshmi", "9000000101", constants.MilerOffline)
code, body := do(t, app, http.MethodPost, "/api/v1/miler/duty/start", tok, `{"lat":0,"lon":0}`)
if code != 200 || !strings.Contains(body, `"resumed":true`) {
t.Fatalf("start duty with an open duty = %d %s, want 200 resumed", code, body)
}
if got := statusOf(offline.Userid); got != constants.MilerAvailable {
t.Fatalf("rider status after resuming = %q, want Available", got)
}
var open int64
gdb.Model(&models.MilerDutyLog{}).Where("userid = ? AND logoutat IS NULL", offline.Userid).Count(&open)
if open != 1 {
t.Fatalf("open duty logs = %d, want the one resumed, not a second", open)
}
// A rider mid-delivery keeps that status; resuming only lifts Offline.
busy, tok := rider("busy", "9000000102", constants.MilerOnDelivery)
if code, body := do(t, app, http.MethodPost, "/api/v1/miler/duty/start", tok, `{"lat":0,"lon":0}`); code != 200 {
t.Fatalf("start duty = %d %s", code, body)
}
if got := statusOf(busy.Userid); got != constants.MilerOnDelivery {
t.Fatalf("on-delivery rider became %q, want On_Delivery kept", got)
}
}

View File

@@ -0,0 +1,194 @@
package routes_test
import (
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
)
// Reverse logistics phase 4: the parcel timeline and the returns summary, end
// to end through the router against a real Postgres. Postgres-gated; the DSN
// must be a THROWAWAY database.
func insightsDB(t *testing.T) {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres return insights test")
}
gdb := testpg.Open(t, dsn, "return_insights_routes_test")
all := []any{&models.Tenant{}, &models.Consignment{}, &models.ConsignmentHistory{}, &models.AppUser{}, &models.Hub{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
for _, tn := range []models.Tenant{
{Tenantid: 1, Tenantname: "Sai's Kitchen", Primaryemail: "sai@k.test", Primarycontact: "9000000001", Status: "Active"},
{Tenantid: 2, Tenantname: "Bawa Medicals", Primaryemail: "bawa@m.test", Primarycontact: "9000000002", Status: "Active"},
} {
tn := tn
if err := gdb.Create(&tn).Error; err != nil {
t.Fatal(err)
}
}
now := time.Now()
parcel := func(id, tenant int, status, reason string, created time.Time, started, back *time.Time) {
cn := models.Consignment{Consignmentid: id, Tenantid: tenant, Trackingno: fmt.Sprintf("DMX%08d", id), Status: status,
Returnreason: reason, Returninitiatedat: started, Returndeliveredat: back}
cn.Createdat = created
if err := gdb.Create(&cn).Error; err != nil {
t.Fatal(err)
}
}
ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
// Sai's Kitchen: 4 parcels, 1 delivered, 1 in return, 2 returned (one took 2 days).
parcel(101, 1, constants.ConsignmentDelivered, "", now, nil, nil)
parcel(102, 1, constants.ConsignmentRTOInitiated, "Receiver refused: gate closed", now, ago(time.Hour), nil)
parcel(103, 1, constants.ConsignmentReturnedToSender, "Receiver refused", now, ago(72*time.Hour), ago(24*time.Hour))
parcel(104, 1, constants.ConsignmentReturnedToSender, "Address not found: no such door", now, ago(48*time.Hour), ago(24*time.Hour))
// Bawa: 2 delivered, 1 cancelled (left out), 1 from 90 days ago (outside the default window).
parcel(201, 2, constants.ConsignmentDelivered, "", now, nil, nil)
parcel(202, 2, constants.ConsignmentDelivered, "", now, nil, nil)
parcel(203, 2, "Cancelled", "", now, nil, nil)
parcel(204, 2, constants.ConsignmentReturnedToSender, "Damaged in transit", now.AddDate(0, 0, -90), ago(90*24*time.Hour), ago(89*24*time.Hour))
// Created 60 days ago, still in return: outside the window's rate, but
// "in return now" all the same.
parcel(205, 2, constants.ConsignmentRTOInitiated, "Customer unavailable", now.AddDate(0, 0, -60), ago(5*24*time.Hour), nil)
ops := models.AppUser{Userid: 7, Authname: "Ops Priya", Email: "ops@doormile.com", Contactno: "9000000007", Password: "x", Roleid: 1}
gdb.Create(&ops)
hub := models.Hub{Hubid: 3, Hubname: "Coimbatore Neptune Hub", Hubtype: "delivery_hub", Applocationid: 1}
gdb.Create(&hub)
uid, hid := 7, 3
for i, h := range []models.ConsignmentHistory{
{Consignmentid: 102, Eventstatus: constants.ConsignmentInwardedAtHub, Hubid: &hid},
{Consignmentid: 102, Eventstatus: constants.ConsignmentOutForDelivery},
{Consignmentid: 102, Eventstatus: constants.ConsignmentOutForDelivery, Remarks: "GPS Update: Lat 11.0, Lon 76.9. Speed 20. Remarks: "},
{Consignmentid: 102, Eventstatus: constants.ConsignmentRTOInitiated, Userid: &uid, Remarks: "[from:Out_for_Delivery] Receiver refused: gate closed"},
} {
h.Createdat = now.Add(time.Duration(i-3) * time.Minute)
if err := gdb.Create(&h).Error; err != nil {
t.Fatal(err)
}
}
}
func TestParcelTimelineIsOrderedReadableAndScoped(t *testing.T) {
insightsDB(t)
app := onboardingApp()
code, body := do(t, app, http.MethodGet, "/api/v1/admin/consignments/102/history", consoleToken(t, "ops@doormile.com", 1, 0), "")
if code != 200 {
t.Fatalf("history = %d %s", code, body)
}
var res struct {
Data []struct {
Eventstatus, Remarks, Fromstatus, Actorname, Hubname string
} `json:"data"`
}
if err := json.Unmarshal([]byte(body), &res); err != nil {
t.Fatal(err)
}
if len(res.Data) != 3 || res.Data[0].Eventstatus != constants.ConsignmentInwardedAtHub || res.Data[2].Eventstatus != constants.ConsignmentRTOInitiated {
t.Fatalf("events out of order (or a GPS ping leaked in): %+v", res.Data)
}
last := res.Data[2]
if last.Remarks != "Receiver refused: gate closed" || last.Fromstatus != "Out_for_Delivery" || last.Actorname != "Ops Priya" {
t.Fatalf("RTO event = %+v, want the bookkeeping prefix stripped and the actor named", last)
}
if res.Data[0].Hubname != "Coimbatore Neptune Hub" {
t.Fatalf("hub event = %+v, want the hub named", res.Data[0])
}
// Its own client may read it; another client may not.
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/consignments/102/history", consoleToken(t, "sai@k.test", 3, 1), ""); code != 200 {
t.Fatalf("own client history = %d, want 200", code)
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/consignments/102/history", consoleToken(t, "bawa@m.test", 3, 2), ""); code != 404 {
t.Fatalf("other client history = %d, want 404", code)
}
}
type summaryResp struct {
Data struct {
Total, Delivered, Returned int64
InReturn int64 `json:"in_return"`
InReturnNow int64 `json:"in_return_now"`
ReturnRate float64 `json:"return_rate"`
AvgReturnDays *float64 `json:"avg_return_days"`
ByClient []struct {
Tenantid int
Total int64
InReturn int64 `json:"in_return"`
Returned int64
ReturnRate float64 `json:"return_rate"`
} `json:"by_client"`
ByReason []struct {
Reason string
Count int64
} `json:"by_reason"`
} `json:"data"`
}
func TestReturnsSummaryRatesReasonsAndScoping(t *testing.T) {
insightsDB(t)
app := onboardingApp()
get := func(tok, query string) summaryResp {
t.Helper()
code, body := do(t, app, http.MethodGet, "/api/v1/admin/returns/summary"+query, tok, "")
if code != 200 {
t.Fatalf("summary%s = %d %s", query, code, body)
}
var r summaryResp
if err := json.Unmarshal([]byte(body), &r); err != nil {
t.Fatal(err)
}
return r
}
// Staff, default window (last 30 days): 6 parcels (cancelled and the
// 90-day-old one left out), 3 of them in or through a return = 50%.
s := get(consoleToken(t, "ops@doormile.com", 1, 0), "").Data
if s.Total != 6 || s.Delivered != 3 || s.InReturn != 1 || s.Returned != 2 || s.ReturnRate != 50 || s.InReturnNow != 2 {
t.Fatalf("staff summary = %+v", s)
}
if s.AvgReturnDays == nil || *s.AvgReturnDays != 1.5 {
t.Fatalf("avg return days = %v, want 1.5 (2 days and 1 day)", s.AvgReturnDays)
}
if len(s.ByClient) != 2 || s.ByClient[0].Tenantid != 1 || s.ByClient[0].ReturnRate != 75 || s.ByClient[1].ReturnRate != 0 {
t.Fatalf("by client = %+v, want Sai's Kitchen first at 75%%, Bawa 0%%", s.ByClient)
}
if len(s.ByReason) != 2 || s.ByReason[0].Reason != "Receiver refused" || s.ByReason[0].Count != 2 || s.ByReason[1].Reason != "Address not found" {
t.Fatalf("by reason = %+v, want notes stripped and grouped by label", s.ByReason)
}
// A wider window brings the old return back in.
from := time.Now().AddDate(0, 0, -120).Format("2006-01-02")
if wide := get(consoleToken(t, "ops@doormile.com", 1, 0), "?from="+from).Data; wide.Total != 8 || wide.Returned != 3 {
t.Fatalf("120-day summary = %+v, want 8 parcels / 3 returned", wide)
}
// A client sees only its own figures, whatever it asks for.
own := get(consoleToken(t, "bawa@m.test", 3, 2), "").Data
if own.Total != 2 || len(own.ByClient) != 1 || own.ByClient[0].Tenantid != 2 || len(own.ByReason) != 0 || own.InReturnNow != 1 {
t.Fatalf("Bawa's own summary = %+v", own)
}
if code, body := do(t, app, http.MethodGet, "/api/v1/admin/returns/summary?tenantid=1", consoleToken(t, "bawa@m.test", 3, 2), ""); code != 403 {
t.Fatalf("a client asking for another client's summary = %d %s, want 403", code, body)
}
}

87
routes/routes_rto_test.go Normal file
View File

@@ -0,0 +1,87 @@
package routes_test
import (
"net/http"
"strings"
"testing"
)
// Reverse logistics (RTO) gates, over real HTTP. Refusals happen in
// middleware (or before any query), so no database is needed.
var rtoWrites = []struct{ method, path, body string }{
{http.MethodPost, "/api/v1/admin/consignments/5/rto", `{"reason":"receiver_refused"}`},
{http.MethodPost, "/api/v1/admin/consignments/5/rto/cancel", `{}`},
{http.MethodPost, "/api/v1/admin/consignments/5/rto/complete", `{}`},
}
func TestRTONeedsALogin(t *testing.T) {
app := newApp()
for _, w := range rtoWrites {
if code, _ := do(t, app, w.method, w.path, "", w.body); code != http.StatusUnauthorized {
t.Errorf("%s %s with no token = %d, want 401", w.method, w.path, code)
}
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/returns", "", ""); code != http.StatusUnauthorized {
t.Errorf("GET /admin/returns with no token = %d, want 401", code)
}
}
// A client login may read its returns but never start, cancel or close one.
func TestRTOActionsAreDoormileStaffOnly(t *testing.T) {
app := newApp()
client := tenantToken(t, 50, 1, 7)
for _, w := range rtoWrites {
code, body := do(t, app, w.method, w.path, client, w.body)
if code != http.StatusForbidden || !strings.Contains(body, "Doormile staff only") {
t.Errorf("client %s %s = %d %s, want 403 staff only", w.method, w.path, code, body)
}
}
}
func TestRTORefusesNonConsoleRoles(t *testing.T) {
app := newApp()
for _, role := range []int{5, 6, 9} {
for _, w := range rtoWrites {
if code, _ := do(t, app, w.method, w.path, token(t, 1, role), w.body); code != http.StatusForbidden {
t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code)
}
}
}
}
// Staff pass every gate (no database here, so the handler's first query
// panics and recover answers 500 — proof nothing in front of it refused).
// A missing reason is refused before any query.
func TestRTOStaffPassTheGate(t *testing.T) {
app := newApp()
staff := token(t, 1, 1)
for _, w := range rtoWrites {
if code, _ := do(t, app, w.method, w.path, staff, w.body); code == 401 || code == 403 || code == 404 {
t.Errorf("staff %s %s = %d; a gate refused or the route is missing", w.method, w.path, code)
}
}
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/consignments/5/rto", staff, `{"reason":"teleported"}`); code != http.StatusBadRequest {
t.Errorf("unknown reason = %d %s, want 400", code, body)
}
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/consignments/5/rto", staff, `{"reason":"other"}`); code != http.StatusBadRequest {
t.Errorf("other without a note = %d %s, want 400", code, body)
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/returns?status=bogus", staff, ""); code != http.StatusBadRequest {
t.Errorf("bad status filter = %d, want 400", code)
}
}
// The rider endpoint stays shut until MILER_RTO_FLOW_ENABLED=true — the
// deployed rider app does not know returns yet.
func TestRiderReturnIsOffByDefault(t *testing.T) {
t.Setenv("MILER_RTO_FLOW_ENABLED", "")
app := newApp()
code, body := do(t, app, http.MethodPost, "/api/v1/miler/consignments/5/return-complete", token(t, 9, 5), `{}`)
if code != http.StatusForbidden || !strings.Contains(body, "RTO_FLOW_DISABLED") {
t.Fatalf("flag off = %d %s, want 403 RTO_FLOW_DISABLED", code, body)
}
if code, _ := do(t, app, http.MethodPost, "/api/v1/miler/consignments/5/return-complete", token(t, 1, 1), `{}`); code != http.StatusForbidden {
t.Fatalf("a console token on the rider route = %d, want 403", code)
}
}

180
scratch/fix_miler_hubs.go Normal file
View File

@@ -0,0 +1,180 @@
//go:build ignore
// Give every Available miler the hub it plainly belongs to.
//
// Why this is needed: assignment everywhere filters
// `hubid = ? AND availabilitystatus = 'Available'`, and that intersection was
// empty — all 15 Available milers had hubid NULL, while all 15 milers that
// had a hub were Offline or already busy. Two seed runs populated disjoint
// sets, so no rider was reachable by HubBatchAssign, HubAutoAssign, or the
// auto-assign retry loop.
//
// The hub is not guessed. Each miler is matched to the NEAREST ACTIVE HUB
// WITHIN ITS OWN applocationid — a rider is never handed a hub in another
// city, and within a city the closest one wins. The seeded GPS sits almost
// exactly on a hub in every case, so this reproduces the intended pairing
// rather than inventing one.
//
// Run: go run scratch/fix_miler_hubs.go (plan only, writes nothing)
// go run scratch/fix_miler_hubs.go --apply (writes, in one transaction)
package main
import (
"fmt"
"math"
"os"
"strings"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
type hub struct {
Hubid int
Hubname string
Applocationid int
Latitude float64
Longitude float64
}
type miler struct {
Userid int
Displayname string
Applocationid int
Currentlatitude float64
Currentlongitude float64
}
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371
toRad := func(d float64) float64 { return d * math.Pi / 180 }
dLat, dLon := toRad(lat2-lat1), toRad(lon2-lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
func main() {
apply := false
for _, a := range os.Args[1:] {
if a == "--apply" {
apply = true
}
}
_ = godotenv.Load()
db.Connect(config.Load())
if db.DB == nil {
fmt.Println("no database connection")
os.Exit(1)
}
var hubs []hub
db.DB.Raw(`SELECT hubid, COALESCE(hubname,'') AS hubname,
COALESCE(applocationid,0) AS applocationid,
COALESCE(latitude,0) AS latitude, COALESCE(longitude,0) AS longitude
FROM hubs
WHERE deletedat IS NULL AND status = 'Active'
AND latitude IS NOT NULL AND longitude IS NOT NULL
AND latitude <> 0 AND longitude <> 0`).Scan(&hubs)
var milers []miler
db.DB.Raw(`SELECT userid, COALESCE(displayname,'') AS displayname,
COALESCE(applocationid,0) AS applocationid,
COALESCE(currentlatitude,0) AS currentlatitude,
COALESCE(currentlongitude,0) AS currentlongitude
FROM milerprofiles
WHERE availabilitystatus = 'Available' AND hubid IS NULL
ORDER BY applocationid, userid`).Scan(&milers)
fmt.Printf("candidate hubs: %d milers to fix: %d\n\n", len(hubs), len(milers))
type change struct {
userid int
name string
hubid int
hubname string
km float64
}
var changes []change
var skipped []string
for _, m := range milers {
best := -1
bestKM := math.MaxFloat64
for _, h := range hubs {
// Never cross a city boundary, whatever the distance says.
if h.Applocationid != m.Applocationid {
continue
}
d := haversineKM(m.Currentlatitude, m.Currentlongitude, h.Latitude, h.Longitude)
if d < bestKM {
bestKM, best = d, h.Hubid
}
}
if best == -1 {
skipped = append(skipped, fmt.Sprintf("user=%d %s (no active hub at applocationid=%d)",
m.Userid, m.Displayname, m.Applocationid))
continue
}
name := ""
for _, h := range hubs {
if h.Hubid == best {
name = h.Hubname
}
}
changes = append(changes, change{m.Userid, m.Displayname, best, name, bestKM})
}
fmt.Println("PLAN — nearest active hub in the miler's own city:")
for _, c := range changes {
fmt.Printf(" user=%-5d %-28s -> hub %-4d %-30s (%.2f km)\n",
c.userid, c.name, c.hubid, c.hubname, c.km)
}
if len(skipped) > 0 {
fmt.Println("\nSKIPPED (left untouched):")
for _, s := range skipped {
fmt.Println(" " + s)
}
}
// Rollback is trivial and worth printing either way: every row being
// written currently holds NULL, so undoing this is one statement.
ids := make([]string, 0, len(changes))
for _, c := range changes {
ids = append(ids, fmt.Sprint(c.userid))
}
fmt.Printf("\nROLLBACK:\n UPDATE milerprofiles SET hubid = NULL WHERE userid IN (%s);\n",
strings.Join(ids, ","))
if !apply {
fmt.Println("\n(plan only — nothing written. Re-run with --apply to commit.)")
return
}
tx := db.DB.Begin()
for _, c := range changes {
// The hubid IS NULL guard makes this idempotent and means a concurrent
// write cannot be clobbered: if someone set a hub in the meantime,
// theirs stands.
res := tx.Exec(`UPDATE milerprofiles SET hubid = ?, updatedat = NOW()
WHERE userid = ? AND hubid IS NULL`, c.hubid, c.userid)
if res.Error != nil {
tx.Rollback()
fmt.Println("FAILED, rolled back:", res.Error)
os.Exit(1)
}
}
if err := tx.Commit().Error; err != nil {
fmt.Println("commit failed:", err)
os.Exit(1)
}
fmt.Printf("\nAPPLIED: %d milers updated.\n", len(changes))
var eligible int64
db.DB.Raw(`SELECT COUNT(*) FROM milerprofiles
WHERE availabilitystatus = 'Available' AND hubid IS NOT NULL`).Scan(&eligible)
fmt.Println("Available AND hubid set is now:", eligible)
}