Compare commits

...

13 Commits

106 changed files with 19467 additions and 307 deletions

View File

@@ -0,0 +1,29 @@
cmd/migratecheck — run the real migration against a throwaway Postgres.
The test suite never calls migrations.Migrate(): the integration tests
AutoMigrate only the models they need. So every raw statement at the end of
migrations/migrate.go — CREATE EXTENSION, the views, the indexes, the registry
seed — was unexecuted until this existed, and Migrate() logs those failures
NON-FATALLY. A broken view therefore returned "migration completed
successfully" and failed later at query time.
That is not hypothetical: it caught `bd.destinationid` (the real column is
bookingdestinationid), which would have left consignment_booking uncreated and
every query that joins through it failing in production.
Run it before shipping a migration change:
docker run -d --name dm-testpg -e POSTGRES_PASSWORD=test -e POSTGRES_DB=logistics \
-p 55432:5432 pgvector/pgvector:pg16
docker exec dm-testpg psql -U postgres -d logistics -c "CREATE EXTENSION IF NOT EXISTS vector;"
MIGRATE_CHECK_DSN="host=localhost port=55432 user=postgres password=test dbname=logistics sslmode=disable" \
go run ./cmd/migratecheck
Check the OUTPUT, not just the exit code — Migrate() returns OK on a logged
failure. Any ❌ or ⚠️ line is a statement that did not run.
The same DSN unskips 50 Postgres integration tests:
REGISTRY_TEST_DSN="host=localhost port=55432 user=postgres password=test dbname=logistics sslmode=disable" \
go test ./... -count=1

40
cmd/migratecheck/main.go Normal file
View File

@@ -0,0 +1,40 @@
// Runs migrations.Migrate() against a throwaway Postgres, so the DDL is
// executed at least once before it runs on a real database at server startup.
//
// The test suite never calls Migrate — the integration tests AutoMigrate the
// models they need — so every raw statement at the end of migrations/migrate.go
// (CREATE EXTENSION, the views, the CONCURRENTLY index, the seed) was
// unexecuted until this existed.
//
// Usage: MIGRATE_CHECK_DSN="host=... " go run ./cmd/migratecheck
package main
import (
"fmt"
"os"
"doormile/db"
"doormile/migrations"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
dsn := os.Getenv("MIGRATE_CHECK_DSN")
if dsn == "" {
fmt.Println("MIGRATE_CHECK_DSN is required (a THROWAWAY database)")
os.Exit(2)
}
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
fmt.Println("connect:", err)
os.Exit(1)
}
db.DB = gdb // the registry seed reads the package handle
if err := migrations.Migrate(gdb); err != nil {
fmt.Println("MIGRATE FAILED:", err)
os.Exit(1)
}
fmt.Println("MIGRATE RETURNED OK")
}

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"strings"
)
type Config struct {
@@ -21,6 +22,12 @@ type Config struct {
NatsUser string
NatsPassword string
AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082)
// AIEngineBaseURL is AI_engine's own health surface (core/health.py),
// in-cluster. Distinct from AILayerBaseURL, which is routemate — the
// Claude-backed miler-selection service. Empty disables the agent-status
// proxy, which is the correct behaviour when the engine is not deployed:
// the console falls back to its snapshot rather than showing nothing.
AIEngineBaseURL string
// RouteOptimizerURL is the Route Optimization API that orders a rider's
// stops (Valhalla-backed road sequencing). Empty disables sequencing: stops
@@ -49,6 +56,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 {
@@ -69,6 +90,7 @@ func Load() *Config {
NatsUser: getEnv("NATS_USER", "doormile"),
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
AIEngineBaseURL: getEnv("AI_ENGINE_BASE_URL", ""),
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"),
GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"),
@@ -79,12 +101,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,56 @@
package constants
// What a client delivers, and what that implies operationally.
//
// ─── One vocabulary, not two ───────────────────────────────────────────────
//
// These eight values already existed as `validCategories` in
// controllers/doormilePricingController.go and as the comment on
// models.DoormilePricing.Category. Tenants now carry one too, so the set is
// promoted here rather than copied: a second list would drift, and a tenant
// whose category is not a pricing category cannot be priced.
var DeliveryCategories = map[string]bool{
"General": true,
"Documents": true,
"Electronics": true,
"Clothing": true,
"Fragile": true,
"Medical": true,
"Automotive": true,
"Food": true,
}
// DeliveryCategoryList is the same set, ordered, for anything that renders a
// choice. General first because it is the safe default; Food last because it
// is the one that turns a capability off.
var DeliveryCategoryList = []string{
"General", "Documents", "Electronics", "Clothing",
"Fragile", "Medical", "Automotive", "Food",
}
// CategoryDefault is what a client with no category recorded is treated as.
// Every tenant onboarded before this field existed has an empty string, and
// they must keep behaving exactly as they did — which means reverse logistics
// stays available to them.
const CategoryDefault = "General"
// ReverseLogisticsAllowed reports whether a return journey makes sense for
// what this client ships.
//
// Food is the exception: a meal that comes back is waste, not inventory. There
// is nothing to restock, nothing to refund against a returned item, and a
// rider carrying it to a hub is carrying rubbish. Offering RTO there is not a
// harmless extra button — it invites an operator to start a return journey
// that can only end in disposal, and it puts a return charge on a client's
// invoice for a parcel nobody can resell.
//
// Everything else can come back: clothing is the canonical case (wrong size),
// and electronics, documents and automotive parts all have a real return path.
//
// An UNKNOWN or empty category allows returns. That is deliberate: this field
// is new, every existing tenant has no value for it, and a default of "off"
// would silently withdraw a working capability from every client already using
// it. New information must not change old behaviour.
func ReverseLogisticsAllowed(category string) bool {
return category != "Food"
}

View File

@@ -0,0 +1,52 @@
package constants
import "testing"
// The rule the whole feature exists for: a returned meal is waste, not
// inventory, so Food clients get no reverse-logistics path.
func TestFoodIsTheOnlyCategoryWithoutReturns(t *testing.T) {
if ReverseLogisticsAllowed("Food") {
t.Error("Food allows returns; a returned meal can only be disposed of")
}
for _, c := range DeliveryCategoryList {
if c == "Food" {
continue
}
if !ReverseLogisticsAllowed(c) {
t.Errorf("%s does not allow returns; only Food should be excluded", c)
}
}
}
// New information must not change old behaviour. Every tenant onboarded before
// this field existed has an empty category, and they were all using returns.
func TestUnknownAndEmptyCategoriesKeepReturns(t *testing.T) {
for _, c := range []string{"", " ", "Groceries", "SomethingNew"} {
if !ReverseLogisticsAllowed(c) {
t.Errorf("category %q withdrew returns; an unrecognised category must not "+
"silently remove a capability an existing client is already using", c)
}
}
}
// The tenant category and the pricing category must stay ONE vocabulary: a
// tenant whose category is not a pricing category cannot be priced.
func TestListAndSetAgree(t *testing.T) {
if len(DeliveryCategoryList) != len(DeliveryCategories) {
t.Fatalf("list has %d, set has %d", len(DeliveryCategoryList), len(DeliveryCategories))
}
for _, c := range DeliveryCategoryList {
if !DeliveryCategories[c] {
t.Errorf("%s is in the list but not the set", c)
}
}
}
func TestDefaultIsAValidCategoryThatAllowsReturns(t *testing.T) {
if !DeliveryCategories[CategoryDefault] {
t.Errorf("CategoryDefault %q is not a valid category", CategoryDefault)
}
if !ReverseLogisticsAllowed(CategoryDefault) {
t.Error("the default category must not withdraw returns")
}
}

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 {
@@ -1859,17 +1945,51 @@ func CreateMiler(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
tx := db.DB.Begin()
// The console form checks these too; the server is what every caller
// (imports, scripts, other tools) actually goes through.
req.Authname = strings.TrimSpace(req.Authname)
req.Displayname = strings.TrimSpace(req.Displayname)
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Contactno = normalisePhone(req.Contactno)
if req.Authname == "" {
return utils.BadRequest(c, "enter the rider's login name")
}
if req.Displayname == "" {
req.Displayname = req.Authname
}
if !indianMobile.MatchString(req.Contactno) {
return utils.BadRequest(c, "enter a valid 10-digit Indian mobile number")
}
if req.Email == "" || !strings.Contains(req.Email, "@") {
return utils.BadRequest(c, "enter a valid email address")
}
vehicle, ok := canonicalVehicleType(req.Defaultvehicletype)
if !ok {
return utils.BadRequest(c, "vehicle type must be one of "+strings.Join(milerVehicleTypes, ", "))
}
appLocID := req.Applocationid
if appLocID == 0 {
appLocID = 1
}
var city models.AppLocation
if err := db.DB.Where("applocationid = ?", appLocID).First(&city).Error; err != nil {
return utils.BadRequest(c, "that city does not exist")
}
if msg := checkMilerHub(req.Hubid, appLocID); msg != "" {
return utils.BadRequest(c, msg)
}
// A client login may only create riders under its own tenant.
tenantID := req.Tenantid
if own := consoleTenantID(c); own != 0 {
tenantID = own
} else if tenantID != 0 {
var n int64
db.DB.Model(&models.Tenant{}).Where("tenantid = ?", tenantID).Count(&n)
if n == 0 {
return utils.BadRequest(c, "that client does not exist")
}
}
// Configid must match what LoginMiler looks up by — it queries
@@ -1881,6 +2001,18 @@ func CreateMiler(c *fiber.Ctx) error {
configID = 1001
}
// One rider per phone number: login finds the rider by it.
if milerPhoneTaken(req.Contactno, configID, 0) {
return utils.Conflict(c, "a rider with this phone number already exists")
}
var emailUsers int64
db.DB.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&emailUsers)
if emailUsers > 0 {
return utils.Conflict(c, "this email is already used by another login")
}
tx := db.DB.Begin()
user := models.AppUser{
Authname: req.Authname,
Email: req.Email,
@@ -1898,6 +2030,9 @@ func CreateMiler(c *fiber.Ctx) error {
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
if isUniqueViolation(err) {
return utils.Conflict(c, "this email is already used by another login")
}
return utils.Internal(c, "failed to create miler account")
}
@@ -1905,7 +2040,7 @@ func CreateMiler(c *fiber.Ctx) error {
Userid: user.Userid,
Displayname: req.Displayname,
Phone: req.Contactno,
Defaultvehicletype: req.Defaultvehicletype,
Defaultvehicletype: vehicle,
Availabilitystatus: constants.MilerOffline,
Rating: 5.00,
Applocationid: appLocID,
@@ -1984,6 +2119,7 @@ func UpdateMiler(c *fiber.Ctx) error {
Displayname string `json:"displayname"`
Defaultvehicletype string `json:"defaultvehicletype"`
Hubid *int `json:"hubid"`
Contactno *string `json:"contactno"`
}
req := new(MilerUpdate)
@@ -1991,18 +2127,53 @@ func UpdateMiler(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
if req.Displayname != "" {
profile.Displayname = req.Displayname
var user models.AppUser
if err := db.DB.Where("userid = ?", profile.Userid).First(&user).Error; err != nil {
return utils.NotFound(c, "miler not found")
}
if name := strings.TrimSpace(req.Displayname); name != "" {
profile.Displayname = name
}
if req.Defaultvehicletype != "" {
profile.Defaultvehicletype = req.Defaultvehicletype
vehicle, ok := canonicalVehicleType(req.Defaultvehicletype)
if !ok {
return utils.BadRequest(c, "vehicle type must be one of "+strings.Join(milerVehicleTypes, ", "))
}
profile.Defaultvehicletype = vehicle
}
if req.Hubid != nil && (profile.Hubid == nil || *profile.Hubid != *req.Hubid) {
// Only a CHANGED hub is checked: an older rider whose current hub is in
// another city (or since deleted) must still be editable.
if msg := checkMilerHub(req.Hubid, profile.Applocationid); msg != "" {
return utils.BadRequest(c, msg)
}
if req.Hubid != nil {
profile.Hubid = req.Hubid
}
// The phone is the rider's login: a wrong or duplicate number could not be
// corrected at all before, so the rider could never sign in.
phone := user.Contactno
if req.Contactno != nil {
phone = normalisePhone(*req.Contactno)
if !indianMobile.MatchString(phone) {
return utils.BadRequest(c, "enter a valid 10-digit Indian mobile number")
}
if phone != user.Contactno && milerPhoneTaken(phone, user.Configid, user.Userid) {
return utils.Conflict(c, "a rider with this phone number already exists")
}
profile.Phone = phone
}
profile.Updatedat = time.Now()
if err := db.DB.Save(profile).Error; err != nil {
err := db.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(profile).Error; err != nil {
return err
}
// appusers carries the login phone and the hub the hub console reads.
return tx.Model(&models.AppUser{}).Where("userid = ?", user.Userid).
Updates(map[string]interface{}{"contactno": phone, "hubid": profile.Hubid}).Error
})
if err != nil {
return utils.Internal(c, "failed to update miler")
}
return utils.OK(c, profile)
@@ -2914,6 +3085,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 +3175,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 +3288,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 +3796,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 +4243,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

@@ -21,6 +21,7 @@ func CreateAgentDecision(c *fiber.Ctx) error {
Context map[string]interface{} `json:"context"`
Decision map[string]interface{} `json:"decision"`
Reasoning string `json:"reasoning"`
TenantID *uint64 `json:"tenant_id"`
ContextEmbedding []float32 `json:"context_embedding"`
}
@@ -44,6 +45,7 @@ func CreateAgentDecision(c *fiber.Ctx) error {
record := models.AgentDecision{
DecisionType: body.DecisionType,
BookingID: body.BookingID,
TenantID: body.TenantID,
Context: string(contextJSON),
Decision: string(decisionJSON),
Reasoning: body.Reasoning,
@@ -73,6 +75,10 @@ func CreateAgentDecision(c *fiber.Ctx) error {
}
// GET /api/v1/internal/agent-decisions/similar
// POST /api/v1/internal/agent-decisions/similar
//
// POST rather than GET because the body carries a 1536-float embedding, and a
// GET body does not survive nginx — which fronts this API today.
func FindSimilarDecisions(c *fiber.Ctx) error {
decisionType := c.Query("decision_type")
limit, err := strconv.Atoi(c.Query("limit", "5"))
@@ -82,6 +88,11 @@ func FindSimilarDecisions(c *fiber.Ctx) error {
type req struct {
Embedding []float32 `json:"embedding"`
// TenantID scopes the search. Omitted or null means "decisions with no
// tenant" — NOT "every tenant". Precedent must never cross tenants:
// the engine feeds these rows to the model, so one client's history
// would otherwise shape another client's dispatch.
TenantID *uint64 `json:"tenant_id"`
}
body := new(req)
if err := c.BodyParser(body); err != nil || len(body.Embedding) == 0 {
@@ -102,14 +113,24 @@ func FindSimilarDecisions(c *fiber.Ctx) error {
}
var results []row
// outcome IS NOT NULL is the point of the table: an unresolved decision is
// not precedent, it is just a past guess. The outcome sweeper
// (internal/ai/outcomes) is what makes rows eligible here.
//
// The tenant predicate uses IS NOT DISTINCT FROM so a NULL tenant matches
// only NULL — plain `= ?` would match nothing at all for B2C decisions and
// silently return an empty recall forever.
if err := db.DB.Raw(
`SELECT decision, reasoning, outcome,
context_embedding <=> ? AS distance
FROM agent_decisions
WHERE decision_type = ? AND outcome IS NOT NULL
WHERE decision_type = ?
AND outcome IS NOT NULL
AND context_embedding IS NOT NULL
AND tenantid IS NOT DISTINCT FROM ?
ORDER BY context_embedding <=> ?
LIMIT ?`,
embeddingStr, decisionType, embeddingStr, limit,
embeddingStr, decisionType, body.TenantID, embeddingStr, limit,
).Scan(&results).Error; err != nil {
utils.Error("FindSimilarDecisions: query failed", "error", err)
return utils.Internal(c, "failed to query similar decisions")

View File

@@ -0,0 +1,87 @@
package controllers
import (
"encoding/json"
"io"
"net/http"
"time"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Proxying AI_engine's live agent state to the console.
//
// The console's Agents page has run on a hand-maintained snapshot
// (krow_talent_app/src/lib/agentNetwork.js, dated 16-20 Sep) because the engine
// exposed no endpoint it could read. The engine now serves GET /agents/status
// from core/health.py — but the console cannot call it directly: that surface
// is a ClusterIP Service on port 8700, and the console is a browser.
//
// So this proxies it. Staff-only, like the rest of /admin/ai.
//
// ─── Fails soft, on purpose ────────────────────────────────────────────────
//
// A 503 here means "ask the snapshot", not "something is broken". The engine is
// not deployed in Kubernetes yet, AI_ENGINE_BASE_URL is empty by default, and
// the Agents page must keep rendering in all of those cases. The console reads
// the status code and falls back; it never shows an error for this.
//
// Short timeout for the same reason: an unreachable engine must not hold a
// console request open. Two seconds is longer than an in-cluster call needs and
// shorter than anyone will wait.
// AIEngineBaseURL is set at boot from config. Empty disables the proxy.
var AIEngineBaseURL string
var engineClient = &http.Client{Timeout: 2 * time.Second}
// GET /api/v1/admin/ai/engine/agents
func GetAIEngineAgents(c *fiber.Ctx) error {
if AIEngineBaseURL == "" {
// Not an error: the engine has no in-cluster address configured, which
// is the default and is true today. The console falls back.
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_NOT_CONFIGURED",
"message": "AI_ENGINE_BASE_URL is not set; the console should use its snapshot.",
})
}
resp, err := engineClient.Get(AIEngineBaseURL + "/agents/status")
if err != nil {
utils.Warn("GetAIEngineAgents: engine unreachable", "error", err)
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_UNREACHABLE",
"message": "The engine did not answer; the console should use its snapshot.",
})
}
defer resp.Body.Close()
// Cap the read. This is a trusted in-cluster service, but a proxy that
// streams an unbounded body into a console response is a bad shape
// regardless of who is on the other end.
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_UNREADABLE", "message": "Could not read the engine's response.",
})
}
if resp.StatusCode != http.StatusOK {
// The engine answers 503 on /agents/status when it has no agent state
// (a non-production run mode). Pass that through rather than
// reinterpreting it.
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_NO_STATE",
"message": "The engine is running but reports no agent state.",
})
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_BAD_JSON", "message": "The engine's response was not JSON.",
})
}
return utils.OK(c, payload)
}

View File

@@ -0,0 +1,287 @@
package controllers
import (
"encoding/json"
"net/url"
"strconv"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Skill findings: the console's rule output, made durable.
//
// See models/ai_findings.go for why this is neither an exception nor a decision.
// The short version: the eight ops skills run in the browser and threw their
// output away, so nobody could answer whether a skill was useful, whether a
// finding had been seen before, or whether acting on one actually cleared it.
// POST /api/v1/admin/ai/findings
//
// Idempotent on fingerprint. The console re-evaluates every 60 seconds and will
// re-send an unchanged finding each time; this bumps lastseenat and seencount
// rather than inserting a duplicate. Without that, the table would grow by the
// number of open findings per minute per operator with the Exceptions page
// open — and "how long has this been open" would be unanswerable, which is the
// one signal that distinguishes an ignored finding from a new one.
func UpsertAIFindings(c *fiber.Ctx) error {
type item struct {
Skillid string `json:"skillid"`
Fingerprint string `json:"fingerprint"`
Severity string `json:"severity"`
Title string `json:"title"`
Proposaltool string `json:"proposaltool"`
Bookingids []int `json:"bookingids"`
Tenantid *int `json:"tenantid"`
}
var body struct {
Findings []item `json:"findings"`
// Cleared carries the fingerprints a scan did NOT raise this time.
// Sent by the console because only it knows the full set it evaluated:
// the backend cannot distinguish "resolved" from "the operator closed
// the tab" on its own.
Cleared []string `json:"cleared"`
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
now := utils.DBNow()
written, skipped := 0, 0
for _, f := range body.Findings {
if f.Skillid == "" || f.Fingerprint == "" {
skipped++
continue
}
scopeJSON, err := json.Marshal(f.Bookingids)
if err != nil {
skipped++
continue
}
row := models.AISkillFinding{
Skillid: f.Skillid,
Fingerprint: f.Fingerprint,
Severity: f.Severity,
Title: f.Title,
Proposaltool: f.Proposaltool,
Bookingcount: len(f.Bookingids),
Scope: string(scopeJSON),
Tenantid: f.Tenantid,
Firstseenat: now,
Lastseenat: now,
Seencount: 1,
}
// ON CONFLICT on the fingerprint: bump the sighting, leave firstseenat
// alone. seencount uses a SQL expression rather than a read-modify-write
// so two operators with the page open cannot lose each other's bump.
//
// clearedat is reset to NULL: a finding that cleared and came back is
// open again, and leaving the old timestamp would make it look resolved
// while it is being re-raised.
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "fingerprint"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"lastseenat": now,
"seencount": gorm.Expr("aiskillfindings.seencount + 1"),
"severity": f.Severity,
"bookingcount": len(f.Bookingids),
"scope": string(scopeJSON),
"clearedat": nil,
}),
}).Create(&row).Error; err != nil {
utils.Warn("UpsertAIFindings: upsert failed", "fingerprint", f.Fingerprint, "error", err)
skipped++
continue
}
written++
}
cleared := 0
if len(body.Cleared) > 0 {
// Only clear what is still open. Re-clearing an already-cleared row
// would move its clearedat forward on every poll and destroy the
// "acting cleared it in 4 minutes" measurement.
res := db.DB.Model(&models.AISkillFinding{}).
Where("fingerprint IN ? AND clearedat IS NULL", body.Cleared).
Update("clearedat", now)
if res.Error != nil {
utils.Warn("UpsertAIFindings: clearing failed", "error", res.Error)
} else {
cleared = int(res.RowsAffected)
}
}
return utils.OK(c, fiber.Map{"written": written, "skipped": skipped, "cleared": cleared})
}
// POST /api/v1/admin/ai/findings/:fingerprint/acted
//
// Records that an operator carried out a finding's proposal, and how it went.
// Separate from the upsert because it is a different event with a different
// actor: the upsert is a scan reporting what it sees, this is a person doing
// something. Collapsing them would make "nobody acted" indistinguishable from
// "the scan has not run since".
func RecordAIFindingActed(c *fiber.Ctx) error {
// Fiber's c.Params returns the RAW path segment, still percent-encoded.
// A fingerprint is "skill:tool:1,2,3", so the console necessarily sends it
// through encodeURIComponent and the ':' and ',' arrive as %3A and %2C.
// Matching the raw string against the stored one therefore never hits, and
// every acted-report 404s — silently, because the console treats this call
// as fire-and-forget.
//
// Found by running the real backend; a mock that echoed the path back
// agreed with the assumption and proved nothing.
fingerprint, err := url.PathUnescape(c.Params("fingerprint"))
if err != nil {
return utils.BadRequest(c, "invalid fingerprint")
}
if fingerprint == "" {
return utils.BadRequest(c, "fingerprint is required")
}
var body struct {
Result string `json:"result"` // ok, partial, failed
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
switch body.Result {
case "ok", "partial", "failed":
default:
// A partial success is a partial success — the console reports six
// riders notified out of eight that way, and flattening it to "ok"
// here would lose exactly the distinction the executors preserve.
return utils.BadRequest(c, "result must be one of: ok, partial, failed")
}
actor, _ := c.Locals("userid").(int)
now := utils.DBNow()
res := db.DB.Model(&models.AISkillFinding{}).
Where("fingerprint = ?", fingerprint).
Updates(map[string]interface{}{
"actedat": now,
"actedby": actor,
"actionresult": body.Result,
})
if res.Error != nil {
utils.Error("RecordAIFindingActed: update failed", "error", res.Error)
return utils.Internal(c, "failed to record the action")
}
if res.RowsAffected == 0 {
return utils.NotFound(c, "finding not found")
}
return utils.OK(c, fiber.Map{"fingerprint": fingerprint, "result": body.Result})
}
// GET /api/v1/admin/ai/findings
//
// What the skills have been noticing. Read-only; Doormile staff only, like the
// rest of the /admin/ai surface.
//
// Defaults to open findings (clearedat IS NULL) because that is the operational
// question. `?days=N` switches to everything in a window, which is the
// measurement question — and those are different enough that one default cannot
// serve both.
func GetAIFindings(c *fiber.Ctx) error {
limit, err := strconv.Atoi(c.Query("limit", "100"))
if err != nil || limit < 1 || limit > 500 {
limit = 100
}
q := db.DB.Model(&models.AISkillFinding{})
if days, err := strconv.Atoi(c.Query("days", "0")); err == nil && days > 0 {
if days > 90 {
days = 90
}
q = q.Where("firstseenat >= ?", utils.DBNow().AddDate(0, 0, -days))
} else {
q = q.Where("clearedat IS NULL")
}
if skill := c.Query("skill"); skill != "" {
q = q.Where("skillid = ?", skill)
}
var rows []models.AISkillFinding
if err := q.Order("lastseenat DESC").Limit(limit).Find(&rows).Error; err != nil {
utils.Error("GetAIFindings: query failed", "error", err)
return utils.Internal(c, "failed to read findings")
}
return utils.List(c, rows, int64(len(rows)))
}
// GET /api/v1/admin/ai/findings/stats
//
// Per skill, over a window: how often it fires, how long its findings stay
// open, how often anyone acts, and whether acting cleared them.
//
// This is the point of the table. "Acted and cleared" versus "cleared on its
// own" is what separates a skill that helps from one that narrates — and a
// skill whose findings always clear untouched is proposing work that did not
// need doing.
func GetAIFindingStats(c *fiber.Ctx) error {
days, err := strconv.Atoi(c.Query("days", "30"))
if err != nil || days < 1 || days > 90 {
days = 30
}
since := utils.DBNow().AddDate(0, 0, -days)
type stat struct {
Skillid string `json:"skillid"`
Findings int64 `json:"findings"`
Stillopen int64 `json:"stillopen"`
Actedon int64 `json:"actedon"`
Clearedafteract int64 `json:"clearedafteract"`
Clearedunacted int64 `json:"clearedunacted"`
Avgopenminutes *float64 `json:"avgopenminutes"`
}
var rows []stat
// EXTRACT over (clearedat - firstseenat): both are written with
// utils.DBNow, so they share a tagging and their difference is correct
// regardless of the IST-digits-labelled-UTC convention.
if err := db.DB.Raw(`
SELECT skillid,
count(*) AS findings,
count(*) FILTER (WHERE clearedat IS NULL) AS stillopen,
count(*) FILTER (WHERE actedat IS NOT NULL) AS actedon,
count(*) FILTER (WHERE actedat IS NOT NULL AND clearedat IS NOT NULL) AS clearedafteract,
count(*) FILTER (WHERE actedat IS NULL AND clearedat IS NOT NULL) AS clearedunacted,
avg(EXTRACT(EPOCH FROM (clearedat - firstseenat)) / 60.0)
FILTER (WHERE clearedat IS NOT NULL) AS avgopenminutes
FROM aiskillfindings
WHERE firstseenat >= ?
GROUP BY skillid
ORDER BY findings DESC`, since).Scan(&rows).Error; err != nil {
utils.Error("GetAIFindingStats: query failed", "error", err)
return utils.Internal(c, "failed to read finding stats")
}
return utils.OK(c, fiber.Map{
"days": days,
"since": since,
"skills": rows,
})
}
// PruneAIFindings drops findings past the retention window. Called from the
// outcome sweeper's tick rather than having its own timer — one more table to
// keep tidy, not one more goroutine.
func PruneAIFindings(retentionDays int) (int, error) {
if db.DB == nil || retentionDays <= 0 {
return 0, nil
}
cutoff := utils.DBNow().AddDate(0, 0, -retentionDays)
res := db.DB.Where("firstseenat < ?", cutoff).Delete(&models.AISkillFinding{})
if res.Error != nil {
return 0, res.Error
}
return int(res.RowsAffected), nil
}

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,266 @@
package controllers
import (
"math"
"doormile/constants"
"doormile/db"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// The shared batch-assign solver.
//
// This was the body of HubBatchAssign. It is lifted out so the admin console
// can run the same solver, because the console's agent layer needs it and the
// hub route is unreachable from an admin login.
//
// ─── Why the console could not use any existing route ──────────────────────
//
// The ops-layer skills raise findings that propose assigning a set of orders
// (SlaGuardianSkill's `assignMiler`), and that proposal has had no executor at
// all. The two candidate routes both failed, for different reasons:
//
// POST /hub/bookings/batch-assign sits behind HubStaffAuth, which refuses
// every token whose role is not 6. From the
// admin console it 403s on every click.
// POST /admin/bookings/:id/assign-miler
// needs a CHOSEN rider per booking
// ({mileruserid}), and a finding does not
// pick one — it names orders, not riders.
//
// So the console needed the hub route's SOLVER (which picks riders itself) with
// the admin route's AUTH. Extracting the solver gives both routes one
// implementation; a second copy would be a third definition of "who gets this
// booking", after internal/assignment's AI path and AssignMilerToBooking.
//
// ─── What this is NOT ──────────────────────────────────────────────────────
//
// A greedy nearest-available-rider heuristic over haversine distance, capped
// per rider. Deliberately not internal/assignment's selectMilerWithAI (which
// reasons about hub load and on-time rate), and deliberately not a multi-stop
// VRP solver. It clears a queue; it does not optimise one. Stop ORDER comes
// afterwards from the Route Optimization API.
// batchRiderScope decides which riders are candidates and which bookings are in
// play. The hub and admin routes differ only here, which is the entire reason
// this split works.
type batchRiderScope struct {
// Bookings narrows the pending-booking query. Hub: its own pincode prefix
// and hub staff's tenant. Admin: the console login's tenant, if any.
Bookings func(*gorm.DB) *gorm.DB
// Riders narrows the rider query. Hub: riders on duty AT that hub. Admin:
// riders on duty anywhere, because an admin batch is not hub-bound.
Riders func(*gorm.DB) *gorm.DB
// ActorID is recorded as the assigner on every BookingAssignment, so an
// agent-proposed assignment is attributable to the operator who confirmed
// it rather than appearing to come from nowhere.
ActorID int
}
// BatchAssignResult is what both routes return.
type BatchAssignResult struct {
Assigned int `json:"assigned"`
Skipped int `json:"skipped"`
Riderssequenced int `json:"riderssequenced"`
Results []fiber.Map `json:"results"`
}
// RunBatchAssign is the solver. bookingIDs empty means "everything the scope
// allows", which is how the hub route clears its whole queue; the console
// always passes an explicit set.
func RunBatchAssign(bookingIDs []int, capPerRider int, scope batchRiderScope) (BatchAssignResult, error) {
if capPerRider <= 0 {
capPerRider = defaultBatchAssignCapPerRider
}
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
if len(bookingIDs) > 0 {
bookingQuery = bookingQuery.Where("bookingid IN ?", bookingIDs)
}
if scope.Bookings != nil {
bookingQuery = scope.Bookings(bookingQuery)
}
var bookings []models.PickupBooking
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
return BatchAssignResult{}, err
}
if len(bookings) == 0 {
return BatchAssignResult{Results: []fiber.Map{}}, nil
}
// Every rider on duty, 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.
riderQuery := db.DB.Where("availabilitystatus IN ?", constants.MilerWorkingStatuses)
if scope.Riders != nil {
riderQuery = scope.Riders(riderQuery)
}
var riderProfiles []models.MilerProfile
if err := riderQuery.Find(&riderProfiles).Error; err != nil {
return BatchAssignResult{}, err
}
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),
})
}
results := make([]fiber.Map, 0, len(bookings))
assignedCount, skippedCount := 0, 0
for _, b := range bookings {
var nearest *batchRiderCandidate
nearestDist := math.MaxFloat64
for _, cand := range candidates {
if cand.assigned >= capPerRider {
continue
}
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
if d < nearestDist {
nearestDist = d
nearest = cand
}
}
if nearest == nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": "no available rider under capacity",
})
skippedCount++
continue
}
actor := scope.ActorID
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &actor); err != nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": err.Error(),
})
skippedCount++
continue
}
nearest.assigned++
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
})
assignedCount++
}
// Batch assignment is exactly the case stop-ordering exists for: a rider
// walks out of here with several bookings and otherwise no indication of
// what order to run them in.
//
// Best-effort and deliberately after the assignments are committed: the
// optimizer is a separate service over the network, and it failing must
// leave the bookings assigned rather than undoing the batch.
sequenced := 0
for _, r := range ridersAssigned(results) {
if _, err := routing.SequenceMilerStops(r); err != nil {
utils.Warn("BatchAssign: stop sequencing failed",
"miler_userid", r, "error", err)
continue
}
sequenced++
}
return BatchAssignResult{
Assigned: assignedCount,
Skipped: skippedCount,
Riderssequenced: sequenced,
Results: results,
}, nil
}
// AdminBatchAssign — POST /api/v1/admin/bookings/batch-assign
//
// The admin counterpart of HubBatchAssign, and the executor behind the console
// ops layer's `assignMiler` proposal. Same solver, admin auth, and scoped to
// the console login's own tenant when there is one (a client login must not
// assign another client's parcels).
//
// Note on behaviour, because it differs from every other console write in the
// agent layer: this COMMITS. There is no preview/reconcile step — the same is
// true of the hub route it reuses. The console's proposal gate is therefore the
// only thing between a finding and a real assignment, which is why the UI must
// keep requiring an explicit click.
func AdminBatchAssign(c *fiber.Ctx) error {
var req struct {
Bookingids []int `json:"bookingids"`
MaxPerRider int `json:"max_per_rider"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// Unlike the hub route, an empty set is refused here. The hub's empty case
// means "clear this hub's queue", bounded by its pincode prefix; an admin
// login has no such bound, so an empty body would mean "assign every
// pending booking in the system" — never what a caller intended.
if len(req.Bookingids) == 0 {
return utils.BadRequest(c, "bookingids is required")
}
actorID, _ := c.Locals("userid").(int)
// The route is registered behind middlewares.DoormileStaffOnly, so a
// partner-tenant login never reaches this handler and `own` is always 0
// today. The scoping below is therefore unreachable — kept deliberately,
// as defence in depth: assignment is a Fleet Ops write and staff-only is
// the current decision, but if that guard is ever relaxed the handler must
// not silently start letting one client assign another's parcels. The same
// belt-and-braces reasoning the ops-layer intents use for their domain
// guards.
own := consoleTenantID(c)
result, err := RunBatchAssign(req.Bookingids, req.MaxPerRider, batchRiderScope{
ActorID: actorID,
Bookings: func(q *gorm.DB) *gorm.DB {
if own == 0 {
return q // Doormile staff
}
// A client login sees only its own bookings. A booking with no
// tenant can't be proven to belong to them, so it stays invisible —
// the same rule canAccessBooking applies to a single booking.
return q.Where("tenantid = ?", own)
},
// Riders are not narrowed by tenant: riders are Doormile's, not a
// client's, and a client login assigning its own parcels still draws
// from the whole on-duty fleet.
Riders: nil,
})
if err != nil {
utils.Error("AdminBatchAssign: failed", "error", err)
return utils.Internal(c, "failed to assign bookings")
}
return utils.OK(c, fiber.Map{
"assigned": result.Assigned,
"skipped": result.Skipped,
"riderssequenced": result.Riderssequenced,
"results": result.Results,
})
}

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,794 @@
package controllers
import (
"errors"
"net/mail"
"regexp"
"strings"
"time"
"unicode/utf8"
"doormile/constants"
"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"`
// Deliverycategory is what the client ships. Required: it drives pricing
// AND whether reverse logistics applies, and guessing it for them means
// guessing whether their parcels can come back.
Deliverycategory string `json:"deliverycategory"`
// 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"
}
// Required, not defaulted. The category drives pricing AND whether this
// client's parcels can be returned at all — defaulting it to General would
// quietly give a food client a reverse-logistics path that makes no sense
// for what they ship, and nobody would be asked.
r.Deliverycategory = strings.TrimSpace(r.Deliverycategory)
if r.Deliverycategory == "" {
return "choose what this client delivers"
}
if !constants.DeliveryCategories[r.Deliverycategory] {
return "that is not a delivery category we price for"
}
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,
Deliverycategory: req.Deliverycategory,
// Defaulted from the category, not asked for separately. The
// operator answers the question they can answer ("what do they
// ship?"); the consequence follows. It stays editable afterwards
// for the cases the category cannot express.
Reverselogisticsenabled: boolPtr(constants.ReverseLogisticsAllowed(req.Deliverycategory)),
}
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"`
// What the client ships, and whether their parcels can come back. The
// console's edit dialog seeds its category picker from these — without
// them it showed "Not recorded" for every client, including ones that
// have a category.
Deliverycategory string `json:"deliverycategory"`
Reverselogisticsenabled bool `json:"reverselogisticsenabled"`
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(t.deliverycategory, '') AS deliverycategory,
COALESCE(t.reverselogisticsenabled, true) AS reverselogisticsenabled,
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"`
Deliverycategory string `gorm:"column:deliverycategory"`
// Scanned as a plain bool from a COALESCE, so a client predating the
// field reads as true — the same meaning Tenant.ReturnsEnabled() gives nil.
Reverselogisticsenabled bool `gorm:"column:reverselogisticsenabled"`
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,
Deliverycategory: r.Deliverycategory, Reverselogisticsenabled: r.Reverselogisticsenabled,
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"`
// Deliverycategory changes what the client ships. Omitted leaves it alone,
// so an edit that only touches the phone number cannot blank it — and an
// older client with no category recorded stays editable without being
// forced to pick one mid-edit.
Deliverycategory *string `json:"deliverycategory"`
// Reverselogisticsenabled overrides what the category implies — a
// clearance line that is final sale, or a caterer who takes back
// equipment. Omitted keeps the stored value, EXCEPT when the category
// changes, which re-derives it (see below).
Reverselogisticsenabled *bool `json:"reverselogisticsenabled"`
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"
}
// Only a category the caller is actually changing is validated — the same
// rule the phone above follows. Seeding from the stored value keeps an
// edit that does not mention the category working, including for clients
// onboarded before the field existed (empty, which validate() would
// otherwise refuse).
if req.Deliverycategory != nil {
check.Deliverycategory = strings.TrimSpace(*req.Deliverycategory)
} else if tenant.Deliverycategory != "" {
check.Deliverycategory = tenant.Deliverycategory
} else {
check.Deliverycategory = constants.CategoryDefault
}
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
}
// Changing WHAT a client ships re-derives whether their parcels can be
// returned — otherwise switching a client to Food would leave returns
// quietly enabled for a category where a returned parcel can only be
// thrown away.
//
// An explicit reverselogisticsenabled in the same request still wins:
// that is the override for the cases a category cannot express (a
// final-sale clearance line, a caterer who takes back equipment).
if req.Deliverycategory != nil && check.Deliverycategory != tenant.Deliverycategory {
tenantUpdates["deliverycategory"] = check.Deliverycategory
tenantUpdates["reverselogisticsenabled"] = constants.ReverseLogisticsAllowed(check.Deliverycategory)
}
if req.Reverselogisticsenabled != nil {
tenantUpdates["reverselogisticsenabled"] = *req.Reverselogisticsenabled
}
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)))
}
// boolPtr is for the Tenant.Reverselogisticsenabled pointer: a non-nil false
// must reach the database, which a plain bool would not (see the field).
func boolPtr(b bool) *bool { return &b }

View File

@@ -0,0 +1,143 @@
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,
// Required since clients carry a delivery category: it sets their
// pricing and whether their parcels can be returned at all.
Deliverycategory: "Clothing",
}
}
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){
"choose what this client delivers": func(r *onboardClientRequest) { r.Deliverycategory = " " },
"not a delivery category we price for": func(r *onboardClientRequest) { r.Deliverycategory = "Groceries" },
"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,758 @@
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}
// tenantAllowsReturns reports whether this consignment's client ships things
// that can come back, and an operator-readable refusal when they do not.
//
// ─── Why this is enforced here and not only in the console ─────────────────
//
// The console hides the RTO controls for a client whose category rules returns
// out. Hiding a button is a courtesy, not a rule: the endpoint stays reachable
// by anyone with a staff token, a stale tab open from before the client's
// category changed, or a direct call. Starting a return for a food client
// would move a perishable parcel into a return journey that can only end in
// disposal, and put a return charge on an invoice for something nobody can
// resell.
//
// Fails OPEN on a lookup error. A database hiccup must not block a legitimate
// return — the cost of wrongly allowing one is an operator reversing it; the
// cost of wrongly blocking one is a parcel stranded with no path home.
func tenantAllowsReturns(tx *gorm.DB, cn *models.Consignment) (bool, string) {
if cn == nil || cn.Tenantid == 0 {
return true, ""
}
var t models.Tenant
if err := tx.Select("tenantname", "deliverycategory", "reverselogisticsenabled").
First(&t, cn.Tenantid).Error; err != nil {
utils.Warn("tenantAllowsReturns: could not read the tenant, allowing the return",
"tenantid", cn.Tenantid, "error", err)
return true, ""
}
if t.ReturnsEnabled() {
return true, ""
}
who := t.Tenantname
if who == "" {
who = "This client"
}
what := t.Deliverycategory
if what == "" {
what = "what they ship"
}
return false, who + " does not use reverse logistics (" + what +
"). Returns are switched off for this client — change it on their profile if that is wrong."
}
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
}
if allowed, refusal := tenantAllowsReturns(tx, cn); !allowed {
return errRTO{refusal}
}
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
}
// The same client-category rule the operator-initiated path applies —
// and it matters MORE here, because nobody chose this. An automatic
// return sends a food parcel on a journey back to a hub where it can
// only be thrown away, bills the client for it, and does so with no
// operator in the loop to notice.
//
// Not an error: the parcel simply stops retrying and stays for a human
// to close out. Logged at info because "the attempts ran out and no
// return was started" is a thing someone will need to explain later.
if allowed, refusal := tenantAllowsReturns(tx, &fresh); !allowed {
utils.Info("RTO: automatic return skipped, client does not use reverse logistics",
"consignment_id", fresh.Consignmentid, "reason", refusal)
return nil
}
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,445 @@
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)
}
}
// ─── Reverse logistics by client category ──────────────────────────────────
//
// The console hides the RTO controls for a client whose category rules returns
// out. These prove the SERVER refuses too: hiding a button leaves the endpoint
// reachable from a stale tab, a direct call, or an operator whose client
// changed category after the page loaded.
// tenantReturnsDB adds the tenants table to the RTO fixture, since
// tenantAllowsReturns reads it.
func tenantReturnsDB(t *testing.T) *gorm.DB {
t.Helper()
gdb := rtoTestDB(t)
if err := gdb.Migrator().DropTable(&models.Tenant{}); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&models.Tenant{}); err != nil {
t.Fatal(err)
}
return gdb
}
func seedTenant(t *testing.T, gdb *gorm.DB, id int, name, category string, rlEnabled bool) {
t.Helper()
must(t, gdb.Create(&models.Tenant{
Tenantid: id, Tenantname: name, Status: "Active",
Deliverycategory: category, Reverselogisticsenabled: &rlEnabled,
}).Error)
}
func TestReturnsRefusedForAFoodClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Peelamedu Meals", "Food", false)
cn := seedParcel(t, gdb, 7101, constants.ConsignmentOutForDelivery)
allowed, refusal := tenantAllowsReturns(gdb, cn)
if allowed {
t.Fatal("a Food client was allowed a return; a returned meal can only be disposed of")
}
// The operator must be told WHICH client and WHY, not just refused.
if !strings.Contains(refusal, "Peelamedu Meals") {
t.Errorf("refusal does not name the client: %q", refusal)
}
if !strings.Contains(refusal, "Food") {
t.Errorf("refusal does not say what they ship: %q", refusal)
}
}
func TestReturnsAllowedForAClothingClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Gandhipuram Garments", "Clothing", true)
cn := seedParcel(t, gdb, 7102, constants.ConsignmentOutForDelivery)
if allowed, refusal := tenantAllowsReturns(gdb, cn); !allowed {
t.Fatalf("a Clothing client was refused a return: %q", refusal)
}
}
// Every tenant onboarded before this field existed has no category and no
// flag. They were all using returns, and a new column must not withdraw that.
func TestReturnsAllowedForATenantPredatingTheField(t *testing.T) {
gdb := tenantReturnsDB(t)
// Written the way an existing row looks: no category, and the column
// default (true) for the flag.
must(t, gdb.Exec(`INSERT INTO tenants (tenantid, tenantname, status) VALUES (?, ?, ?)`,
901, "Legacy Client", "Active").Error)
cn := seedParcel(t, gdb, 7103, constants.ConsignmentOutForDelivery)
if allowed, refusal := tenantAllowsReturns(gdb, cn); !allowed {
t.Fatalf("a pre-existing client lost returns: %q", refusal)
}
}
// The override: a non-Food client whose returns are switched off deliberately
// (a final-sale clearance line) is still refused.
func TestExplicitOverrideBeatsTheCategoryOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Final Sale Co", "Clothing", false)
cn := seedParcel(t, gdb, 7104, constants.ConsignmentOutForDelivery)
if allowed, _ := tenantAllowsReturns(gdb, cn); allowed {
t.Error("the stored flag was ignored; an operator's explicit override must win over the category")
}
}
// Fails OPEN. A database hiccup must not strand a parcel with no path home:
// wrongly allowing a return costs an operator a reversal, wrongly blocking one
// costs a parcel.
func TestUnknownTenantStillAllowsReturnsOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
cn := seedParcel(t, gdb, 7105, constants.ConsignmentOutForDelivery) // tenant 901 never created
if allowed, refusal := tenantAllowsReturns(gdb, cn); !allowed {
t.Fatalf("a missing tenant row blocked a return: %q", refusal)
}
}
// And the guard is actually WIRED into the start path, not merely defined.
func TestStartRTOIsBlockedForAFoodClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Peelamedu Meals", "Food", false)
cn := seedParcel(t, gdb, 7106, constants.ConsignmentOutForDelivery)
allowed, refusal := tenantAllowsReturns(gdb, cn)
if allowed {
t.Fatal("precondition: this client must be refused")
}
// InitiateConsignmentRTO turns that refusal into errRTO, which rtoResult
// maps to a 400. Asserting the error type keeps the two in step.
err := errRTO{refusal}
if err.Error() != refusal {
t.Errorf("errRTO lost the message: %q", err.Error())
}
// The parcel must be untouched: a refused return changes nothing.
var after models.Consignment
must(t, gdb.First(&after, cn.Consignmentid).Error)
if after.Status != constants.ConsignmentOutForDelivery {
t.Errorf("status moved to %s on a refused return", after.Status)
}
}
// The GORM trap that made this feature silently not work.
//
// Tenant.Reverselogisticsenabled is a *bool because GORM OMITS a zero-value
// field from an INSERT when its tag declares a default. As a plain bool, an
// onboarded Food client's `false` was dropped and the column default (true)
// applied — reverse logistics ENABLED for exactly the clients it must be off
// for, with nothing in any log to say so.
//
// This writes through the real onboarding path's value and reads it back.
func TestOnboardedFoodClientIsStoredWithReturnsOffOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
// Built the way clientOnboardingController builds it.
must(t, gdb.Create(&models.Tenant{
Tenantid: 902, Tenantname: "Peelamedu Meals", Status: "Active",
Deliverycategory: "Food",
Reverselogisticsenabled: boolPtr(constants.ReverseLogisticsAllowed("Food")),
}).Error)
var back models.Tenant
must(t, gdb.First(&back, 902).Error)
if back.ReturnsEnabled() {
t.Fatal("a Food client was stored with returns ENABLED; the zero-value false was dropped on insert")
}
}
// And the other half: nil must read as enabled, so a row that predates the
// field keeps the capability it already had.
func TestNilReverseLogisticsReadsAsEnabled(t *testing.T) {
var t1 models.Tenant // nil pointer
if !t1.ReturnsEnabled() {
t.Error("nil read as disabled; a client predating the field would lose returns")
}
off := false
t1.Reverselogisticsenabled = &off
if t1.ReturnsEnabled() {
t.Error("an explicit false read as enabled")
}
}
// The automatic return is the path that matters most: nobody chooses it, so
// an unguarded one would send a food parcel back to a hub to be thrown away,
// bill the client for it, and do so with no operator in the loop.
func TestAutomaticReturnIsSkippedForAFoodClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Peelamedu Meals", "Food", false)
cn := seedParcel(t, gdb, 7107, constants.ConsignmentOutForDelivery)
cn.Attemptcount = 99 // well past any configured threshold
autoRTOAfterSkip(cn, rtoRider, "nobody home")
var after models.Consignment
must(t, gdb.First(&after, 7107).Error)
if after.Status != constants.ConsignmentOutForDelivery {
t.Errorf("an automatic return ran for a Food client: status is now %s", after.Status)
}
}
// And it still runs for a client who does use returns, so the guard has not
// simply turned the feature off.
func TestAutomaticReturnStillRunsForAClothingClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Gandhipuram Garments", "Clothing", true)
cn := seedParcel(t, gdb, 7108, constants.ConsignmentOutForDelivery)
cn.Attemptcount = 99
autoRTOAfterSkip(cn, rtoRider, "nobody home")
var after models.Consignment
must(t, gdb.First(&after, 7108).Error)
if after.Status == constants.ConsignmentOutForDelivery {
t.Error("the automatic return did not run for a client who does use reverse logistics")
}
}

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

@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
@@ -21,16 +22,10 @@ var validZones = map[string]bool{
"National": true,
}
var validCategories = map[string]bool{
"General": true,
"Documents": true,
"Electronics": true,
"Clothing": true,
"Fragile": true,
"Medical": true,
"Automotive": true,
"Food": true,
}
// The category vocabulary moved to constants.DeliveryCategories when tenants
// gained a category of their own: a tenant whose category is not a pricing
// category cannot be priced, so the two must be the same list.
var validCategories = constants.DeliveryCategories
var validServiceTypes = map[string]bool{
"Normal": true,

View File

@@ -0,0 +1,192 @@
package controllers
import (
"strconv"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm/clause"
)
// Demand forecast: stored by the engine, served with a staffing gap.
//
// ─── Why a gap and not just a number ──────────────────────────────────────
//
// docs/prediction-plan.md §2.4 is explicit: "Demand prediction with no consumer
// is a dashboard nobody opens." A number like "expect 42 pickups in 641 on
// Thursday" is not actionable on its own — nobody knows whether 42 is fine.
//
// So the read endpoint joins the forecast to the riders actually on duty in
// that zone and reports the GAP. That is the thing an operator can act on:
// "641 expects 42 and has 6 riders at an average 5 stops each — short by 12".
//
// It stops short of moving anyone. `rebalance_riders` is still review-only
// because no endpoint reassigns riders between zones, and inventing one here
// would be a product decision disguised as plumbing.
// POST /api/v1/internal/demand-forecast
//
// Written by AI_engine's forecast job. Upserts on (zone, forday): re-running
// the job replaces that day's number rather than accumulating versions, because
// the useful question is "what do we currently expect", and the previous
// estimate for a day already past is answered by it having been overwritten
// before the day arrived.
func UpsertDemandForecast(c *fiber.Ctx) error {
type item struct {
Zone string `json:"zone"`
Forday string `json:"forday"` // YYYY-MM-DD
Expectedbookings int `json:"expectedbookings"`
Model string `json:"model"`
Reason string `json:"reason"`
Observations int `json:"observations"`
Baselinemae *float64 `json:"baselinemae"`
Modelmae *float64 `json:"modelmae"`
}
var body struct {
Forecasts []item `json:"forecasts"`
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if len(body.Forecasts) == 0 {
return utils.BadRequest(c, "forecasts is required")
}
now := utils.DBNow()
rows := make([]models.DemandForecast, 0, len(body.Forecasts))
for _, f := range body.Forecasts {
if f.Zone == "" || f.Model == "" {
continue
}
day, err := time.Parse("2006-01-02", f.Forday)
if err != nil {
continue
}
if f.Expectedbookings < 0 {
// A negative count is a model artefact, not a forecast. The engine
// clamps already; this is the second line of defence.
f.Expectedbookings = 0
}
rows = append(rows, models.DemandForecast{
Zone: f.Zone,
Forday: day,
Expectedbookings: f.Expectedbookings,
Model: f.Model,
Reason: f.Reason,
Observations: f.Observations,
Baselinemae: f.Baselinemae,
Modelmae: f.Modelmae,
Generatedat: now,
})
}
if len(rows) == 0 {
return utils.BadRequest(c, "no usable forecast rows")
}
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "zone"}, {Name: "forday"}},
DoUpdates: clause.AssignmentColumns([]string{
"expectedbookings", "model", "reason", "observations",
"baselinemae", "modelmae", "generatedat",
}),
}).CreateInBatches(&rows, 200).Error; err != nil {
utils.Error("UpsertDemandForecast: write failed", "error", err)
return utils.Internal(c, "failed to store the forecast")
}
return utils.OK(c, fiber.Map{"stored": len(rows)})
}
// GET /api/v1/admin/forecast/demand?days=7
//
// Tomorrow onward, per zone, with the staffing gap. Doormile staff only, like
// the rest of the /admin/ai surface it sits beside.
func GetDemandForecast(c *fiber.Ctx) error {
days, err := strconv.Atoi(c.Query("days", "7"))
if err != nil || days < 1 || days > 30 {
days = 7
}
// From today, in the database's own day. utils.DBToday rather than
// time.Now(): the column holds IST wall-clock digits, so a UTC-derived
// boundary would drop today's row for five and a half hours each evening.
from := utils.DBToday()
to := from.AddDate(0, 0, days)
type row struct {
Zone string `json:"zone"`
Forday time.Time `json:"forday"`
Expectedbookings int `json:"expectedbookings"`
Model string `json:"model"`
Reason string `json:"reason"`
Observations int `json:"observations"`
Baselinemae *float64 `json:"baselinemae"`
Modelmae *float64 `json:"modelmae"`
Generatedat time.Time `json:"generatedat"`
// Ridersonduty is the live count for that zone — the same
// availabilitystatus set the batch-assign solver treats as working, so
// the gap is measured against the riders that could actually take work.
Ridersonduty int `json:"ridersonduty"`
// Capacity is riders × the per-rider cap the batch solver uses, so the
// gap is in the same unit the assignment path thinks in.
Capacity int `json:"capacity"`
Gap int `json:"gap"`
}
var rows []row
// ORDER BY gap, not f.gap. "gap" is a computed alias in the SELECT list,
// not a column of f, and qualifying it with the table alias is an error
// Postgres raises at execution time — so this endpoint 500s on every call
// rather than failing to compile. Caught by running the query, not by go vet.
//
// (And the explanation lives here rather than as a -- comment inside the
// query: the SQL is a backtick-delimited raw string, so a backtick in a
// comment terminates it. That mistake cost a build.)
// Riders are joined by the hub's pincode prefix, because a rider belongs to
// a hub and a zone IS a pincode prefix. Zones with a forecast and no hub
// come back with zero riders rather than being dropped — "we expect 40 and
// have nobody" is the single most important row this endpoint can return,
// and an inner join would hide it.
if err := db.DB.Raw(`
SELECT f.zone, f.forday, f.expectedbookings, f.model, f.reason,
f.observations, f.baselinemae, f.modelmae, f.generatedat,
COALESCE(r.on_duty, 0) AS ridersonduty,
COALESCE(r.on_duty, 0) * ? AS capacity,
f.expectedbookings - COALESCE(r.on_duty, 0) * ? AS gap
FROM demandforecast f
LEFT JOIN (
SELECT left(h.pincode, 3) AS zone, count(*) AS on_duty
FROM milerprofiles mp
JOIN hubs h ON h.hubid = mp.hubid
WHERE mp.availabilitystatus IN ?
GROUP BY 1
) r ON r.zone = f.zone
WHERE f.forday >= ? AND f.forday < ?
ORDER BY f.forday ASC, gap DESC`,
defaultBatchAssignCapPerRider, defaultBatchAssignCapPerRider,
constants.MilerWorkingStatuses, from, to,
).Scan(&rows).Error; err != nil {
utils.Error("GetDemandForecast: query failed", "error", err)
return utils.Internal(c, "failed to read the forecast")
}
// Said explicitly rather than left for the reader to infer from an empty
// array: no forecast and a broken forecast look identical otherwise.
note := ""
if len(rows) == 0 {
note = "No forecast has been generated for this window. The engine's forecast job writes it; check that AI_engine is running and has enough delivery history."
}
return utils.OK(c, fiber.Map{
"days": days,
"from": from,
"capperrider": defaultBatchAssignCapPerRider,
"zones": rows,
"note": note,
})
}

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

@@ -14,7 +14,6 @@ import (
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/legs"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -1972,106 +1971,33 @@ func HubBatchAssign(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
capPerRider := req.MaxPerRider
if capPerRider <= 0 {
capPerRider = defaultBatchAssignCapPerRider
}
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
if len(req.Bookingids) > 0 {
bookingQuery = bookingQuery.Where("bookingid IN ?", req.Bookingids)
} else if prefix != "" {
bookingQuery = bookingQuery.Where("pickuppincode LIKE ?", prefix+"%")
// The solver itself now lives in batchAssignService.go, shared with
// AdminBatchAssign. Only the scope differs: this route's riders are the
// ones on duty AT THIS HUB, and with no explicit bookingids it clears this
// hub's own pincode-prefixed queue.
result, err := RunBatchAssign(req.Bookingids, req.MaxPerRider, batchRiderScope{
ActorID: staffID,
Bookings: func(q *gorm.DB) *gorm.DB {
if len(req.Bookingids) == 0 && prefix != "" {
q = q.Where("pickuppincode LIKE ?", prefix+"%")
}
bookingQuery = scopeBookingsToOwnTenant(c, bookingQuery)
var bookings []models.PickupBooking
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch pending bookings")
}
if len(bookings) == 0 {
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
}
var riderProfiles []models.MilerProfile
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
Find(&riderProfiles).Error; err != nil {
return utils.Internal(c, "failed to fetch available riders")
}
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
for _, mp := range riderProfiles {
candidates = append(candidates, &batchRiderCandidate{
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
return scopeBookingsToOwnTenant(c, q)
},
Riders: func(q *gorm.DB) *gorm.DB {
return q.Where("hubid = ?", hubID)
},
})
}
results := make([]fiber.Map, 0, len(bookings))
assignedCount, skippedCount := 0, 0
for _, b := range bookings {
var nearest *batchRiderCandidate
nearestDist := math.MaxFloat64
for _, cand := range candidates {
if cand.assigned >= capPerRider {
continue
}
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
if d < nearestDist {
nearestDist = d
nearest = cand
}
}
if nearest == nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": "no available rider under capacity",
})
skippedCount++
continue
}
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &staffID); err != nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": err.Error(),
})
skippedCount++
continue
}
nearest.assigned++
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
})
assignedCount++
}
// Batch assignment is exactly the case stop-ordering exists for: a rider
// walks out of here with several bookings and, until now, no indication of
// what order to run them in. Sequence each rider that actually got work.
//
// Best-effort and deliberately after the assignments are committed: the
// optimizer is a separate service over the network, and it failing must
// leave the bookings assigned rather than undoing the batch.
sequenced := 0
for _, r := range ridersAssigned(results) {
if _, err := routing.SequenceMilerStops(r); err != nil {
utils.Warn("HubBatchAssign: stop sequencing failed",
"miler_userid", r, "error", err)
continue
}
sequenced++
if err != nil {
utils.Error("HubBatchAssign: failed", "error", err)
return utils.Internal(c, "failed to assign bookings")
}
return utils.OK(c, fiber.Map{
"assigned": assignedCount,
"skipped": skippedCount,
"riderssequenced": sequenced,
"results": results,
"assigned": result.Assigned,
"skipped": result.Skipped,
"riderssequenced": result.Riderssequenced,
"results": result.Results,
})
}

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.

135
controllers/milerAccount.go Normal file
View File

@@ -0,0 +1,135 @@
package controllers
import (
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Rider (miler) accounts: the rules for creating, editing, blocking and
// signing in that the console and the rider app both depend on.
//
// Before this file, CreateMiler checked nothing: two riders could share a phone
// number (login then picked the oldest row, often a blocked or old account, and
// answered "miler account is not active"), a duplicate email surfaced as a
// bare 500, a Coimbatore rider could be attached to a Hyderabad hub, and a
// blocked rider who was still signed in could start duty and put themselves
// back to Available.
// milerVehicleTypes are the vehicle types the console offers; matched without
// regard to case and stored in this spelling.
var milerVehicleTypes = []string{"Bike", "Scooter", "Bicycle", "Car", "Van"}
func canonicalVehicleType(v string) (string, bool) {
v = strings.TrimSpace(v)
if v == "" {
return "Bike", true
}
for _, t := range milerVehicleTypes {
if strings.EqualFold(t, v) {
return t, true
}
}
return "", false
}
// milerLoginLookup finds the rider account for a phone number. Several rows
// can share a number (riders created before duplicates were refused). The
// order is: an Active miler that already has a PIN (the account the rider
// actually uses), then an Active miler without one, then any miler row, then
// anything else; oldest first within each, which is what the plain lookup
// used to return. Ranking a PIN-less duplicate first would answer "incorrect
// PIN" to a rider who signed in yesterday, and let set-pin claim the duplicate.
func milerLoginLookup(phone string, configID int) *gorm.DB {
return db.DB.Where("contactno = ? AND configid = ?", normalisePhone(phone), configID).
Order(`CASE WHEN roleid = 5 AND status = 'Active' AND COALESCE(password, '') <> '' THEN 0
WHEN roleid = 5 AND status = 'Active' THEN 1
WHEN roleid = 5 THEN 2 ELSE 3 END, userid ASC`)
}
// milerNotActiveMessage is what a rider is told when their account cannot sign
// in; a blocked rider is told so, rather than a generic "not active".
func milerNotActiveMessage(status string) string {
if strings.EqualFold(status, constants.MilerBlocked) {
return "your account is blocked — contact your manager"
}
return "miler account is not active"
}
// milerIsBlocked reports whether ops have blocked this rider. Checked on the
// rider-app actions that would otherwise undo a block for a rider who was
// already signed in when it happened (starting duty, setting availability).
func milerIsBlocked(milerUserID int) bool {
var p models.MilerProfile
if db.DB.Select("availabilitystatus").Where("userid = ?", milerUserID).First(&p).Error == nil &&
strings.EqualFold(p.Availabilitystatus, constants.MilerBlocked) {
return true
}
var u models.AppUser
return db.DB.Select("status").Where("userid = ?", milerUserID).First(&u).Error == nil &&
strings.EqualFold(u.Status, constants.MilerBlocked)
}
// milerPhoneTaken reports whether another rider already signs in with this
// number (exceptUserID is the rider being edited, 0 when creating).
func milerPhoneTaken(phone string, configID, exceptUserID int) bool {
var n int64
db.DB.Model(&models.AppUser{}).
Where("contactno = ? AND configid = ? AND roleid = 5 AND userid <> ?", phone, configID, exceptUserID).
Count(&n)
return n > 0
}
// checkMilerHub makes sure a hub exists and sits in the rider's city. A nil hub
// (no base) is always fine.
func checkMilerHub(hubID *int, cityID int) string {
if hubID == nil {
return ""
}
var hub models.Hub
if err := db.DB.Select("hubid", "applocationid").Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil {
return "that hub does not exist"
}
if hub.Applocationid != cityID {
return "that hub is in a different city from the rider — choose a hub in the rider's city"
}
return ""
}
// UnblockMiler — PUT /admin/milers/:id/unblock
// The counterpart of BlockMiler: the rider can sign in again and is Offline
// until they start duty. Same scoping as block (a client login, its own riders).
func UnblockMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
profile, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
if !milerIsBlocked(profile.Userid) {
return utils.BadRequest(c, "this miler is not blocked")
}
now := time.Now()
err := db.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.MilerProfile{}).Where("milerprofileid = ?", profile.Milerprofileid).
Updates(map[string]interface{}{"availabilitystatus": constants.MilerOffline, "updatedat": now}).Error; err != nil {
return err
}
return tx.Model(&models.AppUser{}).Where("userid = ? AND status = ?", profile.Userid, constants.MilerBlocked).
Update("status", "Active").Error
})
if err != nil {
return utils.Internal(c, "failed to unblock miler")
}
profile.Availabilitystatus = constants.MilerOffline
profile.Updatedat = now
return utils.OK(c, profile)
}

View File

@@ -1,6 +1,7 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"sort"
@@ -10,6 +11,8 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/milergeo"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
@@ -32,11 +35,17 @@ func MilerStartDuty(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
// A rider still signed in when ops blocked them used to come straight back
// to Available here.
if milerIsBlocked(milerUserID) {
return utils.Forbidden(c, milerNotActiveMessage(constants.MilerBlocked))
}
midnight := todayMidnight()
var existing models.MilerDutyLog
if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight).
First(&existing).Error; err == nil {
return utils.BadRequest(c, "already on duty, end current duty first")
return resumeMilerDuty(c, milerUserID, existing, req.Lat, req.Lon)
}
now := time.Now()
@@ -61,12 +70,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)
@@ -104,6 +155,29 @@ func MilerEndDuty(c *fiber.Ctx) error {
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerOffline)
db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 0)
// Take the rider out of the live-position set.
//
// A GEO member never expires, and nothing used to remove one — so
// milers:locations kept every rider who had ever started a shift, frozen
// where they last reported. Assignment asks for the TEN NEAREST members,
// and riders who finished weeks ago parked near the hub are the closest
// members there are: they filled all ten slots, every one was then
// discarded by the GPS-freshness check, and the candidate pool collapsed
// to whoever survived — often one person, who then received every order in
// the city. The balancing below that is correct and powerless; it can only
// balance across the pool it is handed.
//
// Best-effort: the rider IS off duty either way, and the freshness check
// still excludes them. This stops them crowding out riders who are on.
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := milergeo.Remove(ctx, db.Rdb, milerUserID); err != nil {
utils.Warn("MilerEndDuty: could not remove the rider from the live-position set",
"miler_userid", milerUserID, "error", err)
}
cancel()
}
return utils.OK(c, fiber.Map{
"dutylogid": dutyLog.Dutylogid,
"logoutat": dutyLog.Logoutat,
@@ -194,7 +268,9 @@ func MilerEndBreak(c *fiber.Ctx) error {
return utils.Internal(c, "failed to end break")
}
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAvailable)
// Ending a break never lifts a block ops applied meanwhile.
db.DB.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked).
Update("availabilitystatus", constants.MilerAvailable)
return utils.OK(c, fiber.Map{
"breaklogid": breakLog.Breaklogid,
@@ -663,6 +739,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 +1082,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 {
// 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")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
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 +1141,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"
@@ -43,7 +44,7 @@ func LoginMiler(cfg *config.Config) fiber.Handler {
}
var user models.AppUser
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil {
return utils.NotFound(c, "no miler account found for this phone number")
}
@@ -52,7 +53,7 @@ func LoginMiler(cfg *config.Config) fiber.Handler {
}
if user.Status != "Active" {
return utils.Forbidden(c, "miler account is not active")
return utils.Forbidden(c, milerNotActiveMessage(user.Status))
}
// pin_set tells the app which screen to show next: true → enter-PIN
@@ -101,7 +102,7 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
}
var user models.AppUser
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil {
return utils.NotFound(c, "no miler account found for this phone number")
}
@@ -110,7 +111,7 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
}
if user.Status != "Active" {
return utils.Forbidden(c, "miler account is not active")
return utils.Forbidden(c, milerNotActiveMessage(user.Status))
}
if !utils.CheckPasswordHash(req.Pin, user.Password) {
@@ -191,7 +192,7 @@ func ResetMilerPin(c *fiber.Ctx) error {
}
var user models.AppUser
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil {
return utils.NotFound(c, "no miler account found for this phone number")
}
@@ -237,14 +238,14 @@ func SetMilerPin(cfg *config.Config) fiber.Handler {
}
var user models.AppUser
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil {
return utils.NotFound(c, "no miler account found for this phone number")
}
if user.Roleid != 5 {
return utils.Forbidden(c, "this endpoint is restricted to miler accounts")
}
if user.Status != "Active" {
return utils.Forbidden(c, "miler account is not active")
return utils.Forbidden(c, milerNotActiveMessage(user.Status))
}
if user.Password != "" {
return utils.Conflict(c, "a PIN is already set for this account; use verify-pin to log in")
@@ -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,
@@ -414,6 +428,14 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
if status == "" {
return utils.BadRequest(c, "status is required")
}
// Blocking is an ops decision: a rider can neither lift it (Available
// from a session that was open when the block happened) nor set it.
if strings.EqualFold(status, constants.MilerBlocked) {
return utils.BadRequest(c, "riders cannot set this status")
}
if milerIsBlocked(milerUserID) {
return utils.Forbidden(c, milerNotActiveMessage(constants.MilerBlocked))
}
var profile models.MilerProfile
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
@@ -688,7 +710,7 @@ func RejectMilerAssignment(c *fiber.Ctx) error {
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
if err := tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
@@ -768,7 +790,7 @@ func MilerCancelAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to release booking")
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
if err := tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
@@ -1512,7 +1534,7 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to close assignment")
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
if err := tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked).
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")

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`

736
docs/prediction-plan.md Normal file
View File

@@ -0,0 +1,736 @@
# Doormile — ETA & Demand Prediction: Implementation Plan
Status: **rungs 1.0–1.1 and Phase 2 built and VERIFIED against a real Postgres; not committed, not deployed**
Written 2026-10-08 · Reviewed 2026-10-08 · Implemented and verified 2026-10-09
Scope: `doormile_backend`, `AI_engine`, `kubernetes`
Related: `krow_talent_app/docs/agent-platform-phase7-plan.md` — Track A1 is a hard
prerequisite for rung 1.1 and Track C1 for Phase 2.3 (see §9)
> **Review pass (2026-10-08).** Three corrections to the first draft, all from
> reading the code rather than reasoning about it:
> 1. **The Phase 1.0 SQL was wrong** — it joined `so.bookingid = c.bookingid`,
> and `consignments` has no `bookingid` column. Fixed via the
> `consignment_booking` view; see hazard **H4**.
> 2. **Routed durations are already captured** — `bookingassignments.etaminutes`
> / `cumulativeeta` / `previouskms` / `cumulativekms` / `sequencedat` exist and
> are written by `internal/routing`. Rung 1.1 needs no new capture plumbing.
> 3. **But they are almost certainly all zeros in production** — sequencing is
> gated on `ROUTE_OPTIMIZER_URL` (absent from the k8s manifest) *and* on a
> rider having ≥2 stops. Rung 1.1 therefore starts with Track A1 and a
> data-accumulation wait. See §3 rung 1.1.
---
## 0c. Verification log (2026-10-09) — what was actually RUN
Everything below was executed, not reasoned about. Postgres 16 + pgvector 0.8.7
in Docker, the real `migrations.Migrate()`, the real Go server over HTTP.
### The migration
Clean on a fresh schema, zero errors. Objects confirmed present:
`consignment_booking` view · `etacalibration` · `aiskillfindings` ·
`demandforecast` · `agent_decisions.tenantid` · `context_embedding` as a vector
type · the ivfflat index · `idx_consignmenthistory_status_consignment`.
### Every SQL statement, against REAL DATA
300 bookings over 60 days across 3 zones, 300 consignments, 277 Delivered
events, 300 assignments carrying `etaminutes`, 25 resolved decisions with
1536-dim embeddings.
| Statement | Result |
|---|---|
| `refreshSQL` (calibration) | **87 cells**, p80 factors **3.11 / 3.20** — inside the 0.5–5.0 bounds, so accepted and used |
| `historicalSQL` (backfill) | **100 rows** |
| `refineSQL` (ETA refine) | **23 rows** — exactly 300/13, matching the seeded `Out_for_Delivery` count. The filter is correct, not merely valid |
| `pendingSQL` (outcomes) | 7 pending, delivery + SLA correctly joined |
| `consignment_booking` | 300 of 300 resolvable |
| similarity search | real neighbours with cosine distances, tenant-scoped |
| demand series (`job.py`) | runs |
| both prune deletes | run |
An empty table proves syntax. These numbers prove the joins and filters.
### Every endpoint, over real HTTP
`POST /internal/demand-forecast` → 2 stored · `GET /admin/ai/forecast/demand`
→ zone 641 expects 42, 6 riders × 5 = 30, **gap 12**; zone 500 with no hub
still appears (the LEFT JOIN decision, proven) · findings upsert → written 2,
re-POST upserts rather than duplicates, 1 cleared · `/acted` → recorded
`partial` · findings stats → `clearedunacted: 1`, `stillopen: 1` ·
`POST /internal/agent-decisions` with a 1536-dim embedding → stored ·
`POST /admin/bookings/batch-assign` → 4 assigned, `max_per_rider: 2` respected
exactly.
### The index hazard — measured, not estimated
`consignmenthistory` grown to **1,000,277 rows / 69 MB**:
`CREATE INDEX CONCURRENTLY` completed in **1.3 seconds**, index **valid**, and
**all five concurrent INSERTs succeeded during the build**. No write blocking.
Count query at 1M rows: 40ms.
### Bugs this verification found
Three, none visible to `go build`, `go vet`, or the test suite:
1. **`bd.destinationid` does not exist** (it is `bookingdestinationid`). The
`consignment_booking` view was never created — and `Migrate()` logs that
non-fatally, so it printed "migration completed successfully" while every
query joining through the view would have failed at runtime.
2. **`ORDER BY f.gap`** — `gap` is a computed alias, and qualifying it is a
runtime error. `GET /admin/ai/forecast/demand` would have 500'd on every
call.
3. **`POST /admin/ai/findings/{fingerprint}/acted` matched nothing.** Fiber
returns the raw percent-encoded path param and the console sends
`encodeURIComponent`, so `:` and `,` arrived as `%3A`/`%2C`. Because the
console treats that call as fire-and-forget it would have failed **silently
forever**, leaving the "did acting clear it" measurement permanently empty.
`cmd/migratecheck` exists so this gap does not recur: the test suite never
calls `Migrate()`, and `Migrate()` returns OK on a logged failure — so its
OUTPUT must be read, not its exit code.
### Still not verified
- The **Prophet path has never executed** — the library is not installed.
- Rung 1.2 is not built (correctly gated on 1.0/1.1 having run).
- `AI_engine`'s agents have not run against real NATS + Postgres.
- Phase 0 and rung 1.0 have **not been run against production data**, so
whether any of this is worth having is still unanswered.
## 0b. Phase 2 — demand forecasting, built 2026-10-08
`AI_engine/prediction/`, 23 tests passing:
| File | |
|---|---|
| `series.py` | the dense daily series and `seasonal_naive`, the baseline every model must beat. Pure stdlib, so it works in an image without the forecasting extras |
| `backtest.py` | rolling-origin validation. A tie goes to the baseline |
| `demand_model.py` | Prophet, used **only** when it beats the baseline on that zone's own history |
| `holidays_in.py` | regional calendar — Pongal and Onam carry as much signal here as Diwali |
| `requirements-forecast.txt` | prophet/pandas/numpy, deliberately NOT in `requirements.txt` |
**The design decision worth keeping:** `forecast()` backtests Prophet against
`seasonal_naive` per zone and uses it only if it wins. Prophet is better on some
series and worse on others, and which is which is a property of the data, not of
the library. A zone where the baseline wins gets the baseline, and the returned
`reason` says so, so the choice is auditable rather than implicit.
**Still missing its consumer.** §2.4 of this plan says demand forecasting with
no consumer is a dashboard nobody opens, and that remains true: `rebalance_riders`
still has no executor, and no backend endpoint serves the forecast. The module
is correct and unconsumed.
**A test caught a real fixture bug worth recording:** the first synthetic series
was perfectly periodic, which makes `seasonal_naive` exact (MAE 0) — so nothing
could beat it and two tests failed for that reason rather than any defect. Real
demand is never exactly periodic; the fixture now carries deterministic noise.
## 0a. What was built (2026-10-08)
Rung 1.1's estimator and calibration, plus the Phase 0 queries. `go build ./...`,
`go vet ./...` clean; `go test ./...` 19 packages pass, 0 failures. Uncommitted,
not deployed, and **nothing has been run against a database**.
| File | |
|---|---|
| `internal/prediction/eta.go` | `ETAMinutes` / `ETAAt` — the floor rule and the zone→weekday→global fallback ladder |
| `internal/prediction/calibration.go` | the p80 refresh query, the store, and the in-memory snapshot |
| `internal/prediction/sweeper.go` | 6h ticker + Redis lock, same shape as `internal/assignment/sweeper.go` |
| `internal/prediction/eta_test.go` | 17 tests, all passing |
| `migrations/migrate.go` | `consignment_booking` view (H4), `etacalibration` table, two indexes |
| `main.go` | `go prediction.StartCalibrationSweeper()` |
| `scratch/prediction_readiness.sql` | Phase 0 + rung 1.0, read-only |
**Three bugs were found and fixed while building, two of them mine:**
1. **The refresh query multiplied every delivery by its assignment history.** A
booking holds several `bookingassignments` rows (Assigned, Rejected,
Reassigned), so a plain join overstated sample counts and pulled the p80
toward whatever got reassigned most. Fixed with `DISTINCT ON (bookingid)`
taking the most recently sequenced row. The same bug was in readiness query
Q10; fixed there too so the check predicts production behaviour.
2. **Hazard H1, in this package's own code.** `Refreshedat` is written through
`utils.DBNow` (IST digits labelled UTC), so reading it as a raw instant makes
a calibration look 5h30m *newer* than it is — a 73h-old table measures as
67.5h and slips under the 72h staleness bound. `Load` now corrects through
`utils.IST`. Confirmed by mutation: removing the call fails
`TestStaleCalibrationFallsBack`.
3. **Untyped `NULL` in a `UNION ALL`.** Postgres resolves column types across
branches and can infer an untyped NULL as text, clashing with the integer
from the first branch — a failure that only appears at runtime against the
real database. Now `NULL::int` explicitly.
**Not done, deliberately — see §3a for why:** the two existing ETA call sites
(`adminController.go:2738`, `cxPickupFanout.go:224`) are untouched. Wiring them
would have been dead code, and the real integration point needs a product
decision.
## 0. Confirmed not implemented
Verified by grep across `AI_engine`, `doormile_backend`, `krow_talent_app/src`:
| | State |
|---|---|
| LSTM | not present |
| ARIMA / SARIMA / Prophet | not present |
| Time-series prediction | not present |
| ETA prediction (learned) | not present |
| Demand prediction | not present |
No ML dependency exists anywhere — no `torch`, `tensorflow`, `sklearn`,
`statsmodels`, `xgboost`, `prophet`, not even `numpy`/`pandas` in
`AI_engine/requirements.txt`. Zero source matches for `lstm`, `arima`,
`forecast`, `time series`.
**What does exist, and is the baseline any model must beat:**
- **Express/admin ETA** — flat constants, `controllers/adminController.go:2740`:
Standard 36h SLA, Fast 12h ETA / 18h SLA, Superfast 6h / 9h. No data input.
- **Customer-app ETA** — district promise lookup, `controllers/cxPickupFanout.go:224`:
`ServiceableDistrict.promise` → 0/1/2/3 days, delivered-by-8pm.
- **`consignments.estimateddeliveryat` and `sladueat`** columns exist and are
populated from those two rules.
- **Demand** — exists only as text describing absent features.
`internal/ai/registry/seed.go:295`: *"Move idle riders into zones with a
demand spike. Disabled until an endpoint implements it."* (`rebalance_riders`,
`Target: "none yet"`).
---
## 1. The framing: these are two different problems
The question "LSTM or ARIMA" assumes one model family covers both. It does not,
and picking the wrong family is the most expensive mistake available here.
| | ETA prediction | Demand prediction |
|---|---|---|
| **Problem type** | supervised **regression** — one row per trip | **time series** — counts per zone per interval |
| **Input** | distance, hour, zone, rider, weight, attempt count | history of its own past values |
| **Right family** | gradient boosting / quantile regression | SARIMA or Prophet |
| **ARIMA fit?** | **no** — there is no series, each trip is independent | yes |
| **LSTM fit?** | no — tabular, boosting wins | only at volume we almost certainly don't have |
**ETA is not a time-series problem.** A delivery's duration depends on its own
features, not on the duration of the delivery before it. Fitting ARIMA to trip
durations models an ordering that carries no signal. This is the single most
common mistake in logistics ML and it is worth stating plainly before any code.
**Demand is a time-series problem** — bookings per pincode per day is a real
series with weekly seasonality and holiday effects, which is exactly what SARIMA
and Prophet are for.
**LSTM is almost certainly wrong for both.** It needs tens of thousands of
sequences to beat SARIMA on a univariate count series, and it loses to gradient
boosting on tabular regression. Section 6 states the condition under which it
would become worth revisiting; until that condition is measured and met,
building it is cost without benefit.
---
## 2. Phase 0 — Data readiness gate (**this phase can return "don't build it yet"**)
Everything downstream depends on history that may not exist. CLAUDE.md §9 lists
go-live across the four cities as still ahead; if real delivery volume hasn't
accumulated, there is nothing to fit and Phase 0 is the whole project for now.
Run these before writing any model code.
### 2.1 Volume and span
```sql
-- Delivered consignments and how far back they go.
SELECT count(*) AS delivered,
min(createdat)::date AS first_day,
max(createdat)::date AS last_day,
count(DISTINCT createdat::date) AS distinct_days
FROM consignmenthistory
WHERE eventstatus = 'Delivered';
-- Per-pincode daily series length (demand needs this per series, not in total).
SELECT c.deliverypincode,
count(DISTINCT h.createdat::date) AS days_with_data,
count(*) AS deliveries
FROM consignmenthistory h
JOIN consignments c ON c.consignmentid = h.consignmentid
WHERE h.eventstatus = 'Delivered'
GROUP BY 1 ORDER BY 3 DESC LIMIT 30;
```
**Go/no-go thresholds:**
| | ETA regression | Demand SARIMA |
|---|---|---|
| Minimum usable | ~2,000 completed trips | ~90 days per series |
| Comfortable | ~10,000+ | ~1 year (two seasonal cycles) |
| Below minimum | keep the promise table | aggregate to city level, or wait |
If per-pincode series are too short, **aggregate up** — city-level demand on 90
days is forecastable where pincode-level on 90 days is noise.
### 2.2 The three data hazards (all verified in this codebase)
**H1 — Timestamps are IST wall-clock digits labelled UTC.**
`utils.DBNow` stores IST digits with a UTC tag. CLAUDE.md §8.5 is explicit:
`t.UnixMilli()` is off by 5h30m, and this already caused "yesterday's work shown
as today" on the miler app. `models/ai_runs.go:26` documents the same trap.
This matters more for prediction than anywhere else, because **hour-of-day is a
primary ETA feature and day-boundary is the demand bucket.** Fitted on raw
`createdat`, hour-of-day is right by accident and the daily bucket is wrong for
every event between 18:30 and 00:00 IST.
Use `utils.EpochMillis` (`utils/epoch.go`) as the single conversion, exactly as
the customer surface does. Write one `trip_features` SQL view that does the
correction once, and let every model read the view — never raw columns.
**H2 — There is no `deliveredat` column.**
`consignments` has `createdat`, `inwardedat`, `estimateddeliveryat`, `sladueat`,
`returninitiatedat`, `returndeliveredat` — but **no delivery completion
timestamp**. Status reaches `Delivered`; the time only exists as
`consignmenthistory.createdat WHERE eventstatus = 'Delivered'`.
So the training label is derived, not stored. Verify it is reliably written
before trusting it:
```sql
SELECT count(*) FILTER (WHERE h.consignmentid IS NULL) AS delivered_without_event
FROM consignments c
LEFT JOIN consignmenthistory h
ON h.consignmentid = c.consignmentid AND h.eventstatus = 'Delivered'
WHERE c.status = 'Delivered';
```
Non-zero means silent label loss — fix the write path before fitting anything.
**H4 — `consignments` has no `bookingid`. Every join through it is a two-path
resolution.** This is the trap CLAUDE.md §8.5 warns about, and the first draft of
this document walked straight into it.
The link is `bookingdestinations.consignmentid → bookingdestinations.bookingid`
for multi-destination pickups, falling back to `pickupbookings.consignmentid` for
console/express bookings and any row written before the fan-out existed — and
that legacy column **names only the FIRST order** of a multi-destination pickup.
`cxDestinationForConsignment` (`controllers/cxPickupFanout.go:191`) is the
canonical resolver in Go.
Get this wrong and the service-type and promise features silently attach to the
wrong parcel for every multi-destination booking. Resolve it **once**, in the
view, and never join through `consignments` directly:
```sql
CREATE OR REPLACE VIEW consignment_booking AS
SELECT c.consignmentid,
COALESCE(bd.bookingid, pb.bookingid) AS bookingid,
bd.bookingdestinationid
FROM consignments c
LEFT JOIN bookingdestinations bd ON bd.consignmentid = c.consignmentid
LEFT JOIN pickupbookings pb ON pb.consignmentid = c.consignmentid
AND bd.bookingid IS NULL;
```
Verify the resolution covers everything before trusting it:
```sql
SELECT count(*) AS unresolvable
FROM consignment_booking WHERE bookingid IS NULL;
```
**H3 — Fabricated timestamps are a known pattern in this estate.**
DailyGrubs' order data has invented delivery timestamps and a double-labelled
timezone. Doormile is a different database, but the same team and the same
`DBNow` convention. Spot-check that delivery times aren't clustered on
suspiciously round values or identical offsets from `createdat` before fitting.
**A model trained on fabricated timestamps predicts confidently and wrongly** —
and unlike a broken query, nothing surfaces the error.
### 2.3 Deliverable
A one-page readiness note: row counts, series lengths, H1/H2/H3 findings, and a
go/no-go per track. If it says no-go, stop here — Phase 1.0 (below) still pays
for itself and needs no history.
---
## 3. Phase 1 — ETA
A ladder. Each rung ships, is measured against the one below, and is only
climbed if the measurement justifies it.
### 1.0 — Measure the current promise table (**no ML, do this regardless**)
Before predicting anything, find out how wrong the constants already are:
```sql
-- Promise vs actual, per service type.
-- Joins through consignment_booking (H4) — NEVER c.bookingid, which does not exist.
SELECT so.servicetype,
count(*) AS n,
avg(EXTRACT(EPOCH FROM (h.createdat - c.createdat))/3600) AS actual_hours_avg,
avg(EXTRACT(EPOCH FROM (c.estimateddeliveryat - c.createdat))/3600) AS promised_hours_avg,
count(*) FILTER (WHERE h.createdat > c.sladueat) AS sla_breaches
FROM consignments c
JOIN consignmenthistory h ON h.consignmentid = c.consignmentid AND h.eventstatus = 'Delivered'
JOIN consignment_booking cb ON cb.consignmentid = c.consignmentid
LEFT JOIN bookingserviceoptions so ON so.bookingid = cb.bookingid
GROUP BY 1;
```
Table and column names verified against the models: `bookingserviceoptions`
(`models/booking.go:198`), `serviceabledistricts` (`models/customer_app.go:60`),
`consignmenthistory` (`models/audit.go:109`).
Two outcomes, both useful. If the constants are close, **there is no ETA problem
to solve** and the honest answer is to stop. If they are badly off, this query
gives the baseline error that every later rung must beat — and it may be fixable
by re-tuning the constants per service type and district, which is an afternoon's
work rather than a model.
### 1.1 — Routing-based ETA (**the rung most likely to be the right stopping point**)
**Better news than the first draft assumed, with a catch.** `internal/routing`
is a client for a real Valhalla-backed road-network API
(`routes.workolik.com /api/v1/optimization/doormile/sequence`), and its response
already carries durations, not just an ordering: `etaminutes`, `cumulativeeta`,
`previouskms`, `cumulativekms`, `totaleta` (`internal/routing/optimizer.go:81-95`).
And **those values are already persisted**, on `bookingassignments`
(`models/booking.go:239-244`): `step`, `previouskms`, `cumulativekms`,
`etaminutes`, `cumulativeeta`, `sequencedat`. So a predicted-vs-actual dataset —
routed ETA at assignment time paired with the delivery event — is structurally
already being collected. No new capture plumbing is needed.
**The catch, and it is a real one: those columns are almost certainly all zeros
in production.** Two independent gates:
1. **`routing.BaseURL` empty disables sequencing entirely** (deliberately —
`optimizer.go:26`). It is set from `cfg.RouteOptimizerURL` at `main.go:246`,
and `ROUTE_OPTIMIZER_URL` is one of the 19 env vars missing from
`kubernetes/manifests/doormile/miletruth.yaml` — see Track A1 of the Phase 7
plan. CLAUDE.md §8.5 says the same thing from the other side: *"Sequencing
… is not deployed yet, so riders with more than one stop come back
unsequenced (step: 0)."*
2. **`minStopsToSequence = 2`** (`optimizer.go:44`) — a rider with one stop is
never sequenced. In a courier operation a large share of assignments may be
single-stop, so even with routing switched on, routed ETAs cover only
multi-stop riders.
**Two consequences for the plan:**
- **Rung 1.1 has a hard prerequisite: Phase 7 Track A1.** Until
`ROUTE_OPTIMIZER_URL` is in the manifest, there is no routed duration to
calibrate, and `etaminutes` stays 0. Verify before building:
```sql
SELECT count(*) AS assignments,
count(*) FILTER (WHERE etaminutes > 0) AS with_routed_eta,
count(*) FILTER (WHERE step > 0) AS sequenced,
min(sequencedat), max(sequencedat)
FROM bookingassignments;
```
`with_routed_eta = 0` means this rung starts by turning routing on and waiting
for data, not by fitting a calibration.
- **Single-stop assignments need their own duration source.** Haversine ×
a learned road-circuity factor per zone is the pragmatic fallback —
`haversineKM` already exists once, in `hubController.go` (do not redefine it;
CLAUDE.md §7). Alternatively call the routing API for single stops too, which
is a change to `minStopsToSequence`'s rationale and should be decided
explicitly rather than assumed.
With a routed duration in hand, the calibration is a grouped median over history
and not machine learning:
```
eta = routed_duration × calibration[zone, hour_bucket, weekday] + handling_time[hub]
```
One SQL query, refreshed nightly. Interpretable, debuggable, no training
infrastructure, no model server — and because `etaminutes` is already stored per
assignment, the calibration is fitted on the system's own past predictions
against its own actuals, which is the cleanest possible training signal.
**Only climb past this rung if 1.1 is measurably insufficient.** For a
single-city hub-based courier, it very often isn't.
### 3a. Where the calibrated ETA can actually be applied — **decision needed**
Found while implementing, and it changes the integration plan.
**At booking-create time there is no routed duration.** The sequence is: booking
created → `estimateddeliveryat` written from the promise table → rider assigned
→ `internal/routing` sequences and writes `etaminutes`. The routed number
arrives *after* the promise has been set and shown.
So wiring `prediction.ETAMinutes` into `adminController.go:2738` or
`cxPickupFanout.go:224` would return `false` on every call, forever — not
because the calibration is cold, but because `RoutedMinutes` is structurally 0
at that moment. That is dead code, so those call sites were left alone.
The routed ETA first exists at **sequencing time**, which means applying it is
revising a promise the customer has already been given. That is a product
decision, not a wiring one:
| | |
|---|---|
| **Option A — refine `estimateddeliveryat`, never touch `sladueat`** | The customer's "arrives by" sharpens as the system learns more; the commitment they were given does not move. **Recommended.** |
| Option B — leave both, expose the calibrated ETA only on tracking | Nothing stored changes; the sharper number is display-only. Safest, least useful. |
| Option C — revise both | The SLA stops being a commitment. Not recommended. |
Option A needs one call in `internal/routing` after the ETA columns are written,
plus a decision on whether `cxstage` should emit an event when a promise moves —
a customer watching the tracking page will see the time change, and silently
is probably the wrong way for that to happen.
**This is decision 7 in §8.** Nothing should be wired until it is made.
### 1.2 — Gradient-boosted regression (only if 1.1 is insufficient)
Features, all already in the schema:
| Feature | Source |
|---|---|
| haversine + routed distance | `pickuplatitude/longitude`, `deliverylatitude/longitude` |
| hour of day, weekday | `createdat` **via `EpochMillis`** (H1) |
| pickup / delivery pincode | `pickuppincode`, `deliverypincode` |
| origin / destination hub | `originhubid`, `destinationhubid` |
| chargeable weight | `chargeableweight` |
| service type | `bookingserviceoptions.servicetype` |
| attempt count | `consignments.attemptcount` |
| rider | `assignedmileruserid` (hash, not identity — see §8) |
| hub inbound load at assignment | derived from `consignmenthistory` |
**Predict a quantile, not a mean.** An ETA shown to a customer should be the p80
— "arrives by" — not the average, which is late half the time. Use quantile
regression or a boosted model with a quantile objective. This single choice
matters more to perceived accuracy than the model family.
Label: `delivered_event.createdat − consignment.createdat`, both corrected for H1.
### 1.3 — Attempt-aware ETA
`attemptcount` exists and `MilerSkipDelivery` increments it, so failed attempts
are recorded. An ETA that ignores re-attempts is wrong for exactly the parcels
customers complain about. Worth a separate model only once 1.2 is in production
and its residuals show re-attempts as the dominant error mode.
---
## 4. Phase 2 — Demand
### 2.1 — The series
```sql
CREATE OR REPLACE VIEW demand_daily AS
SELECT (createdat)::date AS day, -- H1 correction applied in the real view
pickuppincode,
count(*) AS bookings
FROM pickupbookings
WHERE status <> 'Cancelled'
GROUP BY 1, 2;
```
Decide the grain deliberately: pincode × day is what `rebalance_riders` wants,
but it is also the sparsest. Start at **city × day**, prove the pipeline, then
descend to zone only where series length supports it.
### 2.2 — Baseline first
Seasonal naïve — "same weekday last week" — is the baseline. It is one line of
SQL and it beats badly-configured SARIMA routinely. Any model that cannot beat it
on held-out data does not ship.
### 2.3 — SARIMA or Prophet
| | Choose when |
|---|---|
| **SARIMA** (`statsmodels`) | few series, weekly seasonality, want interpretable orders |
| **Prophet** | many series, holidays matter (Indian festival calendar is a real effect on courier volume), need it to work without per-series tuning |
**Recommendation: Prophet**, for the holiday regressors. Diwali, Pongal and
regional festivals move courier volume substantially, Prophet takes a holiday
calendar as a first-class input, and it does not need per-series order selection
across dozens of pincodes.
Validate with rolling-origin backtesting (expanding window), **never** a random
split — a random train/test split on time series leaks the future and reports an
accuracy you will not see in production.
### 2.4 — What consumes the forecast
Demand prediction with no consumer is a dashboard nobody opens. The honest
consumer is `rebalance_riders` (`seed.go:295`), which is itself unimplemented —
so Phase 2 should be scoped **with** that tool's executor or not at all.
Minimum useful output: tomorrow's expected bookings per zone, plus a
staffing-gap signal against rostered riders. That is actionable; a forecast
number alone is not.
---
## 5. Where this runs
A prediction service is a **third** runtime next to the Go API and the Python
agents. Options, cheapest first:
| Option | Shape | Cost |
|---|---|---|
| **A — SQL + nightly job** | Calibration tables computed by a Go sweeper; serving is a table lookup | no new runtime, no new image |
| **B — module inside `AI_engine`** | New package; adds `pandas`/`statsmodels`/`prophet` to the image | one runtime, image grows ~300MB |
| **C — separate service** | Own repo/image/deploy/probes | full operational cost |
**Recommendation: A for Phase 1.1, B for Phase 2.** Rung 1.1 needs no model
server at all — it is a calibration table and a multiply, so it belongs in the
Go backend as a sweeper beside `StartPendingSweeper` (`main.go:244`). Prophet
genuinely needs Python, so Phase 2 lands in `AI_engine`. Option C only becomes
right if Phase 1.2 happens and model serving needs independent scaling.
**Prerequisite from the Phase 7 plan:** `AI_engine` is not in Kubernetes and has
no HTTP health surface (Track C1 there). Phase 2 inherits that work — it cannot
deploy before it.
---
## 6. LSTM — the condition for revisiting
Not recommended now. The condition under which it becomes worth measuring:
- Phase 2.3 is in production, backtested, and **losing to its own residual
structure** — i.e. Prophet's errors are autocorrelated in a way a sequence
model could capture; and
- ≥ 2 years of daily data across ≥ 50 series (≈ 36,000 observations), and
- a measured business cost to the remaining forecast error that exceeds the cost
of training infrastructure, GPU or CPU-hours, and the ongoing retraining a
neural model needs to not rot.
All three, not any one. Until then an LSTM here would be a more expensive way to
get a worse number, and the honest recommendation is to say so rather than build
it.
---
## 7. File manifest
### Phase 0 — data readiness (no application code)
| File | New? |
|---|---|
| `doormile_backend/docs/prediction-data-readiness.md` | **new** — the findings note |
| `doormile_backend/scratch/readiness_queries.sql` | **new** — the queries above, kept for re-running |
### Phase 1.0 / 1.1 — measurement and routing ETA
| File | New? | Change |
|---|---|---|
| `doormile_backend/migrations/migrate.go` | | add the `consignment_booking` view (H4), the `trip_features` view (H1 correction in one place), and the `eta_calibration` table |
| `doormile_backend/internal/prediction/calibration.go` | **new** | nightly grouped-median refresh |
| `doormile_backend/internal/prediction/eta.go` | **new** | `EstimateETA(booking) (time.Time, confidence)` |
| `doormile_backend/internal/prediction/eta_test.go` | **new** | falls back to the promise table when calibration is missing |
| `doormile_backend/internal/prediction/sweeper.go` | **new** | ticker + Redis lock, pattern from `internal/assignment/sweeper.go:88` |
| `doormile_backend/main.go:244` | | `go prediction.StartCalibrationSweeper()` |
| `doormile_backend/controllers/adminController.go:2740` | | call `prediction.EstimateETA`, keep constants as fallback |
| `doormile_backend/controllers/cxPickupFanout.go:224` | | same, keep the promise table as fallback |
| `doormile_backend/utils/epoch.go` | | read-only — the H1 conversion to reuse |
### Phase 1.2 — learned ETA (only if 1.1 insufficient)
| File | New? |
|---|---|
| `AI_engine/prediction/__init__.py` · `eta_model.py` · `features.py` | **new** |
| `AI_engine/prediction/train_eta.py` | **new** — offline training, writes a versioned artifact |
| `AI_engine/tests/test_eta_features.py` | **new** — H1 correction asserted on both timestamp taggings |
| `AI_engine/requirements.txt` | modify — `pandas`, `scikit-learn` or `lightgbm` |
| `doormile_backend/internal/prediction/eta.go` | modify — call the service, fall back to 1.1 |
### Phase 2 — demand
| File | New? |
|---|---|
| `AI_engine/prediction/demand_model.py` | **new** |
| `AI_engine/prediction/holidays_in.py` | **new** — the festival calendar |
| `AI_engine/prediction/backtest.py` | **new** — rolling-origin, never a random split |
| `AI_engine/tests/test_demand_backtest.py` | **new** — must beat seasonal-naïve to pass |
| `AI_engine/requirements.txt` | modify — `prophet` or `statsmodels` |
| `AI_engine/Dockerfile` | modify — Prophet needs a compiler toolchain |
| `doormile_backend/migrations/migrate.go` | modify — `demand_daily` view, `demand_forecast` table |
| `doormile_backend/routes/routes.go` | modify — `GET /admin/forecast/demand` (staff-only) |
| `doormile_backend/controllers/forecastController.go` | **new** |
| `doormile_backend/internal/ai/registry/seed.go:295` | modify — `rebalance_riders` once it has a consumer |
| `kubernetes/manifests/doormile/ai-engine.yaml` | modify — resources for Prophet |
### Console (Phase 2 only, optional)
| File | Change |
|---|---|
| `krow_talent_app/src/api/doormile/endpoints.js` | add `getDemandForecast` |
| a new forecast panel | render it — **after** a consumer exists, not before |
---
## 8. Decisions needed
0. **Is `ROUTE_OPTIMIZER_URL` set in the cluster?** If not, `etaminutes` is 0
everywhere and rung 1.1 begins with Phase 7 Track A1 plus a data-accumulation
wait, not with a calibration. This gates more than anything else here.
1. **Is there enough history?** Phase 0 answers it. Everything else is blocked
on that number.
1b. **Do single-stop assignments get routed too?** `minStopsToSequence = 2`
excludes them today. Either lower it, or accept a haversine-based fallback for
single-stop ETAs. An explicit call, not an assumption.
2. **Does ETA need to be learned at all, or do the constants just need
re-tuning?** Rung 1.0 answers it, cheaply.
3. **p80 or mean ETA?** Recommend p80 — "arrives by" is the promise customers
hear, and a mean is late half the time.
4. **Demand grain** — city × day to start, or straight to pincode × day?
Recommend city first.
5. **Does `rebalance_riders` get an executor in the same phase?** If no, Phase 2
produces a number nobody acts on.
6. **Rider as a feature (1.2)** — per-rider ETA adjustment is a performance
signal about a named person. Hash it, use it only in aggregate, and decide
deliberately whether it may ever surface in the console. This is a people
decision, not a modelling one.
7. **May a promise be revised after the customer has seen it?** §3a. Blocks the
last wiring step of rung 1.1 — everything else is built. Recommend Option A:
refine `estimateddeliveryat`, never move `sladueat`.
---
## 9. Sequencing
```
Phase 7 A1 (env vars) ──> routing actually runs ──> etaminutes accumulates
│ │
▼ ▼
Phase 0 (readiness) ──> [go / no-go] 1.1 calibration possible
│
no-go ─────────────┴──> stop; re-tune constants (1.0) and revisit after go-live
│
go ──> 1.0 measure ──> 1.1 routing ETA ──> [measure] ──> 1.2 only if needed
└──> 2.1/2.2 baseline ──> 2.3 Prophet (needs Phase 7 C1 first)
```
**Both tracks now depend on the Phase 7 plan**, for different reasons: rung 1.1
needs `ROUTE_OPTIMIZER_URL` (Track A1) before routed ETAs exist at all, and
Phase 2.3 needs `AI_engine` deployable with a health surface (Track C1). Track A1
is one manifest edit and unblocks both — do it first regardless of which track
you want.
**Start with Phase 0 and rung 1.0.** Both are SQL, neither needs a model, and
together they either justify the rest of this plan or retire it. Rung 1.1 is the
highest-leverage item in the document and is not machine learning at all.
The likeliest honest outcome: **1.0 + 1.1 ship, 1.2 is never needed, Phase 2
waits for volume, and LSTM never happens.** That is a success, not a shortfall.
---
## Standing constraints
- Nothing committed, pushed or deployed without being asked.
- No migrations run against a real database without being asked — the views and
tables here are additive, but "additive" is not "has run".
- Phase 0's queries are read-only; they are safe to run, and should be run before
anything else in this document.

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,240 @@
package outcomes
import (
"encoding/json"
"time"
"doormile/constants"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Seeding the decision memory from history, so recall is not useless for weeks.
//
// ─── The cold-start problem this solves ────────────────────────────────────
//
// `/internal/agent-decisions/similar` filters on `outcome IS NOT NULL`. A row
// becomes eligible only after the outcome sweeper has judged it, and a decision
// is only judged once its window has closed. So the sequence for a freshly
// switched-on memory is:
//
// switch embeddings on -> wait for stalls to happen -> wait 48h per decision
// -> the sweeper labels them -> only now does recall return anything
//
// With autonomy gates off and two decision types, that is weeks of recall
// returning [] — which reads as "retrieval does not help here" rather than
// "retrieval has nothing to retrieve yet". The first conclusion is wrong and
// expensive to un-learn.
//
// This backfills decisions from bookings whose outcome is ALREADY known.
//
// ─── What it does and does not claim ───────────────────────────────────────
//
// A backfilled row is not a decision the engine made. It is a record of a
// situation that occurred and how it ended, shaped so the retrieval path can
// use it as precedent. That distinction is recorded honestly:
//
// decision.action = "none" — nothing was decided; nobody intervened
// decision.source = "backfill" — so these are distinguishable forever
// reasoning = states plainly that this is historical, not a decision
//
// Why that matters: as_prompt_block renders `action` to the model. Writing a
// plausible-looking action here would teach the model that an action it never
// took produced the outcome that followed — which is worse than no memory.
// "none" is honest: this is what happened when nothing was done.
//
// ─── It writes no embeddings ───────────────────────────────────────────────
//
// Embedding is the engine's job (AI_engine/core/embeddings.py) and needs the
// provider key, which this process does not have. Backfilled rows land with
// NULL context_embedding and are invisible to similarity search until
// something embeds them. That is deliberate: a backfill that silently created
// un-embedded rows AND claimed to have seeded the memory would be the worse
// failure. BackfillStats reports the count so the caller knows what is owed.
// BackfillStats is what one run produced.
type BackfillStats struct {
Scanned int `json:"scanned"`
Inserted int `json:"inserted"`
Skipped int `json:"skipped"`
// NeedsEmbedding is Inserted — every backfilled row still needs a vector
// before it can be retrieved. Surfaced separately so it cannot be missed.
NeedsEmbedding int `json:"needsembedding"`
}
// historicalRow is one past booking whose ending is known.
//
// Joins through consignment_booking, never consignments.bookingid — that column
// does not exist, and the legacy pickupbookings.consignmentid link names only
// the first order of a multi-destination pickup (hazard H4).
type historicalRow struct {
Bookingid int
Tenantid *uint64
Deliverypincode string
Status string
Createdat time.Time
Deliveredat *time.Time
Sladueat *time.Time
Attemptcount int
Chargeableweight float64
}
const historicalSQL = `
SELECT pb.bookingid,
pb.tenantid AS tenantid,
COALESCE(c.deliverypincode, '') AS deliverypincode,
COALESCE(c.status, pb.status) AS status,
c.createdat AS createdat,
del.createdat AS deliveredat,
c.sladueat AS sladueat,
COALESCE(c.attemptcount, 0) AS attemptcount,
COALESCE(c.chargeableweight, 0) AS chargeableweight
FROM consignments c
JOIN consignment_booking cb ON cb.consignmentid = c.consignmentid
JOIN pickupbookings pb ON pb.bookingid = cb.bookingid
LEFT JOIN (
SELECT DISTINCT ON (consignmentid) consignmentid, createdat
FROM consignmenthistory
WHERE eventstatus = ?
ORDER BY consignmentid, createdat ASC
) del ON del.consignmentid = c.consignmentid
WHERE c.deletedat IS NULL
-- Only parcels whose story has ended. An in-flight parcel has no outcome to
-- learn from, and guessing one is exactly what this must not do.
AND (del.createdat IS NOT NULL OR c.status IN ?)
-- Not already backfilled or decided for. The uniqueness is on
-- (decision_type, booking_id), enforced here rather than by a constraint
-- because real decisions legitimately repeat for one booking.
AND NOT EXISTS (
SELECT 1 FROM agent_decisions d
WHERE d.booking_id = pb.bookingid AND d.decision_type = ?
)
ORDER BY c.createdat DESC
LIMIT ?
`
// BackfillDecisionType is its own type, kept separate from the engine's
// `stall_response` and `assignment_failure`. Recall is per-type, so backfilled
// precedent is retrievable on purpose and never silently mixed into a type the
// engine thinks it authored.
const BackfillDecisionType = "historical_delivery"
// Backfill writes historical precedent rows. Idempotent: a booking that already
// has a decision of this type is skipped, so re-running adds only what is new.
//
// limit bounds one run — this scans delivery history, which is the largest
// table pair in the database.
func Backfill(gdb *gorm.DB, limit int) (BackfillStats, error) {
if gdb == nil {
return BackfillStats{}, nil
}
if limit <= 0 {
limit = 2000
}
terminal := []string{
constants.ConsignmentDelivered,
"Returned_to_Sender",
"Missing",
"Damaged",
}
var rows []historicalRow
if err := gdb.Raw(historicalSQL,
constants.ConsignmentDelivered, terminal, BackfillDecisionType, limit,
).Scan(&rows).Error; err != nil {
return BackfillStats{}, err
}
stats := BackfillStats{Scanned: len(rows)}
batch := make([]models.AgentDecision, 0, len(rows))
for _, r := range rows {
outcome := OutcomeFailure
switch {
case r.Deliveredat == nil:
// Terminal but never delivered: returned, lost or damaged.
outcome = OutcomeFailure
case r.Sladueat != nil && r.Deliveredat.After(*r.Sladueat):
// Delivered late. Counting this a success would teach that any
// eventual delivery is a good outcome — the same trap the
// stall_response rule avoids.
outcome = OutcomeFailure
default:
outcome = OutcomeSuccess
}
// The facts shape mirrors what AI_engine puts in context.facts, so an
// embedding of a backfilled row sits in the same space as a real one.
// If these diverge, retrieval returns neighbours that are near in
// vector space for the wrong reasons.
facts := map[string]any{
"booking_id": r.Bookingid,
"delivery_pincode": r.Deliverypincode,
"attempt_count": r.Attemptcount,
"chargeable_weight": r.Chargeableweight,
"final_status": r.Status,
}
if r.Deliveredat != nil {
facts["hours_to_deliver"] = int(r.Deliveredat.Sub(r.Createdat).Hours())
}
contextJSON, err := json.Marshal(map[string]any{
"facts": facts,
"model": "none",
"source": "backfill",
})
if err != nil {
stats.Skipped++
continue
}
decisionJSON, err := json.Marshal(map[string]any{
// Honest: nobody decided anything. See the package comment — a
// plausible-looking action here would be a fabricated lesson.
"action": "none",
"confidence": 0.0,
"source": "backfill",
})
if err != nil {
stats.Skipped++
continue
}
recordedAt := utils.DBNow()
batch = append(batch, models.AgentDecision{
DecisionType: BackfillDecisionType,
BookingID: u64(r.Bookingid),
TenantID: r.Tenantid,
Context: string(contextJSON),
Decision: string(decisionJSON),
Reasoning: "Historical outcome backfilled from delivery records. No agent decision was made for this booking; this row records what happened when nothing intervened.",
Outcome: &outcome,
OutcomeRecordedAt: &recordedAt,
CreatedAt: r.Createdat,
})
}
if len(batch) == 0 {
return stats, nil
}
if err := gdb.CreateInBatches(&batch, 200).Error; err != nil {
return stats, err
}
stats.Inserted = len(batch)
stats.NeedsEmbedding = len(batch)
utils.Info("outcomes: backfilled historical precedent",
"scanned", stats.Scanned, "inserted", stats.Inserted, "skipped", stats.Skipped,
"note", "rows have no embedding yet and are not retrievable until one is written")
return stats, nil
}
func u64(n int) *uint64 {
if n <= 0 {
return nil
}
v := uint64(n)
return &v
}

View File

@@ -0,0 +1,158 @@
// Package outcomes decides, after the fact, whether an agent's decision worked.
//
// This is the half of the decision memory that was missing, and without it the
// other half does nothing. `/internal/agent-decisions/similar` filters on
// `outcome IS NOT NULL`, so until something judges a decision it is invisible
// to retrieval. Embeddings could flow for months and every recall would still
// come back empty.
//
// A decision is not precedent because it was made. It is precedent because we
// know how it turned out.
//
// ─── What "worked" means ──────────────────────────────────────────────────
//
// Two decision types exist today, from exactly two call sites in the engine:
//
// stall_response AI_engine/agents/exception_agent.py:456
// assignment_failure AI_engine/agents/dispatch_agent.py:335
//
// Each gets its own definition below. A third type appearing without a rule
// here is left pending rather than guessed at — a wrong label is worse than no
// label, because it teaches the model confidently.
package outcomes
import (
"os"
"strconv"
"strings"
"time"
"doormile/utils"
)
// The outcome vocabulary. Stored in agent_decisions.outcome (varchar 30) and
// read back by the Insights tab, which groups by it.
const (
// OutcomeSuccess — the thing the decision was trying to achieve happened.
OutcomeSuccess = "success"
// OutcomeFailure — it did not.
OutcomeFailure = "failure"
// OutcomeUnknown — the window closed without enough evidence either way.
// Deliberately recorded rather than left pending: a pending row is
// retried every sweep forever, and an unjudgeable decision should stop
// costing a scan. It is excluded from retrieval the same as pending,
// because `outcome IS NOT NULL` is not the only filter that matters —
// see judgeable().
OutcomeUnknown = "unknown"
)
const (
defaultOutcomeWindowHours = 48
// A decision younger than this is left alone: the booking it concerns is
// probably still in flight, and judging it now would record a failure for
// something that simply has not finished.
minDecisionAge = 30 * time.Minute
// Caps one sweep's work; the rest are next sweep's.
sweepBatch = 500
)
// OutcomeWindow is how long after a decision its result is judged.
// AGENT_OUTCOME_WINDOW_HOURS, default 48.
//
// The window is a real tradeoff, not a tuning knob. Too short and a parcel
// that was always going to take three days is recorded as a failure of the
// decision rather than of the promise. Too long and the memory learns slowly.
// 48h matches the Standard service SLA (36h) with headroom.
func OutcomeWindow() time.Duration {
if v := strings.TrimSpace(os.Getenv("AGENT_OUTCOME_WINDOW_HOURS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Hour
}
utils.Warn("AGENT_OUTCOME_WINDOW_HOURS is not a positive integer, using the default",
"value", v, "default_hours", defaultOutcomeWindowHours)
}
return defaultOutcomeWindowHours * time.Hour
}
// RetentionDays bounds how long decisions are kept. Longer than the 30 days
// aiagentruns keeps, because old precedent is the whole point of this table —
// but not unbounded, which is what it was.
func RetentionDays() int {
if v := strings.TrimSpace(os.Getenv("AGENT_DECISION_RETENTION_DAYS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 180
}
// bookingFacts is what the sweeper reads about the booking a decision concerned.
// Timestamps come from columns written with CURRENT_TIMESTAMP defaults, so they
// are consistent with each other and their differences are correct regardless
// of the IST-digits-labelled-UTC convention (utils.DBNow) — the same reasoning
// internal/prediction's calibration relies on.
type bookingFacts struct {
Bookingid int
Status string
Assigned bool
DecidedAt time.Time
DeliveredAt *time.Time
SLADueAt *time.Time
AssignedAt *time.Time
Cancelled bool
}
// judge applies the per-type rule. Returns the outcome and whether the decision
// is judgeable at all; a false means leave it pending for now.
func judge(decisionType string, f bookingFacts, now time.Time, window time.Duration) (string, bool) {
age := now.Sub(f.DecidedAt)
if age < minDecisionAge {
return "", false // too soon; the booking is still in flight
}
switch decisionType {
case "stall_response":
// The agent intervened because a rider had stopped making progress.
// It worked if the parcel reached the customer, and reached them
// within the promise that was in force.
if f.Cancelled {
return OutcomeFailure, true
}
if f.DeliveredAt != nil {
if f.SLADueAt != nil && f.DeliveredAt.After(*f.SLADueAt) {
// Delivered, but late. Counting this as success would teach
// the model that any eventual delivery vindicates the action.
return OutcomeFailure, true
}
return OutcomeSuccess, true
}
if age >= window {
// Window closed, never delivered, not cancelled — stuck.
return OutcomeFailure, true
}
return "", false
case "assignment_failure":
// The agent reasoned about why no rider could be found. It worked if
// the booking subsequently got one.
if f.Cancelled {
return OutcomeFailure, true
}
if f.Assigned && f.AssignedAt != nil && f.AssignedAt.After(f.DecidedAt) {
return OutcomeSuccess, true
}
if age >= window {
return OutcomeFailure, true
}
return "", false
default:
// An unrecognised decision type. Judge it unknown once the window has
// closed so it stops being rescanned, but never guess success or
// failure — a wrong label is worse than no label.
if age >= window {
return OutcomeUnknown, true
}
return "", false
}
}

View File

@@ -0,0 +1,214 @@
package outcomes
import (
"testing"
"time"
)
func at(h int) time.Time {
return time.Date(2026, 10, 5, h, 0, 0, 0, time.UTC)
}
func tp(t time.Time) *time.Time { return &t }
const window = 48 * time.Hour
// ─── Too soon to judge ─────────────────────────────────────────────────────
func TestYoungDecisionIsLeftPending(t *testing.T) {
decided := at(10)
for _, dt := range []string{"stall_response", "assignment_failure", "something_new"} {
_, ok := judge(dt, bookingFacts{DecidedAt: decided}, decided.Add(5*time.Minute), window)
if ok {
t.Errorf("%s: judged a 5-minute-old decision; the booking is still in flight", dt)
}
}
}
func TestPendingWhileInsideTheWindow(t *testing.T) {
decided := at(10)
// An hour in: no delivery yet, but the window has not closed. Recording
// failure here would blame the decision for a parcel still on its way.
if _, ok := judge("stall_response", bookingFacts{DecidedAt: decided}, decided.Add(time.Hour), window); ok {
t.Error("stall_response judged before its window closed")
}
if _, ok := judge("assignment_failure", bookingFacts{DecidedAt: decided}, decided.Add(time.Hour), window); ok {
t.Error("assignment_failure judged before its window closed")
}
}
// ─── stall_response ────────────────────────────────────────────────────────
func TestStallDeliveredOnTimeIsSuccess(t *testing.T) {
decided := at(10)
f := bookingFacts{
DecidedAt: decided,
DeliveredAt: tp(decided.Add(3 * time.Hour)),
SLADueAt: tp(decided.Add(12 * time.Hour)),
}
got, ok := judge("stall_response", f, decided.Add(4*time.Hour), window)
if !ok || got != OutcomeSuccess {
t.Errorf("got %q ok=%v, want success", got, ok)
}
}
// The case that matters most: counting any eventual delivery as success would
// teach the model that the action is always vindicated, which is exactly the
// wrong lesson.
func TestStallDeliveredLateIsFailure(t *testing.T) {
decided := at(10)
f := bookingFacts{
DecidedAt: decided,
DeliveredAt: tp(decided.Add(20 * time.Hour)),
SLADueAt: tp(decided.Add(12 * time.Hour)),
}
got, ok := judge("stall_response", f, decided.Add(21*time.Hour), window)
if !ok || got != OutcomeFailure {
t.Errorf("got %q ok=%v, want failure — delivered 8h past SLA", got, ok)
}
}
func TestStallDeliveredWithNoSLAIsSuccess(t *testing.T) {
decided := at(10)
// No sladueat recorded (an older row). Delivery is the best evidence
// available and there is nothing to call it late against.
f := bookingFacts{DecidedAt: decided, DeliveredAt: tp(decided.Add(5 * time.Hour))}
got, ok := judge("stall_response", f, decided.Add(6*time.Hour), window)
if !ok || got != OutcomeSuccess {
t.Errorf("got %q ok=%v, want success", got, ok)
}
}
func TestStallNeverDeliveredAfterWindowIsFailure(t *testing.T) {
decided := at(10)
got, ok := judge("stall_response", bookingFacts{DecidedAt: decided}, decided.Add(window+time.Hour), window)
if !ok || got != OutcomeFailure {
t.Errorf("got %q ok=%v, want failure", got, ok)
}
}
func TestStallCancelledIsFailureImmediately(t *testing.T) {
decided := at(10)
f := bookingFacts{DecidedAt: decided, Cancelled: true}
// Cancellation is conclusive — no need to wait out the window.
got, ok := judge("stall_response", f, decided.Add(time.Hour), window)
if !ok || got != OutcomeFailure {
t.Errorf("got %q ok=%v, want failure on cancellation", got, ok)
}
}
// ─── assignment_failure ────────────────────────────────────────────────────
func TestAssignmentLaterAssignedIsSuccess(t *testing.T) {
decided := at(10)
f := bookingFacts{
DecidedAt: decided,
Assigned: true,
AssignedAt: tp(decided.Add(2 * time.Hour)),
}
got, ok := judge("assignment_failure", f, decided.Add(3*time.Hour), window)
if !ok || got != OutcomeSuccess {
t.Errorf("got %q ok=%v, want success", got, ok)
}
}
// A rider assigned BEFORE the decision is not evidence the decision worked —
// it is the assignment that was already there. Without the ordering check,
// every reassignment decision would read as an instant success.
func TestAssignmentPredatingTheDecisionIsNotSuccess(t *testing.T) {
decided := at(10)
f := bookingFacts{
DecidedAt: decided,
Assigned: true,
AssignedAt: tp(decided.Add(-2 * time.Hour)),
}
got, ok := judge("assignment_failure", f, decided.Add(time.Hour), window)
if ok && got == OutcomeSuccess {
t.Error("counted a pre-existing assignment as the decision's success")
}
}
func TestAssignmentNeverAssignedAfterWindowIsFailure(t *testing.T) {
decided := at(10)
got, ok := judge("assignment_failure", bookingFacts{DecidedAt: decided}, decided.Add(window+time.Hour), window)
if !ok || got != OutcomeFailure {
t.Errorf("got %q ok=%v, want failure", got, ok)
}
}
func TestAssignmentCancelledIsFailure(t *testing.T) {
decided := at(10)
f := bookingFacts{DecidedAt: decided, Cancelled: true}
got, ok := judge("assignment_failure", f, decided.Add(time.Hour), window)
if !ok || got != OutcomeFailure {
t.Errorf("got %q ok=%v, want failure", got, ok)
}
}
// ─── Unknown types are never guessed ───────────────────────────────────────
func TestUnknownTypeIsNeverSuccessOrFailure(t *testing.T) {
decided := at(10)
// Inside the window: pending.
if _, ok := judge("a_new_decision_type", bookingFacts{DecidedAt: decided}, decided.Add(time.Hour), window); ok {
t.Error("judged an unknown decision type inside its window")
}
// Past it: unknown, so it stops being rescanned — but never a label that
// would teach the model something nobody defined.
got, ok := judge("a_new_decision_type", bookingFacts{DecidedAt: decided}, decided.Add(window+time.Hour), window)
if !ok || got != OutcomeUnknown {
t.Errorf("got %q ok=%v, want unknown", got, ok)
}
}
// A delivered unknown type must still not be called a success: the rule for
// what success means for that type does not exist yet.
func TestUnknownTypeWithDeliveryIsStillUnknown(t *testing.T) {
decided := at(10)
f := bookingFacts{DecidedAt: decided, DeliveredAt: tp(decided.Add(time.Hour))}
got, ok := judge("a_new_decision_type", f, decided.Add(window+time.Hour), window)
if !ok || got != OutcomeUnknown {
t.Errorf("got %q ok=%v, want unknown", got, ok)
}
}
// ─── Configuration ─────────────────────────────────────────────────────────
func TestOutcomeWindowDefaultAndOverride(t *testing.T) {
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "")
if got := OutcomeWindow(); got != defaultOutcomeWindowHours*time.Hour {
t.Errorf("default window = %v, want %v", got, defaultOutcomeWindowHours*time.Hour)
}
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "12")
if got := OutcomeWindow(); got != 12*time.Hour {
t.Errorf("window = %v, want 12h", got)
}
// Garbage falls back rather than producing a zero window, which would
// judge every decision the instant it passed minDecisionAge.
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "not-a-number")
if got := OutcomeWindow(); got != defaultOutcomeWindowHours*time.Hour {
t.Errorf("window on garbage = %v, want the default", got)
}
t.Setenv("AGENT_OUTCOME_WINDOW_HOURS", "0")
if got := OutcomeWindow(); got != defaultOutcomeWindowHours*time.Hour {
t.Errorf("window on 0 = %v, want the default", got)
}
}
func TestRetentionDaysDefaultAndOverride(t *testing.T) {
t.Setenv("AGENT_DECISION_RETENTION_DAYS", "")
if got := RetentionDays(); got != 180 {
t.Errorf("default retention = %d, want 180", got)
}
t.Setenv("AGENT_DECISION_RETENTION_DAYS", "90")
if got := RetentionDays(); got != 90 {
t.Errorf("retention = %d, want 90", got)
}
// Retention must be longer than aiagentruns' 30 days, because old
// precedent is the point of this table. Not enforced in code — asserted
// here so a future change to the default has to confront it.
t.Setenv("AGENT_DECISION_RETENTION_DAYS", "")
if RetentionDays() <= 30 {
t.Error("decision retention is no longer than telemetry retention; precedent will be purged before it is useful")
}
}

View File

@@ -0,0 +1,226 @@
package outcomes
import (
"context"
"os"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/utils"
"gorm.io/gorm"
)
// The outcome sweeper.
//
// Same shape as internal/assignment/sweeper.go and internal/prediction/sweeper.go:
// a ticker, a recover() per tick, and a Redis lock that expires before the next
// tick so one replica of three does the work. Without Redis every replica
// sweeps, which is wasteful but correct — each update is idempotent and scoped
// to rows that are still pending.
const (
defaultOutcomeSweepSeconds = 900 // 15m
minOutcomeSweepSeconds = 120
outcomeLockKey = "ai:outcome-sweep:lock"
)
func sweepInterval() time.Duration {
if v := strings.TrimSpace(os.Getenv("AGENT_OUTCOME_SWEEP_SECONDS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
if n > 0 && n < minOutcomeSweepSeconds {
n = minOutcomeSweepSeconds
}
return time.Duration(n) * time.Second
}
utils.Warn("AGENT_OUTCOME_SWEEP_SECONDS is not a non-negative integer, using the default",
"value", v, "default_seconds", defaultOutcomeSweepSeconds)
}
return defaultOutcomeSweepSeconds * time.Second
}
// PruneFindings is set at boot by main.go to controllers.PruneAIFindings.
// A function variable rather than a direct call because controllers imports
// most of the codebase, and this package is imported BY controllers' siblings —
// calling it directly would be an import cycle.
var PruneFindings func(retentionDays int) (int, error)
// StartOutcomeSweeper judges pending decisions on a timer, and prunes old ones.
// Call once at boot, in a goroutine.
func StartOutcomeSweeper() {
interval := sweepInterval()
if interval == 0 {
utils.Info("OutcomeSweeper: disabled (AGENT_OUTCOME_SWEEP_SECONDS=0)")
return
}
utils.Info("OutcomeSweeper: started",
"interval", interval.String(), "window", OutcomeWindow().String(),
"retention_days", RetentionDays())
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
sweepOnce(interval)
}
}
func sweepOnce(interval time.Duration) {
defer func() {
if r := recover(); r != nil {
utils.Error("OutcomeSweeper: panic recovered", "error", r)
}
}()
if db.DB == nil {
return
}
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
ttl := interval - 30*time.Second
if ttl <= 0 {
ttl = interval / 2
}
got, err := db.Rdb.SetNX(ctx, outcomeLockKey, "1", ttl).Result()
cancel()
if err == nil && !got {
return
}
}
judged, err := JudgePending(db.DB, time.Now())
if err != nil {
utils.Error("OutcomeSweeper: judging failed", "error", err)
}
pruned, err := Prune(db.DB)
if err != nil {
utils.Error("OutcomeSweeper: prune failed", "error", err)
}
// Skill findings share this tick rather than carrying their own timer:
// one more table to keep tidy, not one more goroutine. Injected so this
// package does not import controllers (which imports everything).
findingsPruned := 0
if PruneFindings != nil {
if n, err := PruneFindings(RetentionDays()); err != nil {
utils.Error("OutcomeSweeper: finding prune failed", "error", err)
} else {
findingsPruned = n
}
}
if judged > 0 || pruned > 0 || findingsPruned > 0 {
utils.Info("OutcomeSweeper: swept",
"judged", judged, "decisions_pruned", pruned, "findings_pruned", findingsPruned)
}
}
// pendingRow is one unjudged decision joined to the booking it concerned.
//
// The join runs through pickupbookings on the decision's own booking_id, which
// the engine supplies — not through consignments, which has no bookingid column
// (the hazard CLAUDE.md §8.5 and docs/prediction-plan.md H4 describe). The
// delivered timestamp therefore comes from consignmenthistory via the
// consignment_booking view, the one place that resolution is written down.
type pendingRow struct {
ID uint64
DecisionType string
Bookingid *int
Status string
Assignedat *time.Time
Assigned bool
Createdat time.Time
Deliveredat *time.Time
Sladueat *time.Time
}
const pendingSQL = `
SELECT d.id AS id,
d.decision_type AS decision_type,
d.booking_id AS bookingid,
COALESCE(pb.status, '') AS status,
ba.assignedat AS assignedat,
(pb.assignedmileruserid IS NOT NULL) AS assigned,
d.created_at AS createdat,
del.createdat AS deliveredat,
con.sladueat AS sladueat
FROM agent_decisions d
LEFT JOIN pickupbookings pb ON pb.bookingid = d.booking_id
LEFT JOIN (
SELECT DISTINCT ON (bookingid) bookingid, assignedat
FROM bookingassignments
ORDER BY bookingid, assignedat DESC NULLS LAST, bookingassignmentid DESC
) ba ON ba.bookingid = d.booking_id
LEFT JOIN consignment_booking cb ON cb.bookingid = d.booking_id
LEFT JOIN consignments con ON con.consignmentid = cb.consignmentid
LEFT JOIN (
SELECT DISTINCT ON (consignmentid) consignmentid, createdat
FROM consignmenthistory
WHERE eventstatus = ?
ORDER BY consignmentid, createdat ASC
) del ON del.consignmentid = cb.consignmentid
WHERE d.outcome IS NULL
ORDER BY d.created_at ASC
LIMIT ?
`
// JudgePending labels every pending decision it can and returns how many it
// wrote. A decision it cannot judge yet is left pending for the next sweep.
func JudgePending(gdb *gorm.DB, now time.Time) (int, error) {
if gdb == nil {
return 0, nil
}
var rows []pendingRow
if err := gdb.Raw(pendingSQL, constants.ConsignmentDelivered, sweepBatch).Scan(&rows).Error; err != nil {
return 0, err
}
window := OutcomeWindow()
judged := 0
for _, r := range rows {
facts := bookingFacts{
Status: r.Status,
Assigned: r.Assigned,
DecidedAt: r.Createdat,
DeliveredAt: r.Deliveredat,
SLADueAt: r.Sladueat,
AssignedAt: r.Assignedat,
Cancelled: r.Status == constants.BookingCancelled,
}
if r.Bookingid != nil {
facts.Bookingid = *r.Bookingid
}
outcome, ok := judge(r.DecisionType, facts, now, window)
if !ok {
continue
}
// Guarded on outcome IS NULL so two replicas sweeping concurrently
// cannot overwrite each other, and a decision is judged exactly once.
res := gdb.Exec(
`UPDATE agent_decisions SET outcome = ?, outcome_recorded_at = ? WHERE id = ? AND outcome IS NULL`,
outcome, utils.DBNow(), r.ID,
)
if res.Error != nil {
utils.Warn("OutcomeSweeper: could not record outcome", "id", r.ID, "error", res.Error)
continue
}
judged += int(res.RowsAffected)
}
return judged, nil
}
// Prune drops decisions past the retention window. agent_decisions had no
// retention at all while aiagentruns purged at 30 days — and this is the table
// the similarity query scans, so unbounded growth degrades every recall.
func Prune(gdb *gorm.DB) (int, error) {
if gdb == nil {
return 0, nil
}
cutoff := utils.DBNow().AddDate(0, 0, -RetentionDays())
res := gdb.Exec(`DELETE FROM agent_decisions WHERE created_at < ?`, cutoff)
if res.Error != nil {
return 0, res.Error
}
return int(res.RowsAffected), nil
}

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,489 @@
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)
}
}
// Four console verbs have executors now: notify_riders, alert_low_battery_rider,
// assign_riders and trigger_auto_dispatch (the last two share the batch-assign
// solver behind POST /admin/bookings/batch-assign). Every console write that
// still has none must say REVIEW ONLY, or Agent Studio would advertise an
// action that cannot run.
//
// The invariant reads Implementedat, not a list kept here: a tool whose
// Implementedat still points into lib/assistant/skills/ has no executor, and
// one pointing at lib/assistant/agent/actions.js does. That is why wiring an
// executor means moving Implementedat — the test cannot be satisfied by
// editing the description alone.
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: "Assign a finding's orders to riders. Greedy nearest on-duty rider, capped per rider, committed server-side — there is no preview step, so the operator's click is the gate.", Target: "doormile_backend POST /admin/bookings/batch-assign",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js", 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: "Tell a rider on a low battery to charge or report to the nearest hub.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/agent/actions.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: "Auto-assign orders that have waited too long for dispatch. The same batch-assign action as assign_riders, under the name LateDispatchSkill proposes it by.", Target: "doormile_backend POST /admin/bookings/batch-assign",
Implementedat: consoleSrc + "lib/assistant/agent/actions.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,31 +3,99 @@ 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 (
maxRetries = 5
retryDelay = 2 * time.Minute
geoRadiusKm = 10.0
geoMaxCount = 10
maxActive = 3
// How many GEO members to fetch per search.
//
// Raised from 10, and the reason matters. GEOSEARCH returns the N NEAREST
// members, and eligibility (GPS freshness, availability, the active cap) is
// applied AFTERWARDS. So any member that is near but not eligible consumes
// one of the N slots and pushes a usable rider out of the result entirely.
//
// Stale members were the worst case: nothing removed a rider from the set
// when they went off duty, so riders who finished weeks ago sat frozen near
// the hub where most pickups originate — the nearest members there are.
// Ten of those filled every slot, all ten were discarded, and the pool
// collapsed to one rider who then took every order in the city.
//
// MilerEndDuty now removes riders (internal/milergeo.Remove), which fixes
// it going forward. This is the defence for members ALREADY in a live
// Redis, and for any future reason a nearby rider turns out ineligible.
// 50 members is a cheap read and leaves room for the filters.
geoMaxCount = 50
// 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
// 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 +115,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 +169,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 +228,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 +238,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 +286,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 +327,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 +352,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 +377,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",
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 {
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.Error("Assignment(inline): NO_MILER_AVAILABLE — all retries exhausted",
"booking_id", bookingID, "max_retries", maxRetries)
utils.Warn("Assignment(inline): no eligible miler", "booking_id", bookingID, "attempt", attempt)
}
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,161 @@
// 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"
"strconv"
"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
ZRem(ctx context.Context, key string, members ...interface{}) *redis.IntCmd
}
// Remove drops a rider from the GEO set.
//
// ─── Why this has to exist ─────────────────────────────────────────────────
//
// A GEO set member never expires. Nothing removed a rider when they went off
// duty, so `milers:locations` accumulated every rider who had ever started a
// shift, frozen at their last reported position — for good.
//
// That is not merely untidy, because assignment asks for the TEN NEAREST
// members (geoMaxCount in internal/assignment). Riders who finished weeks ago,
// parked near the hub where most pickups originate, are geographically the
// closest members there are. They fill all ten slots, every one of them is
// then discarded by the GPS-freshness check, and the candidate pool collapses
// to whoever happened to survive — often a single rider.
//
// The balancing logic below that (leastLoaded, betterChoice) is correct and
// irrelevant: it can only balance across the pool it is handed. The symptom is
// every order in a city going to the same person.
//
// Called on end-duty. A failure is logged by the caller and not fatal: a rider
// left in the set is the status quo, not a regression.
func Remove(ctx context.Context, rdb Client, milerUserID int) error {
if rdb == nil {
return nil
}
return rdb.ZRem(ctx, Key, strconv.Itoa(milerUserID)).Err()
}
// 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,175 @@
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
remErr error
removed []interface{}
}
func (f *fakeRedis) ZRem(ctx context.Context, _ string, members ...interface{}) *redis.IntCmd {
f.removed = append(f.removed, members...)
cmd := redis.NewIntCmd(ctx)
if f.remErr != nil {
cmd.SetErr(f.remErr)
} else {
cmd.SetVal(int64(len(members)))
}
return cmd
}
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)
}
}
// A GEO member never expires, and nothing removed one when a rider went off
// duty — so milers:locations kept every rider who had ever started a shift,
// frozen where they last reported. Assignment takes the N NEAREST members, so
// those stale entries crowded out riders who were actually working and the
// candidate pool collapsed to whoever survived the freshness filter.
func TestRemoveDropsTheRiderFromTheSet(t *testing.T) {
f := &fakeRedis{}
if err := Remove(context.Background(), f, 412); err != nil {
t.Fatalf("Remove: %v", err)
}
if len(f.removed) != 1 || f.removed[0] != "412" {
t.Errorf("removed = %v, want [\"412\"]", f.removed)
}
}
// The member is the rider's userid as a DECIMAL STRING — the same spelling
// GeoAdd writes and Search reads back. A mismatch here would remove nothing
// and report success.
func TestRemoveUsesTheSameMemberSpellingAsAdd(t *testing.T) {
f := &fakeRedis{}
_ = Remove(context.Background(), f, 7)
if f.removed[0] != "7" {
t.Errorf("member = %v, want \"7\" (decimal string, not an int)", f.removed[0])
}
}
// Best-effort at the call site: the rider is off duty either way, and the
// freshness check still excludes them. The error must reach the caller so it
// can be logged rather than swallowed here.
func TestRemoveReturnsTheError(t *testing.T) {
f := &fakeRedis{remErr: errors.New("redis down")}
if err := Remove(context.Background(), f, 1); err == nil {
t.Error("Remove swallowed the error")
}
}
func TestRemoveOnNilClientIsANoOp(t *testing.T) {
if err := Remove(context.Background(), nil, 1); err != nil {
t.Errorf("Remove(nil) = %v, want nil", err)
}
}

View File

@@ -0,0 +1,272 @@
package prediction
import (
"time"
"doormile/constants"
"doormile/db"
"doormile/utils"
"gorm.io/gorm"
)
// The calibration refresh.
//
// Every delivered consignment whose rider was sequenced carries two numbers:
// what the Route Optimization API predicted (bookingassignments.etaminutes,
// written by internal/routing) and what actually happened (the Delivered row in
// consignmenthistory). The ratio between them, grouped and taken at p80, is the
// factor ETAMinutes multiplies by.
//
// Why p80 and not the mean: docs/prediction-plan.md §8 decision 3. An ETA is a
// promise, and a mean is late half the time.
//
// Why a view and not a join here: consignments has no bookingid column (hazard
// H4 in the plan). The consignment_booking view resolves the two paths —
// bookingdestinations for multi-destination pickups, the legacy
// pickupbookings.consignmentid for console and pre-fan-out rows — once, in
// migrate.go. Joining through consignments directly attaches features to the
// wrong parcel for every multi-destination booking, silently.
// ETACalibration is one calibration cell as stored. Written only by Refresh,
// read at boot and after each refresh.
type ETACalibration struct {
Calibrationid int `gorm:"primaryKey;column:calibrationid;autoIncrement"`
Scope string `gorm:"column:scope;size:24;not null;index:idx_etacalibration_lookup,priority:1"`
Zone string `gorm:"column:zone;size:8;index:idx_etacalibration_lookup,priority:2"`
Hourbucket *int `gorm:"column:hourbucket;index:idx_etacalibration_lookup,priority:3"`
Weekday *int `gorm:"column:weekday;index:idx_etacalibration_lookup,priority:4"`
Factor float64 `gorm:"column:factor;not null"`
Handling float64 `gorm:"column:handling;not null;default:0"`
Samples int `gorm:"column:samples;not null"`
Refreshedat time.Time `gorm:"column:refreshedat;not null"`
}
func (ETACalibration) TableName() string { return "etacalibration" }
// row is one group from the refresh query.
type row struct {
Scope string
Zone string
Hourbucket *int
Weekday *int
Factor float64
Samples int
}
// Observed durations below this are almost certainly data errors — a Delivered
// event written in the same second as the consignment, or a backfill. Including
// them drags every factor toward zero.
const minObservedMinutes = 5
// And above this, the parcel sat for days for a reason that has nothing to do
// with road duration (held at hub, re-attempts, disputes). Calibrating a
// routed-duration multiplier on those teaches it the wrong thing.
const maxObservedMinutes = 48 * 60
// refreshSQL computes the p80 ratio of actual to routed duration, at three
// grains plus a global row, in one pass.
//
// The `actual` term uses consignmenthistory.createdat - consignments.createdat.
// Both come from CURRENT_TIMESTAMP defaults, so they share a tagging and their
// difference is correct regardless of hazard H1 — which is why this does not
// go near estimateddeliveryat, whose writes are not uniform
// (adminController.go:2738 uses time.Now(), elsewhere it is CURRENT_TIMESTAMP).
//
// EXTRACT(ISODOW) is 1..7 Monday-first, matching weekdayOf in eta.go. The hour
// bucket divides by 3, matching hourBucketSize. If either changes, both change.
// A booking can hold SEVERAL bookingassignments rows — Assigned, Rejected,
// Reassigned, Cancelled are all statuses it passes through. Joining them all
// would multiply one delivered parcel by its whole assignment history and pull
// the p80 toward whatever got reassigned most, so `assigned` picks exactly one
// row per booking: the most recently sequenced one, which is the routed ETA
// that was actually in force when the parcel was delivered.
//
// NULL::int / NULL::text are explicit because a UNION ALL resolves column types
// across its branches; an untyped NULL can be inferred as text and fail against
// the integer from the first branch — at runtime, against the real database,
// which is exactly where it is most expensive to discover.
const refreshSQL = `
WITH assigned AS (
SELECT DISTINCT ON (ba.bookingid)
ba.bookingid,
ba.etaminutes
FROM bookingassignments ba
WHERE ba.etaminutes > 0
ORDER BY ba.bookingid, ba.sequencedat DESC NULLS LAST, ba.bookingassignmentid DESC
),
observed AS (
SELECT left(c.deliverypincode, 3) AS zone,
(EXTRACT(HOUR FROM c.createdat)::int / ?) AS hourbucket,
EXTRACT(ISODOW FROM c.createdat)::int AS weekday,
a.etaminutes::double precision AS routed,
EXTRACT(EPOCH FROM (h.createdat - c.createdat)) / 60.0 AS actual
FROM consignments c
JOIN consignmenthistory h
ON h.consignmentid = c.consignmentid
AND h.eventstatus = ?
JOIN consignment_booking cb
ON cb.consignmentid = c.consignmentid
JOIN assigned a
ON a.bookingid = cb.bookingid
WHERE c.deliverypincode IS NOT NULL
AND length(c.deliverypincode) >= 3
),
clean AS (
SELECT * FROM observed
WHERE actual BETWEEN ? AND ?
AND routed > 0
)
SELECT 'zone_hour_weekday'::text AS scope, zone, hourbucket, weekday,
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed) AS factor,
count(*)::int AS samples
FROM clean GROUP BY zone, hourbucket, weekday
UNION ALL
SELECT 'zone_weekday'::text, zone, NULL::int, weekday,
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed), count(*)::int
FROM clean GROUP BY zone, weekday
UNION ALL
SELECT 'zone'::text, zone, NULL::int, NULL::int,
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed), count(*)::int
FROM clean GROUP BY zone
UNION ALL
SELECT 'global'::text, ''::text, NULL::int, NULL::int,
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed), count(*)::int
FROM clean
`
// Refresh recomputes the calibration from history, replaces the stored table,
// and swaps the in-memory snapshot.
//
// A failure leaves the previous calibration in place — a refresh that cannot
// run is not a reason to stop answering with the last good factors. If they go
// stale past staleAfter, ETAMinutes stops trusting them on its own.
func Refresh(gdb *gorm.DB) error {
if gdb == nil {
return nil
}
var rows []row
if err := gdb.Raw(refreshSQL,
hourBucketSize, constants.ConsignmentDelivered, minObservedMinutes, maxObservedMinutes,
).Scan(&rows).Error; err != nil {
return err
}
kept := make([]ETACalibration, 0, len(rows))
now := utils.DBNow()
for _, r := range rows {
if r.Samples < minSamples {
continue // a p80 over fewer than minSamples is noise
}
if r.Factor < minFactor || r.Factor > maxFactor {
// Out of bounds is a signal about the data, not a usable factor.
// Log it once here rather than discovering it per request.
utils.Warn("prediction: discarding out-of-bounds calibration factor",
"scope", r.Scope, "zone", r.Zone, "factor", r.Factor, "samples", r.Samples)
continue
}
kept = append(kept, ETACalibration{
Scope: r.Scope,
Zone: r.Zone,
Hourbucket: r.Hourbucket,
Weekday: r.Weekday,
Factor: r.Factor,
Samples: r.Samples,
Refreshedat: now,
})
}
if len(kept) == 0 {
// No cell cleared the floor. Expected until routing is on and history
// accumulates; ETAMinutes keeps returning false and the promise tables
// keep answering.
utils.Info("prediction: no calibration cells met the sample floor",
"groups_considered", len(rows), "min_samples", minSamples)
return nil
}
// Replace wholesale in one transaction: a partial table would serve a mix
// of old and new factors for the same zone.
if err := gdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`DELETE FROM etacalibration`).Error; err != nil {
return err
}
return tx.CreateInBatches(&kept, 200).Error
}); err != nil {
return err
}
Load(kept)
utils.Info("prediction: calibration refreshed", "cells", len(kept))
return nil
}
// Load swaps the in-memory snapshot. Exported so boot can populate from the
// stored table without recomputing, and so tests can install a known
// calibration without a database.
//
// builtAt is the NEWEST Refreshedat among the rows, not time.Now(): a restart
// that loads month-old rows from the store must still look month-old to the
// staleness guard in ETAMinutes. Taking the load time here would silently
// re-arm a stale calibration on every deploy. A row with no Refreshedat (a test
// fixture) is treated as fresh.
// Refreshedat is stored through utils.DBNow, which writes IST wall-clock digits
// labelled UTC. Reading it back as an instant is off by 5h30m, so it goes
// through utils.IST first — the same correction utils/epoch.go exists for.
// Without it a fresh calibration reads as 5.5 hours old, which is the very
// hazard docs/prediction-plan.md calls H1.
func Load(rows []ETACalibration) {
t := &table{cells: make(map[string]cell, len(rows))}
for _, r := range rows {
if r.Refreshedat.IsZero() {
continue
}
if at := utils.IST(r.Refreshedat); at.After(t.builtAt) {
t.builtAt = at
}
}
if t.builtAt.IsZero() {
t.builtAt = time.Now() // a fixture with no Refreshedat counts as fresh
}
for _, r := range rows {
c := cell{factor: r.Factor, handling: r.Handling, samples: r.Samples}
switch r.Scope {
case "global":
t.global, t.hasGlobal = c, true
case "zone":
t.cells[keyZone(r.Zone)] = c
case "zone_weekday":
if r.Weekday != nil {
t.cells[keyZoneWeekday(r.Zone, *r.Weekday)] = c
}
case "zone_hour_weekday":
if r.Weekday != nil && r.Hourbucket != nil {
t.cells[keyZoneHourWeekday(r.Zone, *r.Hourbucket, *r.Weekday)] = c
}
}
}
current.store(t)
}
// Reset clears the in-memory calibration. Tests only — it makes ETAMinutes
// return false, which is the state a fresh process is in before boot loads.
func Reset() { current.store(nil) }
// LoadFromDB populates the snapshot from the stored table at boot, so a restart
// does not wait for the next refresh to start answering.
func LoadFromDB() {
if db.DB == nil {
return
}
var rows []ETACalibration
if err := db.DB.Find(&rows).Error; err != nil {
utils.Warn("prediction: could not load stored calibration", "error", err)
return
}
if len(rows) == 0 {
return
}
Load(rows)
utils.Info("prediction: calibration loaded from store", "cells", len(rows))
}

277
internal/prediction/eta.go Normal file
View File

@@ -0,0 +1,277 @@
// Package prediction turns Doormile's own past predictions into better ones.
//
// Doormile already promises a delivery time, in two places and both of them
// fixed tables: controllers/adminController.go uses service-type constants
// (Normal 24h, Fast 12h, Superfast 6h) and controllers/cxPickupFanout.go uses
// the destination district's `promise` column (same-day/next-day/2-day/3-day).
// Neither looks at a single delivery that actually happened.
//
// This package does. The Route Optimization API already returns a road-network
// duration per stop and internal/routing already stores it on
// bookingassignments.etaminutes. Pairing that stored prediction with the
// Delivered event in consignmenthistory gives a predicted-vs-actual series the
// system produced itself, and a grouped p80 over it is a calibration factor:
//
// eta = routed_minutes × factor[zone, hour_bucket, weekday] + handling[zone]
//
// That is a median, not a model. No training, no inference server, no new
// runtime — a table refreshed nightly and a map lookup on the booking path.
//
// ─── The floor rule ────────────────────────────────────────────────────────
//
// ETAMinutes returns (0, false) whenever it lacks the evidence to do better,
// and every caller keeps its existing rule for that case. So:
//
// - no routed duration (ROUTE_OPTIMIZER_URL unset, or a single-stop rider) → false
// - no calibration cell with enough samples → false
// - calibration never refreshed, or the refresh failed → false
// - a factor outside sane bounds → false
//
// Today, in the cluster, ROUTE_OPTIMIZER_URL is not set (see Phase 7 Track A1),
// so this returns false for every booking and the promise tables answer exactly
// as they do now. Switch routing on, let a few weeks of deliveries land, and it
// starts answering — with no code change and no deploy. Today's behaviour is
// the floor; this can only raise it.
//
// ─── p80, not the mean ─────────────────────────────────────────────────────
//
// The calibration is the 80th percentile of observed overrun, not the average.
// An ETA shown to a customer is a promise — "arrives by" — and a mean is late
// half the time. docs/prediction-plan.md §8 decision 3.
package prediction
import (
"strconv"
"strings"
"sync"
"time"
"doormile/utils"
)
const (
// minSamples is the floor for trusting one calibration cell. Below it the
// p80 is noise and the lookup falls back to a coarser key.
minSamples = 20
// A factor outside these bounds means the calibration is wrong, not that
// deliveries are 10× their routed duration. Refuse rather than serve it:
// an absurd ETA is worse than today's flat constant.
minFactor = 0.5
maxFactor = 5.0
// maxHandlingMinutes caps the additive hub term for the same reason.
maxHandlingMinutes = 240
// staleAfter is how long a calibration stays usable without a refresh. The
// sweeper runs far more often than this; exceeding it means the refresh has
// been failing silently, and a month-old factor should not keep answering.
staleAfter = 72 * time.Hour
// hourBucketSize groups the day into 8 three-hour buckets. Finer buckets
// split the samples too thin to clear minSamples on real volume.
hourBucketSize = 3
)
// Input is everything the estimate needs. Built by the caller from the booking
// it already has in hand; this package never queries on the request path.
type Input struct {
// RoutedMinutes is bookingassignments.etaminutes — the Route Optimization
// API's road-network duration. Zero means unknown, which is the common case
// today and the whole reason for the floor rule.
RoutedMinutes int
// DeliveryPincode keys the zone. Only its first three digits are used, the
// same grain the hub console filters on (pickuppincode LIKE '641%').
DeliveryPincode string
// At is when the estimate is being made. Interpreted through utils.IST,
// because this database stores IST wall-clock digits and a raw .Hour() on a
// value tagged UTC is off by 5h30m — the defect utils/epoch.go documents.
At time.Time
}
// Result is a calibrated estimate and the cell that produced it. Source is for
// logging and for the console to say where a number came from; nothing branches
// on it.
type Result struct {
Minutes int
Source string // "zone_hour_weekday", "zone_weekday", "zone", "global"
Samples int
}
// cell is one calibration row, in memory.
type cell struct {
factor float64
handling float64
samples int
}
// table is an immutable calibration snapshot. Replaced wholesale by the
// refresh; readers never see a half-updated map.
type table struct {
cells map[string]cell
global cell
hasGlobal bool
builtAt time.Time
}
var (
current atomic[*table]
)
// atomic is a tiny generic holder. sync/atomic.Pointer would do, but this keeps
// the zero value useful (an unset calibration reads as nil, which ETAMinutes
// treats as "no evidence") without an init func.
type atomic[T any] struct {
mu sync.RWMutex
v T
}
func (a *atomic[T]) load() T {
a.mu.RLock()
defer a.mu.RUnlock()
return a.v
}
func (a *atomic[T]) store(v T) {
a.mu.Lock()
a.v = v
a.mu.Unlock()
}
// zoneOf is the first three digits of a pincode — the same grain the hub
// console scopes on. An empty or short pincode has no zone, which the lookup
// treats as "global only".
func zoneOf(pincode string) string {
p := strings.TrimSpace(pincode)
if len(p) < 3 {
return ""
}
return p[:3]
}
// hourBucket is the 3-hour block of the IST day, 0..7.
func hourBucket(t time.Time) int {
return utils.IST(t).Hour() / hourBucketSize
}
// weekdayOf is the ISO weekday in IST, 1 (Monday) to 7 (Sunday) — matching
// Postgres's EXTRACT(ISODOW) so the Go lookup and the refresh SQL agree.
func weekdayOf(t time.Time) int {
d := int(utils.IST(t).Weekday())
if d == 0 {
return 7 // Go's Sunday is 0; ISO's is 7
}
return d
}
// keyZoneHourWeekday, keyZoneWeekday and keyZone are the three progressively
// coarser lookups. Distinct prefixes so a zone can never collide with a
// weekday-qualified key.
func keyZoneHourWeekday(zone string, bucket, weekday int) string {
return "zhw:" + zone + ":" + strconv.Itoa(bucket) + ":" + strconv.Itoa(weekday)
}
func keyZoneWeekday(zone string, weekday int) string {
return "zw:" + zone + ":" + strconv.Itoa(weekday)
}
func keyZone(zone string) string {
return "z:" + zone
}
// ETAMinutes returns a calibrated door-to-door estimate in minutes, and whether
// it is trustworthy.
//
// False means the caller must use whatever it does today — its service-type
// constants or the district promise table. A false is not an error and is not
// logged per call: it is the expected answer until routing is switched on and
// history accumulates.
func ETAMinutes(in Input) (Result, bool) {
if in.RoutedMinutes <= 0 {
// No road-network duration to calibrate against. The dominant case
// today: ROUTE_OPTIMIZER_URL is unset in the cluster, and even with it
// set, internal/routing skips riders with fewer than two stops.
return Result{}, false
}
t := current.load()
if t == nil || len(t.cells) == 0 && !t.hasGlobal {
return Result{}, false
}
if !t.builtAt.IsZero() && time.Since(t.builtAt) > staleAfter {
// A refresh has been failing for days. Fall back rather than serve a
// factor that predates whatever changed.
return Result{}, false
}
zone := zoneOf(in.DeliveryPincode)
bucket, weekday := hourBucket(in.At), weekdayOf(in.At)
type candidate struct {
key string
source string
}
candidates := []candidate{}
if zone != "" {
candidates = append(candidates,
candidate{keyZoneHourWeekday(zone, bucket, weekday), "zone_hour_weekday"},
candidate{keyZoneWeekday(zone, weekday), "zone_weekday"},
candidate{keyZone(zone), "zone"},
)
}
for _, c := range candidates {
if cl, ok := t.cells[c.key]; ok && cl.samples >= minSamples {
if m, ok := apply(in.RoutedMinutes, cl); ok {
return Result{Minutes: m, Source: c.source, Samples: cl.samples}, true
}
}
}
if t.hasGlobal && t.global.samples >= minSamples {
if m, ok := apply(in.RoutedMinutes, t.global); ok {
return Result{Minutes: m, Source: "global", Samples: t.global.samples}, true
}
}
return Result{}, false
}
// apply is the estimate itself, with the bounds check that keeps a bad
// calibration from producing an absurd promise.
func apply(routedMinutes int, c cell) (int, bool) {
if c.factor < minFactor || c.factor > maxFactor {
return 0, false
}
if c.handling < 0 || c.handling > maxHandlingMinutes {
return 0, false
}
m := float64(routedMinutes)*c.factor + c.handling
if m <= 0 {
return 0, false
}
return int(m + 0.5), true
}
// ETAAt is ETAMinutes as an absolute time, for the callers that store a
// timestamp rather than a duration. The returned time is in the same shape the
// caller's `from` was, so it round-trips into the database unchanged.
func ETAAt(in Input, from time.Time) (time.Time, Result, bool) {
r, ok := ETAMinutes(in)
if !ok {
return time.Time{}, Result{}, false
}
return from.Add(time.Duration(r.Minutes) * time.Minute), r, true
}
// Loaded reports whether a usable calibration is in memory. For
// GET /admin/ai/status and the readiness note; not used on the booking path.
func Loaded() (bool, time.Time, int) {
t := current.load()
if t == nil {
return false, time.Time{}, 0
}
return len(t.cells) > 0 || t.hasGlobal, t.builtAt, len(t.cells)
}

View File

@@ -0,0 +1,284 @@
package prediction
import (
"testing"
"time"
"doormile/utils"
)
func ptr(n int) *int { return &n }
// A calibration that is deliberately coarse-to-fine, so the fallback ladder is
// observable: the zone_hour_weekday cell has a different factor from the
// zone_weekday one, which differs from zone, which differs from global.
func fixture() []ETACalibration {
return []ETACalibration{
{Scope: "zone_hour_weekday", Zone: "641", Hourbucket: ptr(3), Weekday: ptr(1), Factor: 1.5, Samples: 100},
{Scope: "zone_weekday", Zone: "641", Weekday: ptr(1), Factor: 2.0, Samples: 100},
{Scope: "zone", Zone: "641", Factor: 2.5, Samples: 100},
{Scope: "global", Factor: 3.0, Samples: 100},
}
}
// 2026-10-05 is a Monday. 10:00 IST is hour bucket 3 (10/3). The database
// stores IST digits labelled UTC, so the fixture time is built that way on
// purpose — it is the shape a real column read produces.
func mondayTenAM() time.Time {
return time.Date(2026, 10, 5, 10, 0, 0, 0, time.UTC)
}
// ─── The floor rule: these are the cases that must return false ────────────
func TestNoRoutedDurationFallsBack(t *testing.T) {
Load(fixture())
defer Reset()
// The dominant case in production today: ROUTE_OPTIMIZER_URL is unset, so
// internal/routing never runs and etaminutes is 0.
if _, ok := ETAMinutes(Input{RoutedMinutes: 0, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Fatal("ETAMinutes answered with no routed duration; the promise table must keep answering")
}
if _, ok := ETAMinutes(Input{RoutedMinutes: -5, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Fatal("ETAMinutes answered on a negative routed duration")
}
}
func TestNoCalibrationFallsBack(t *testing.T) {
Reset()
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Fatal("ETAMinutes answered with no calibration loaded")
}
}
func TestBelowSampleFloorFallsBack(t *testing.T) {
Load([]ETACalibration{
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: minSamples - 1},
})
defer Reset()
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Fatalf("ETAMinutes trusted a cell with %d samples (floor is %d)", minSamples-1, minSamples)
}
}
func TestOutOfBoundsFactorFallsBack(t *testing.T) {
for _, f := range []float64{minFactor - 0.1, maxFactor + 0.1, 0, -1} {
Load([]ETACalibration{{Scope: "zone", Zone: "641", Factor: f, Samples: 100}})
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Errorf("ETAMinutes served an out-of-bounds factor %v; an absurd ETA is worse than a flat constant", f)
}
Reset()
}
}
// This is also the H1 regression test, verified by mutation: Refreshedat is
// written through utils.DBNow (IST wall-clock digits labelled UTC), so reading
// it as a raw instant makes it appear 5h30m NEWER than it is. A calibration
// 73h old then measures as 67.5h and slips under the 72h staleAfter bound.
// Removing the utils.IST call in Load fails exactly this test.
//
// The margin matters: with staleAfter at 72h, the 5.5h error is a 7.6% window
// in which a stale calibration keeps answering. Narrow staleAfter and the bug
// gets proportionally worse, which is why the correction belongs in Load rather
// than in a wider bound here.
func TestStaleCalibrationFallsBack(t *testing.T) {
old := utils.DBNow().Add(-(staleAfter + time.Hour))
Load([]ETACalibration{
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: old},
})
defer Reset()
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Fatal("ETAMinutes trusted a calibration older than staleAfter; refreshes have been failing")
}
}
// The companion case: a just-refreshed calibration must answer. Note this one
// passes with or without the IST correction (the raw read errs toward "newer",
// not "older"), so it documents intent rather than guarding the bug — the guard
// above is what fails on mutation.
func TestFreshCalibrationIsNotMisreadAsStale(t *testing.T) {
Load([]ETACalibration{
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: utils.DBNow()},
})
defer Reset()
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); !ok {
t.Fatal("a just-refreshed calibration was treated as stale — the DBNow/IST correction in Load is wrong")
}
}
// Regression: loading month-old rows from the store at boot must stay stale.
// Stamping builtAt with time.Now() here would re-arm a dead calibration on
// every deploy.
func TestBootLoadDoesNotRearmStaleRows(t *testing.T) {
old := utils.DBNow().Add(-(staleAfter + 24*time.Hour))
Load([]ETACalibration{
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: old},
{Scope: "global", Factor: 2.2, Samples: 100, Refreshedat: old},
})
defer Reset()
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Fatal("a restart re-armed a stale stored calibration")
}
}
// ─── The ladder: most specific cell wins, then progressively coarser ───────
func TestMostSpecificCellWins(t *testing.T) {
Load(fixture())
defer Reset()
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()})
if !ok {
t.Fatal("expected an estimate")
}
if r.Source != "zone_hour_weekday" {
t.Errorf("source = %q, want zone_hour_weekday", r.Source)
}
if r.Minutes != 60 { // 40 × 1.5
t.Errorf("minutes = %d, want 60 (40 × 1.5)", r.Minutes)
}
}
func TestFallsToZoneWeekdayThenZoneThenGlobal(t *testing.T) {
full := fixture()
defer Reset()
// Drop the finest cell: 13:00 IST is bucket 4, for which there is no
// zone_hour_weekday row, so zone_weekday answers.
Load(full)
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001",
At: time.Date(2026, 10, 5, 13, 0, 0, 0, time.UTC)})
if !ok || r.Source != "zone_weekday" || r.Minutes != 80 { // 40 × 2.0
t.Errorf("got %+v ok=%v, want zone_weekday 80", r, ok)
}
// A Tuesday has no weekday cell for this zone, so zone answers.
r, ok = ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001",
At: time.Date(2026, 10, 6, 13, 0, 0, 0, time.UTC)})
if !ok || r.Source != "zone" || r.Minutes != 100 { // 40 × 2.5
t.Errorf("got %+v ok=%v, want zone 100", r, ok)
}
// An unknown zone falls all the way to global.
r, ok = ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "500081", At: mondayTenAM()})
if !ok || r.Source != "global" || r.Minutes != 120 { // 40 × 3.0
t.Errorf("got %+v ok=%v, want global 120", r, ok)
}
}
func TestShortOrEmptyPincodeUsesGlobalOnly(t *testing.T) {
Load(fixture())
defer Reset()
for _, p := range []string{"", "6", "64", " "} {
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: p, At: mondayTenAM()})
if !ok || r.Source != "global" {
t.Errorf("pincode %q: got %+v ok=%v, want global", p, r, ok)
}
}
}
// ─── Bucket arithmetic must match the refresh SQL, or the lookup misses ────
func TestHourBucketMatchesRefreshSQL(t *testing.T) {
// refreshSQL does EXTRACT(HOUR FROM createdat)::int / hourBucketSize.
// hourBucket must agree exactly, or every finest-grain lookup misses.
for hour := 0; hour < 24; hour++ {
got := hourBucket(time.Date(2026, 10, 5, hour, 30, 0, 0, time.UTC))
if want := hour / hourBucketSize; got != want {
t.Errorf("hour %d: bucket = %d, want %d", hour, got, want)
}
}
}
func TestWeekdayMatchesPostgresISODOW(t *testing.T) {
// EXTRACT(ISODOW) is Monday=1 … Sunday=7. Go's time.Weekday is Sunday=0.
// 2026-10-05 is a Monday.
want := map[int]int{5: 1, 6: 2, 7: 3, 8: 4, 9: 5, 10: 6, 11: 7}
for day, w := range want {
got := weekdayOf(time.Date(2026, 10, day, 12, 0, 0, 0, time.UTC))
if got != w {
t.Errorf("2026-10-%02d: weekday = %d, want %d (ISODOW)", day, got, w)
}
}
}
func TestZoneIsPincodePrefix(t *testing.T) {
cases := map[string]string{
"641001": "641", "641 ": "641", "500081": "500",
"": "", "64": "", " ": "",
}
for in, want := range cases {
if got := zoneOf(in); got != want {
t.Errorf("zoneOf(%q) = %q, want %q", in, got, want)
}
}
}
// ─── ETAAt and handling term ───────────────────────────────────────────────
func TestETAAtAddsToTheGivenTime(t *testing.T) {
Load(fixture())
defer Reset()
from := mondayTenAM()
at, r, ok := ETAAt(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: from}, from)
if !ok {
t.Fatal("expected an estimate")
}
if want := from.Add(60 * time.Minute); !at.Equal(want) {
t.Errorf("ETAAt = %v, want %v", at, want)
}
// The returned time keeps the caller's location, so it round-trips into the
// database in the same shape it came out.
if at.Location() != from.Location() {
t.Errorf("ETAAt changed the location from %v to %v", from.Location(), at.Location())
}
if r.Minutes != 60 {
t.Errorf("result minutes = %d, want 60", r.Minutes)
}
}
func TestETAAtFallsBackWithoutCalibration(t *testing.T) {
Reset()
from := mondayTenAM()
if _, _, ok := ETAAt(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: from}, from); ok {
t.Fatal("ETAAt answered with no calibration")
}
}
func TestHandlingTermIsAddedAndBounded(t *testing.T) {
Load([]ETACalibration{
{Scope: "zone", Zone: "641", Factor: 2.0, Handling: 15, Samples: 100},
})
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()})
if !ok || r.Minutes != 95 { // 40 × 2.0 + 15
t.Errorf("got %+v ok=%v, want 95", r, ok)
}
Reset()
Load([]ETACalibration{
{Scope: "zone", Zone: "641", Factor: 2.0, Handling: maxHandlingMinutes + 1, Samples: 100},
})
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
t.Error("served a handling term above the cap")
}
Reset()
}
func TestLoadedReportsState(t *testing.T) {
Reset()
if ok, _, _ := Loaded(); ok {
t.Error("Loaded() true after Reset")
}
Load(fixture())
defer Reset()
ok, _, cells := Loaded()
if !ok || cells != 3 { // 3 keyed cells; global is held separately
t.Errorf("Loaded() = %v, cells %d; want true, 3", ok, cells)
}
}

View File

@@ -0,0 +1,185 @@
package prediction
import (
"time"
"doormile/constants"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Refining a promise after the customer has already been given one.
//
// ─── Why this cannot happen at booking-create time ─────────────────────────
//
// The obvious place to use a calibrated ETA is where the promise is first
// written — controllers/adminController.go:2738 and
// controllers/cxPickupFanout.go:224. It does not work there, and not because
// the calibration is cold: at that moment there is structurally no routed
// duration to calibrate. The order of events is
//
// booking created -> estimateddeliveryat written from the promise table
// -> rider assigned
// -> internal/routing sequences -> etaminutes written
//
// so RoutedMinutes is 0 on every create and ETAMinutes would return false
// forever. Wiring it there would be dead code.
//
// The routed duration first exists at sequencing. So refining the promise means
// revising a number the customer may already have seen.
//
// ─── What this does and deliberately does not do ───────────────────────────
//
// It updates consignments.estimateddeliveryat — the "arrives by" the customer
// is shown — and never touches sladueat. That split is the whole design:
//
// estimateddeliveryat our best current belief. Allowed to improve.
// sladueat the commitment made at booking. Must not move, or it
// stops being a commitment and every breach can be
// explained away by moving the target.
//
// It also only ever moves the estimate when the calibration is trustworthy
// (ETAMinutes true), so with ROUTE_OPTIMIZER_URL unset — the state today —
// this function reads some rows and writes nothing.
//
// A customer watching the tracking page will see the time change. Whether that
// should also emit a stage event is an open product question
// (docs/prediction-plan.md §3a); this writes the column and does not notify,
// because inventing a notification is the more consequential of the two
// choices to get wrong.
// RefinedETA is one estimate this pass revised, for logging.
type RefinedETA struct {
Consignmentid int
Was *time.Time
Now time.Time
Source string
}
// refineRow is a consignment awaiting delivery, joined to the routed duration
// of its booking's latest sequenced assignment.
//
// The join goes through consignment_booking because consignments has no
// bookingid column, and the legacy pickupbookings.consignmentid link names only
// the FIRST order of a multi-destination pickup — hazard H4. DISTINCT ON picks
// one assignment per booking: a booking passes through several
// (Assigned/Rejected/Reassigned) and the newest sequenced one is the routed ETA
// actually in force.
type refineRow struct {
Consignmentid int
Deliverypincode string
Createdat time.Time
Estimateddeliveryat *time.Time
Etaminutes int
Sequencedat *time.Time
}
const refineSQL = `
WITH assigned AS (
SELECT DISTINCT ON (ba.bookingid)
ba.bookingid, ba.etaminutes, ba.sequencedat
FROM bookingassignments ba
WHERE ba.etaminutes > 0
ORDER BY ba.bookingid, ba.sequencedat DESC NULLS LAST, ba.bookingassignmentid DESC
)
SELECT c.consignmentid,
COALESCE(c.deliverypincode, '') AS deliverypincode,
c.createdat,
c.estimateddeliveryat,
a.etaminutes,
a.sequencedat
FROM consignments c
JOIN consignment_booking cb ON cb.consignmentid = c.consignmentid
JOIN assigned a ON a.bookingid = cb.bookingid
WHERE c.status NOT IN ?
AND c.deletedat IS NULL
ORDER BY a.sequencedat DESC NULLS LAST
LIMIT ?
`
// refineBatch caps one pass. The sweeper runs often enough that a backlog
// clears over a few ticks, and an unbounded UPDATE loop on a hot table is the
// kind of thing that shows up as a latency spike somewhere unrelated.
const refineBatch = 300
// terminal statuses — a parcel that has arrived, come back, or been written off
// has nothing left to estimate.
func terminalStatuses() []string {
return []string{
constants.ConsignmentDelivered,
"Returned_to_Sender",
"Missing",
"Damaged",
}
}
// RefineInFlight recomputes estimateddeliveryat for consignments that are still
// moving and whose rider has a routed duration. Returns what it changed.
//
// Safe to call when nothing is calibrated: ETAMinutes returns false for every
// row and this writes nothing.
func RefineInFlight(gdb *gorm.DB, now time.Time) ([]RefinedETA, error) {
if gdb == nil {
return nil, nil
}
if ok, _, _ := Loaded(); !ok {
return nil, nil // nothing to refine with; the promise tables stand
}
var rows []refineRow
if err := gdb.Raw(refineSQL, terminalStatuses(), refineBatch).Scan(&rows).Error; err != nil {
return nil, err
}
changed := make([]RefinedETA, 0, len(rows))
for _, r := range rows {
// The estimate is anchored on when sequencing happened, not on the
// consignment's creation: the routed duration describes the journey
// from the rider's current position onward.
anchor := r.Createdat
if r.Sequencedat != nil {
anchor = *r.Sequencedat
}
eta, res, ok := ETAAt(Input{
RoutedMinutes: r.Etaminutes,
DeliveryPincode: r.Deliverypincode,
At: anchor,
}, anchor)
if !ok {
continue
}
// Skip a no-op write. Without this every pass rewrites every in-flight
// row with the same value, which churns WAL and makes the log useless
// for seeing what actually moved.
if r.Estimateddeliveryat != nil && withinAMinute(*r.Estimateddeliveryat, eta) {
continue
}
if err := gdb.Model(&models.Consignment{}).
Where("consignmentid = ?", r.Consignmentid).
Update("estimateddeliveryat", eta).Error; err != nil {
utils.Warn("prediction: could not refine ETA",
"consignmentid", r.Consignmentid, "error", err)
continue
}
changed = append(changed, RefinedETA{
Consignmentid: r.Consignmentid,
Was: r.Estimateddeliveryat,
Now: eta,
Source: res.Source,
})
}
return changed, nil
}
func withinAMinute(a, b time.Time) bool {
d := a.Sub(b)
if d < 0 {
d = -d
}
return d < time.Minute
}

View File

@@ -0,0 +1,130 @@
package prediction
import (
"context"
"os"
"strconv"
"strings"
"time"
"doormile/db"
"doormile/utils"
)
// The calibration sweeper.
//
// Deliberately the same shape as internal/assignment/sweeper.go: a ticker, a
// recover() per tick so one bad run cannot take the process down, and a Redis
// lock that expires before the next tick so only one replica of three does the
// work. Without Redis every replica refreshes, which is wasteful but correct —
// Refresh replaces the table in one transaction, so concurrent runs converge
// rather than interleave.
//
// This is a nightly job by default. The calibration is a p80 over weeks of
// deliveries; recomputing it more often costs a scan and changes nothing.
const (
defaultCalibrationSweepSeconds = 6 * 60 * 60 // 6h
calibrationLockKey = "prediction:calibration:lock"
// Below this a "sweep" is a scan loop against the whole delivery history.
minCalibrationSweepSeconds = 600
)
// calibrationInterval: PREDICTION_CALIBRATION_SECONDS, default 6h; 0 turns the
// sweeper off and leaves whatever is in the store. Read once at start, matching
// how assignment's sweepInterval behaves.
func calibrationInterval() time.Duration {
if v := strings.TrimSpace(os.Getenv("PREDICTION_CALIBRATION_SECONDS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
if n > 0 && n < minCalibrationSweepSeconds {
n = minCalibrationSweepSeconds
}
return time.Duration(n) * time.Second
}
utils.Warn("PREDICTION_CALIBRATION_SECONDS is not a non-negative integer, using the default",
"value", v, "default_seconds", defaultCalibrationSweepSeconds)
}
return defaultCalibrationSweepSeconds * time.Second
}
// StartCalibrationSweeper loads whatever calibration is stored, then refreshes
// it on a timer. Call once at boot, in a goroutine.
//
// The load happens even when the sweeper is disabled: a stored calibration from
// a previous deploy is still the best available answer, and ETAMinutes will
// stop trusting it on its own once it passes staleAfter.
func StartCalibrationSweeper() {
LoadFromDB()
interval := calibrationInterval()
if interval == 0 {
utils.Info("CalibrationSweeper: disabled (PREDICTION_CALIBRATION_SECONDS=0)")
return
}
utils.Info("CalibrationSweeper: started", "interval", interval.String())
// One refresh shortly after boot rather than waiting a full interval, so a
// newly deployed replica is not serving a day-old calibration for six
// hours. Offset so three replicas do not all wake together.
time.Sleep(90 * time.Second)
refreshOnce(interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
refreshOnce(interval)
}
}
// refreshOnce makes one refresh attempt. Only one replica at a time (a Redis
// lock that expires before the next tick).
func refreshOnce(interval time.Duration) {
defer func() {
if r := recover(); r != nil {
utils.Error("CalibrationSweeper: panic recovered", "error", r)
}
}()
if db.DB == nil {
return
}
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
ttl := interval - 60*time.Second
if ttl <= 0 {
ttl = interval / 2
}
got, err := db.Rdb.SetNX(ctx, calibrationLockKey, "1", ttl).Result()
cancel()
if err == nil && !got {
return // another replica has this refresh
}
}
started := time.Now()
if err := Refresh(db.DB); err != nil {
// A failed refresh leaves the previous calibration serving. That is the
// intended behaviour: the last good factors beat falling back to flat
// constants, and staleAfter puts a bound on how long that can last.
utils.Error("CalibrationSweeper: refresh failed", "error", err,
"took_ms", time.Since(started).Milliseconds())
return
}
ok, builtAt, cells := Loaded()
utils.Info("CalibrationSweeper: refresh complete",
"loaded", ok, "cells", cells, "built_at", builtAt,
"took_ms", time.Since(started).Milliseconds())
// Apply the fresh calibration to parcels still in flight. Writes
// estimateddeliveryat only — never sladueat, which is the commitment made
// at booking (see internal/prediction/refine.go). A no-op while nothing is
// calibrated.
refined, err := RefineInFlight(db.DB, time.Now())
if err != nil {
utils.Error("CalibrationSweeper: ETA refine failed", "error", err)
return
}
if len(refined) > 0 {
utils.Info("CalibrationSweeper: refined in-flight ETAs", "count", len(refined))
}
}

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
}

60
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"errors"
"net/url"
"os"
@@ -12,8 +13,13 @@ import (
"doormile/config"
"doormile/controllers"
"doormile/db"
"doormile/internal/ai/outcomes"
"doormile/internal/ai/playground"
"doormile/internal/ai/telemetry"
"doormile/internal/assignment"
"doormile/internal/milergeo"
"doormile/internal/notify"
"doormile/internal/prediction"
"doormile/internal/routing"
"doormile/internal/sms"
"doormile/internal/worker"
@@ -85,11 +91,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,9 +241,44 @@ 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()
// Recalibrates the ETA estimate from the system's own past predictions
// (bookingassignments.etaminutes) against what actually happened. Until
// ROUTE_OPTIMIZER_URL is configured there is nothing to calibrate, the
// calibration stays empty, and every ETA comes from the existing promise
// tables exactly as before. See docs/prediction-plan.md rung 1.1.
go prediction.StartCalibrationSweeper()
// Judges what the engine's past decisions actually achieved, which is what
// makes them retrievable: /internal/agent-decisions/similar filters on
// `outcome IS NOT NULL`, so an unjudged decision is invisible to recall.
// Also prunes past the retention window — this table had none.
// Skill-finding retention rides the outcome sweeper's tick. Assigned here
// rather than called directly, to keep internal/ai/outcomes free of a
// controllers import.
outcomes.PruneFindings = controllers.PruneAIFindings
go outcomes.StartOutcomeSweeper()
// 9. Point the stop sequencer at the Route Optimization API.
routing.BaseURL = cfg.RouteOptimizerURL
// Where AI_engine's health surface lives, for the console's agent-status
// proxy. Empty disables it and the console keeps using its snapshot.
controllers.AIEngineBaseURL = cfg.AIEngineBaseURL
// 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() {

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"
@@ -41,6 +42,8 @@ func Migrate(db *gorm.DB) error {
&models.CarrierPricing{},
&models.DoormilePricing{},
&models.AgentDecision{},
&models.AISkillFinding{},
&models.DemandForecast{},
&models.HubStaffAccount{},
&models.HubConversation{},
&models.HubMessage{},
@@ -60,6 +63,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 +84,30 @@ 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())
}
// pgvector must exist before the vector column can be declared. This was
// missing: the ALTER TABLE below has always run against a database where
// the extension was installed by hand, and its failure is logged
// non-fatally — so on any database where it was not, the column and its
// index silently never existed and every similarity query 500s.
//
// Creating an extension needs rights a plain application role may not
// have. A failure here is logged and not fatal for the same reason the
// column add is not: the registry and decision log are not on a booking's
// path, and the API must keep serving orders.
if res := db.Exec(`CREATE EXTENSION IF NOT EXISTS vector`); res.Error != nil {
utils.Error("⚠️ pgvector extension unavailable — decision memory and similarity search are disabled",
"error", res.Error)
} else {
utils.Info("✅ pgvector extension ready")
}
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 {
@@ -149,5 +188,105 @@ func Migrate(db *gorm.DB) error {
utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready")
}
// Reverse logistics for clients onboarded before the field existed.
//
// They have no delivery category and were all using returns, so they must
// keep them — new information must not withdraw a working capability. Done
// as a one-off backfill rather than a column default: a default makes GORM
// omit an explicit `false` on every future INSERT, which is how Food
// clients were silently created with returns enabled.
//
// Guarded on deliverycategory being empty, so it only ever touches rows
// that predate the field and can never re-enable returns for a client an
// operator has deliberately switched off.
if res := db.Exec(`UPDATE tenants SET reverselogisticsenabled = true
WHERE COALESCE(deliverycategory, '') = ''`); res.Error != nil {
utils.Error("❌ Failed to backfill tenants.reverselogisticsenabled", "error", res.Error)
} else if res.RowsAffected > 0 {
utils.Info("✅ reverse logistics kept on for pre-existing clients", "rows", res.RowsAffected)
}
// ── ETA calibration (docs/prediction-plan.md rung 1.1) ──────────────────
//
// consignment_booking resolves a consignment to its booking. There is no
// consignments.bookingid column, and the link runs two ways: through
// bookingdestinations for multi-destination customer pickups, and through
// the legacy pickupbookings.consignmentid for console/express bookings and
// anything written before the fan-out existed. That legacy column names
// only the FIRST order of a multi-destination pickup, which is why the
// bookingdestinations path is tried first and the fallback is guarded.
//
// Every query that needs a consignment's booking goes through this view.
// Joining consignments to bookings by hand attaches features to the wrong
// parcel for multi-destination bookings, silently — the defect CLAUDE.md
// §8.5 describes and docs/prediction-plan.md records as hazard H4.
if res := db.Exec(`CREATE OR REPLACE VIEW consignment_booking AS
SELECT c.consignmentid,
COALESCE(bd.bookingid, pb.bookingid) AS bookingid,
bd.bookingdestinationid
FROM consignments c
LEFT JOIN bookingdestinations bd ON bd.consignmentid = c.consignmentid
LEFT JOIN pickupbookings pb ON pb.consignmentid = c.consignmentid
AND bd.bookingid IS NULL`); res.Error != nil {
utils.Error("❌ Failed to create consignment_booking view", "error", res.Error)
} else {
utils.Info("✅ consignment_booking view ready")
}
// etacalibration holds the grouped p80 of actual-over-routed duration that
// internal/prediction multiplies a routed ETA by. Written only by the
// calibration sweeper, read at boot. Empty is the normal state until
// ROUTE_OPTIMIZER_URL is configured and deliveries accumulate — the
// estimator falls back to the existing promise tables while it is.
if res := db.Exec(`CREATE TABLE IF NOT EXISTS etacalibration (
calibrationid SERIAL PRIMARY KEY,
scope VARCHAR(24) NOT NULL,
zone VARCHAR(8),
hourbucket INTEGER,
weekday INTEGER,
factor DOUBLE PRECISION NOT NULL,
handling DOUBLE PRECISION NOT NULL DEFAULT 0,
samples INTEGER NOT NULL,
refreshedat TIMESTAMPTZ NOT NULL
)`); res.Error != nil {
utils.Error("❌ Failed to create etacalibration table", "error", res.Error)
} else {
utils.Info("✅ etacalibration table ready")
}
if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_etacalibration_lookup
ON etacalibration (scope, zone, hourbucket, weekday)`); res.Error != nil {
utils.Error("❌ Failed to create etacalibration lookup index", "error", res.Error)
} else {
utils.Info("✅ etacalibration lookup index ready")
}
// The calibration scan joins deliveries to their assignment. Without this
// the refresh sequential-scans consignmenthistory on every run.
//
// CONCURRENTLY is not optional here. A plain CREATE INDEX takes a SHARE
// lock, which blocks INSERTs for as long as the build takes — and
// consignmenthistory gets a row on EVERY parcel status change. On a table
// of any size that means riders cannot complete deliveries while the
// migration runs: a revenue-path outage caused by an index for a
// background job that is switched off by default.
//
// The tradeoffs CONCURRENTLY brings, and why they are acceptable:
// - It cannot run inside a transaction. db.Exec is autocommit, so this
// is fine, but it must never be moved inside a tx block.
// - It can fail and leave an INVALID index behind, which is then not
// used by the planner and must be dropped by hand. That degrades the
// calibration scan to a sequential one; it does not affect any
// booking. The log line below names the recovery.
if res := db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_consignmenthistory_status_consignment
ON consignmenthistory (eventstatus, consignmentid)`); res.Error != nil {
utils.Error("⚠️ consignmenthistory index not created — the ETA calibration scan will be slower. "+
"If this left an INVALID index, drop it before retrying: "+
"DROP INDEX IF EXISTS idx_consignmenthistory_status_consignment",
"error", res.Error)
} else {
utils.Info("✅ consignmenthistory (eventstatus, consignmentid) index ready")
}
return nil
}

View File

@@ -6,6 +6,19 @@ type AgentDecision struct {
ID uint64 `gorm:"primaryKey"`
DecisionType string `gorm:"size:50;index"`
BookingID *uint64 `gorm:"index"`
// TenantID scopes retrieval. Without it, similarity search would return one
// client's operational history as precedent for a decision about another —
// and because the engine feeds that precedent to the model, one tenant's
// past would shape another tenant's dispatch.
//
// Nullable because the engine does not always know the tenant (a B2C
// booking carries a nil tenantid by design). A NULL row is recallable only
// by a NULL-tenant query, never by a tenant's own — the safer default,
// given console logins are already unscoped when tenantid is NULL.
//
// Added before the table filled on purpose: adding it afterwards is a
// backfill with no way to attribute the rows already there.
TenantID *uint64 `gorm:"column:tenantid;index"`
Context string `gorm:"type:jsonb"`
Decision string `gorm:"type:jsonb"`
Reasoning string `gorm:"type:text"`

92
models/ai_findings.go Normal file
View File

@@ -0,0 +1,92 @@
package models
import "time"
// AISkillFinding is one thing a console ops skill noticed.
//
// ─── Why this table exists ─────────────────────────────────────────────────
//
// The eight rule skills (SlaGuardian, DoorstepStall, FleetBalancer,
// HighValueCod, RiderBatterySafety, HubCongestion, LateDispatch, CashExposure)
// run in the operator's browser, on a 60-second React Query interval, and their
// findings were thrown away. Nothing persisted them, so three questions had no
// answer at all:
//
// Has this booking been flagged before, and how many times?
// Which skills fire most, and which are ignored every time they do?
// Did the proposal an operator carried out actually clear the finding?
//
// The third is the one that matters. The console already re-runs its scan after
// executing a proposal to see whether the finding disappears — that evidence
// existed for one render and then vanished. Persisting it turns "the skills seem
// useful" into something measurable, and it is the raw material for any later
// per-rider or per-zone memory.
//
// ─── What this is NOT ──────────────────────────────────────────────────────
//
// Not an exception. `consignmentexceptions` records a real operational problem
// with a parcel (Lost, Damaged, Misrouted) raised by a person. A finding is a
// rule noticing a pattern, most of which resolve themselves without anyone
// doing anything. Writing findings into that table would flood a queue people
// are supposed to work.
//
// Not a decision either: `agent_decisions` is the engine's model-made choices,
// with embeddings for retrieval. A finding is deterministic and carries no
// vector.
type AISkillFinding struct {
Findingid int `json:"findingid" gorm:"primaryKey;column:findingid;autoIncrement"`
// Skillid matches aiskills.skillid, so a finding can be grouped by the rule
// that raised it and read alongside the thresholds in force at the time.
Skillid string `json:"skillid" gorm:"column:skillid;size:64;not null;index:idx_aiskillfindings_skill_time,priority:1"`
// Fingerprint is what makes a finding the SAME finding across polls. The
// skills re-evaluate every 60 seconds and will re-raise an unchanged
// problem every time; without this the table would grow by the number of
// open findings per minute per operator with the page open.
//
// Computed by the console from the skill id and the scope's booking ids —
// deliberately not including severity or counts, which drift while the
// underlying problem stays the same.
Fingerprint string `json:"fingerprint" gorm:"column:fingerprint;size:120;not null;uniqueIndex:uq_aiskillfindings_fingerprint"`
Severity string `json:"severity" gorm:"column:severity;size:20"` // critical, warning, info
Title string `json:"title" gorm:"column:title"`
// Proposaltool is the verb the finding proposes, e.g. notifyRider,
// assignMiler, enforce_cash_handoff. Matches aitools.toolname where one
// exists; review-only verbs are recorded too, which is how "this skill
// keeps proposing something nobody can act on" becomes visible.
Proposaltool string `json:"proposaltool" gorm:"column:proposaltool;size:64;index"`
// Bookingcount and Scope: the count is for grouping, the scope is the
// booking ids as a JSON array so a specific parcel's history can be found.
// Not a join table — a finding's scope is read whole or not at all, and a
// row per booking per finding would multiply this table by average scope
// size for no query anyone needs.
Bookingcount int `json:"bookingcount" gorm:"column:bookingcount;default:0"`
Scope string `json:"scope" gorm:"column:scope;type:jsonb"`
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
// Firstseenat vs Lastseenat: an upsert on fingerprint bumps the last-seen
// and the count, so how LONG a finding has been open is answerable. That is
// the actual signal of an ignored finding — not that it exists, but that it
// has existed for six hours.
Firstseenat time.Time `json:"firstseenat" gorm:"column:firstseenat;not null;index:idx_aiskillfindings_skill_time,priority:2"`
Lastseenat time.Time `json:"lastseenat" gorm:"column:lastseenat;not null"`
Seencount int `json:"seencount" gorm:"column:seencount;default:1"`
// Actedat / Actedby / Actionresult record an operator carrying out the
// proposal. Null means nobody did — which is data, not a gap.
Actedat *time.Time `json:"actedat" gorm:"column:actedat"`
Actedby *int `json:"actedby" gorm:"column:actedby"`
Actionresult string `json:"actionresult" gorm:"column:actionresult;size:20"` // ok, partial, failed
// Clearedat is set when a later scan no longer raises this fingerprint.
// Paired with Actedat it answers the question the whole table is for: did
// acting clear it, or did it clear on its own?
Clearedat *time.Time `json:"clearedat" gorm:"column:clearedat"`
}
func (AISkillFinding) TableName() string { return "aiskillfindings" }

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

53
models/demand_forecast.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import "time"
// DemandForecast is one zone-day the engine expects.
//
// ─── Why this is stored rather than computed on read ───────────────────────
//
// The forecast is produced in Python (AI_engine/prediction), because the model
// is: Prophet where it beats a seasonal baseline, the baseline otherwise. The
// console and any staffing decision need it in Go. So the engine writes here
// and the backend serves it — the same split the agent registry and the
// decision log already use, and the reason the /internal/* surface exists.
//
// It is also the honest shape: a forecast is a thing produced at a moment by a
// model, not a function of the current table. Recomputing it on every read
// would make yesterday's number unrecoverable, which is exactly what you want
// when asking "was the forecast any good".
type DemandForecast struct {
Forecastid int `json:"forecastid" gorm:"primaryKey;column:forecastid;autoIncrement"`
// Zone is the first three digits of a pickup pincode — the same grain the
// hub console scopes on (pickuppincode LIKE '641%') and the same grain
// internal/prediction calibrates ETA at. Keeping one definition of "zone"
// across both is deliberate.
Zone string `json:"zone" gorm:"column:zone;size:8;not null;uniqueIndex:uq_demandforecast_zone_day,priority:1"`
// Forday is the day being predicted, not the day it was predicted on.
Forday time.Time `json:"forday" gorm:"column:forday;not null;uniqueIndex:uq_demandforecast_zone_day,priority:2;index"`
Expectedbookings int `json:"expectedbookings" gorm:"column:expectedbookings;not null"`
// Model and Reason record WHICH model produced this and why it was chosen —
// "prophet beat the weekly baseline over 12 folds (18% lower MAE)", or
// "prophet is not installed in this image". Stored because the choice is
// made per zone from that zone's own history, so without it nobody can tell
// whether a bad forecast came from a bad model or from a thin series.
Model string `json:"model" gorm:"column:model;size:32;not null"`
Reason string `json:"reason" gorm:"column:reason"`
// Observations is how many days of history the forecast was fitted on, and
// Baselinemae/Modelmae are the backtest scores. A forecast with 31
// observations and a model barely beating the baseline deserves less trust
// than one with 400, and this is what lets a reader see that rather than
// taking the number at face value.
Observations int `json:"observations" gorm:"column:observations;default:0"`
Baselinemae *float64 `json:"baselinemae" gorm:"column:baselinemae"`
Modelmae *float64 `json:"modelmae" gorm:"column:modelmae"`
Generatedat time.Time `json:"generatedat" gorm:"column:generatedat;not null"`
}
func (DemandForecast) TableName() string { return "demandforecast" }

View File

@@ -15,10 +15,58 @@ type Tenant struct {
// Defaults off: turning it on platform-wide would block deliveries for
// clients whose customer app has no way to show the code yet.
Requiredeliveryotp bool `json:"requiredeliveryotp" gorm:"column:requiredeliveryotp;default:false"`
// Deliverycategory is WHAT this client ships, from the same vocabulary
// pricing uses (constants.DeliveryCategories) — a tenant whose category is
// not a pricing category cannot be priced, so the two are one list.
//
// It decides whether reverse logistics applies: a returned meal is waste,
// not inventory, so Food clients get no RTO path. See
// constants.ReverseLogisticsAllowed for the reasoning.
//
// Empty on every tenant onboarded before this field existed, and empty
// must keep behaving as it always did — which is why
// ReverseLogisticsAllowed treats an unknown category as returnable rather
// than defaulting the new flag to off.
Deliverycategory string `json:"deliverycategory" gorm:"column:deliverycategory;size:32"`
// Reverselogisticsenabled is the OPERATIONAL consequence, stored rather
// than derived on every read.
//
// Two reasons it is its own column. First, an operator may need to turn
// returns off for a non-Food client (a clearance line that is final sale)
// or on for a Food client (a caterer who takes back equipment), and a
// derived value cannot be overridden. Second, deriving it would mean that
// editing a client's category silently changes whether live parcels can be
// returned — a stored flag makes that an explicit second decision.
// A POINTER, deliberately, and this is load-bearing.
//
// GORM omits a zero-value field from an INSERT when the tag declares a
// default — so a plain `bool` set to false was silently dropped and the
// column default (true) applied, creating Food clients with reverse
// logistics ENABLED: the exact case this feature exists to prevent,
// failing silently. A test caught it.
//
// Dropping the default instead is worse: ADD COLUMN ... NOT NULL with no
// default fails outright on a populated tenants table.
//
// A pointer gives both. nil means "not stated" and the column default
// (true) applies — which is what every row predating this field wants. A
// non-nil false is written as false, because GORM never omits a non-nil
// pointer. Read it through ReturnsEnabled(), never directly.
Reverselogisticsenabled *bool `json:"reverselogisticsenabled" gorm:"column:reverselogisticsenabled;default:true"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
// ReturnsEnabled is the one way to read Reverselogisticsenabled.
//
// nil means the client predates the field, and those clients were all using
// returns — so nil is TRUE. Reading the pointer directly invites a nil deref
// or, worse, treating "not stated" as "disabled" and silently withdrawing a
// capability a client is already using.
func (t Tenant) ReturnsEnabled() bool {
return t.Reverselogisticsenabled == nil || *t.Reverselogisticsenabled
}
func (Tenant) TableName() string {
return "tenants"
}

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,
"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)
@@ -370,6 +396,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/milers/:id/activity", controllers.GetMilerActivity)
adminAuth.Put("/milers/:id", controllers.UpdateMiler)
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
adminAuth.Put("/milers/:id/unblock", controllers.UnblockMiler)
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
adminAuth.Post("/milers/:id/notify", controllers.AdminNotifyMiler)
@@ -388,42 +415,59 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
adminAuth.Post("/bookings/:id/cancel", controllers.AdminCancelBooking)
adminAuth.Post("/bookings/bulk-cancel", controllers.AdminBulkCancelBookings)
// Batch assign, admin side. The same solver /hub/bookings/batch-assign
// uses (controllers/batchAssignService.go), with admin auth and scoped to
// the login's own tenant. This is what backs the console ops layer's
// `assignMiler` proposal, which had no executor because the hub route
// 403s for every admin token and /bookings/:id/assign-miler needs a rider
// the finding does not pick.
adminAuth.Post("/bookings/batch-assign", middlewares.DoormileStaffOnly, controllers.AdminBatchAssign)
// 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 +477,50 @@ 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 console's rule skills have been noticing. The findings used to
// exist for one render in the operator's browser and then vanish, so
// nothing could say whether a skill was useful or whether acting on a
// finding cleared it. Writes are open to the same roles as reads here:
// the console reports what it evaluated, it is not an operator action.
aiRegistry.Get("/findings", controllers.GetAIFindings)
aiRegistry.Get("/findings/stats", controllers.GetAIFindingStats)
aiRegistry.Post("/findings", controllers.UpsertAIFindings)
aiRegistry.Post("/findings/:fingerprint/acted", controllers.RecordAIFindingActed)
// Tomorrow's expected pickups per zone, with the staffing gap against
// riders actually on duty. A bare forecast number is not actionable — the
// gap is (docs/prediction-plan.md §2.4).
aiRegistry.Get("/forecast/demand", controllers.GetDemandForecast)
// The engine's live agent state, proxied. The console's Agents page cannot
// reach :8700 itself (ClusterIP, and it is a browser). A 503 here means
// "use the snapshot", not "broken" — see the controller.
aiRegistry.Get("/engine/agents", controllers.GetAIEngineAgents)
// 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
@@ -530,8 +615,19 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
internal.Post("/notify", controllers.InternalNotify)
internal.Post("/bookings/:id/reassign", controllers.InternalReassign)
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
// POST, not GET: the handler needs a 1536-float embedding in the body, and
// a GET with a body is dropped by nginx and most HTTP clients — and this
// API is served THROUGH host nginx today (conf/nginx-doormile.conf), so it
// would not merely be risky, it would not work. Nothing called it while it
// was a GET, so changing the method breaks no caller.
internal.Post("/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)
// The demand forecast, written by AI_engine's forecast job. The model lives
// in Python (prophet where it beats a seasonal baseline, the baseline
// otherwise); the backend stores and serves it.
internal.Post("/demand-forecast", controllers.UpsertDemandForecast)
// 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,239 @@
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",
// Added with the agent-platform work. Listed here so the existing
// invariants cover them: every /admin/ai read must require a login and
// must refuse miler, hub-staff and customer roles. A new route that skips
// this table is a new route nobody proved is gated.
"/api/v1/admin/ai/findings",
"/api/v1/admin/ai/findings/stats",
"/api/v1/admin/ai/forecast/demand",
"/api/v1/admin/ai/engine/agents",
}
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"}`},
}
// Finding reports are NOT registry mutations, and deliberately not role-1-only.
//
// The aiWrites table above encodes "changing the registry is an operator
// decision, so role 1 only". Reporting what the rule skills noticed is a
// different thing: it is telemetry from the Exceptions page, which managers (3)
// and executives (4) open as part of their job. Gating it to role 1 would mean
// a manager's session silently reported nothing, and the "how long has this
// finding been open" measurement would depend on who happened to be logged in.
//
// What it must still refuse is everyone outside the console: miler, hub staff
// and customer tokens.
var aiFindingWrites = []struct{ method, path, body string }{
{http.MethodPost, "/api/v1/admin/ai/findings", `{"findings":[],"cleared":[]}`},
{http.MethodPost, "/api/v1/admin/ai/findings/abc/acted", `{"result":"ok"}`},
}
func TestFindingReportsAreOpenToConsoleRolesButNotOutsiders(t *testing.T) {
app := newApp()
for _, w := range aiFindingWrites {
// No token at all is refused.
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)
}
// Outside the console: refused.
for _, role := range []int{5, 6, 9} { // miler, hub staff, customer
if code, _ := do(t, app, w.method, w.path, token(t, 1, role), w.body); code != http.StatusForbidden && code != http.StatusUnauthorized {
t.Errorf("%s %s as role %d = %d, want 401/403", w.method, w.path, role, code)
}
}
// Console roles: NOT forbidden. The handler may still fail without a
// database in this app; what matters here is that authorisation let it
// through rather than stopping it.
for _, role := range []int{1, 3, 4} {
if code, _ := do(t, app, w.method, w.path, token(t, 1, role), w.body); code == http.StatusForbidden {
t.Errorf("%s %s as role %d = 403; the Exceptions page must be able to report findings", w.method, w.path, role)
}
}
}
}
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,"deliverycategory":"Clothing"` + 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)
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More