From 0ac5d3d54ff244840cee214715a00ad0d0b54dc2 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 30 Sep 2026 14:47:58 +0530 Subject: [PATCH] updates on the ai and agent and all thse things awith onboarding --- config/config.go | 55 + config/config_test.go | 33 + controllers/aiInsightsController.go | 64 + controllers/aiPlaygroundController.go | 127 ++ controllers/aiRegistryController.go | 215 ++ controllers/clientOnboardingController.go | 566 +++++ controllers/clientOnboarding_test.go | 106 + docs/customer-app-api-reference.md | 1877 +++++++++++++++++ internal/ai/playground/openai_compat.go | 238 +++ internal/ai/playground/openai_compat_test.go | 130 ++ internal/ai/playground/playground.go | 341 +++ internal/ai/playground/playground_test.go | 280 +++ internal/ai/playground/redact.go | 78 + internal/ai/playground/tools.go | 178 ++ .../ai/playground/tools_integration_test.go | 82 + internal/ai/registry/registry_test.go | 480 +++++ internal/ai/registry/rules.go | 134 ++ internal/ai/registry/seed.go | 304 +++ internal/ai/registry/store.go | 389 ++++ .../ai/registry/store_integration_test.go | 280 +++ internal/ai/registry/thresholds.go | 152 ++ internal/ai/telemetry/insights.go | 228 ++ internal/ai/telemetry/parse.go | 146 ++ internal/ai/telemetry/recorder.go | 169 ++ .../ai/telemetry/store_integration_test.go | 135 ++ internal/ai/telemetry/telemetry_test.go | 177 ++ internal/testpg/testpg.go | 48 + main.go | 24 + middlewares/onboarding_owner.go | 39 + middlewares/staff_only.go | 21 + migrations/migrate.go | 20 + models/ai_registry.go | 123 ++ models/ai_runs.go | 35 + routes/routes.go | 35 + routes/routes_ai_registry_pg_test.go | 151 ++ routes/routes_ai_registry_test.go | 190 ++ routes/routes_client_onboarding_test.go | 105 + 37 files changed, 7755 insertions(+) create mode 100644 controllers/aiInsightsController.go create mode 100644 controllers/aiPlaygroundController.go create mode 100644 controllers/aiRegistryController.go create mode 100644 controllers/clientOnboardingController.go create mode 100644 controllers/clientOnboarding_test.go create mode 100644 docs/customer-app-api-reference.md create mode 100644 internal/ai/playground/openai_compat.go create mode 100644 internal/ai/playground/openai_compat_test.go create mode 100644 internal/ai/playground/playground.go create mode 100644 internal/ai/playground/playground_test.go create mode 100644 internal/ai/playground/redact.go create mode 100644 internal/ai/playground/tools.go create mode 100644 internal/ai/playground/tools_integration_test.go create mode 100644 internal/ai/registry/registry_test.go create mode 100644 internal/ai/registry/rules.go create mode 100644 internal/ai/registry/seed.go create mode 100644 internal/ai/registry/store.go create mode 100644 internal/ai/registry/store_integration_test.go create mode 100644 internal/ai/registry/thresholds.go create mode 100644 internal/ai/telemetry/insights.go create mode 100644 internal/ai/telemetry/parse.go create mode 100644 internal/ai/telemetry/recorder.go create mode 100644 internal/ai/telemetry/store_integration_test.go create mode 100644 internal/ai/telemetry/telemetry_test.go create mode 100644 internal/testpg/testpg.go create mode 100644 middlewares/onboarding_owner.go create mode 100644 middlewares/staff_only.go create mode 100644 models/ai_registry.go create mode 100644 models/ai_runs.go create mode 100644 routes/routes_ai_registry_pg_test.go create mode 100644 routes/routes_ai_registry_test.go create mode 100644 routes/routes_client_onboarding_test.go diff --git a/config/config.go b/config/config.go index d5b6120..69243f3 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "strings" ) type Config struct { @@ -49,6 +50,20 @@ type Config struct { SMTPUser string SMTPPassword string SMTPFrom string + + // ClientOnboardingOwners are the console logins allowed to onboard a new + // client (tenant + its console login). Comma-separated emails, compared + // case-insensitively. Deliberately a short allow-list rather than a role: + // every Doormile admin has roleid 1, and onboarding mints credentials. + ClientOnboardingOwners []string + + // The Agent Studio Test playground's model: any OpenAI-compatible chat + // completions API (Groq by default; xAI works too). Empty API key leaves + // the playground off — the endpoint answers 503 PLAYGROUND_NOT_CONFIGURED. + // Set the key as a secret in the deployment, never in a tracked file. + PlaygroundLLMBaseURL string + PlaygroundLLMAPIKey string + PlaygroundLLMModel string } func Load() *Config { @@ -79,12 +94,52 @@ func Load() *Config { SMTPUser: getEnv("SMTP_USER", ""), SMTPPassword: getEnv("SMTP_PASSWORD", ""), SMTPFrom: getEnv("SMTP_FROM", ""), + + ClientOnboardingOwners: splitEmails(getEnv("CLIENT_ONBOARDING_OWNERS", "admin@doormile.com")), + + PlaygroundLLMBaseURL: getEnv("PLAYGROUND_LLM_BASE_URL", "https://api.groq.com/openai/v1"), + PlaygroundLLMAPIKey: getEnv("PLAYGROUND_LLM_API_KEY", ""), + PlaygroundLLMModel: getEnv("PLAYGROUND_LLM_MODEL", "openai/gpt-oss-120b"), } } +// requiredInProduction are the secrets whose development fallback above is a +// literal committed to this repository. In production a missing one must stop +// the boot: falling back would sign every token with a JWT secret anyone with +// the source can read, and connect with a published password. +var requiredInProduction = []string{"JWT_SECRET_KEY", "DB_PASSWORD", "NATS_PASSWORD"} + +// MissingProductionSecrets names each required secret that is unset when +// ENV=production. Always empty in any other environment, so local development +// keeps running on the fallbacks. +func (c *Config) MissingProductionSecrets() []string { + if !strings.EqualFold(c.Env, "production") { + return nil + } + var missing []string + for _, key := range requiredInProduction { + if os.Getenv(key) == "" { + missing = append(missing, key) + } + } + return missing +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } + +// splitEmails parses a comma-separated email list: trimmed, lower-cased, +// blanks dropped. +func splitEmails(v string) []string { + var out []string + for _, e := range strings.Split(v, ",") { + if e = strings.ToLower(strings.TrimSpace(e)); e != "" { + out = append(out, e) + } + } + return out +} diff --git a/config/config_test.go b/config/config_test.go index 9c8e35a..41a907d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) { diff --git a/controllers/aiInsightsController.go b/controllers/aiInsightsController.go new file mode 100644 index 0000000..d3fff08 --- /dev/null +++ b/controllers/aiInsightsController.go @@ -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))) +} diff --git a/controllers/aiPlaygroundController.go b/controllers/aiPlaygroundController.go new file mode 100644 index 0000000..7a00568 --- /dev/null +++ b/controllers/aiPlaygroundController.go @@ -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) +} diff --git a/controllers/aiRegistryController.go b/controllers/aiRegistryController.go new file mode 100644 index 0000000..4f397fd --- /dev/null +++ b/controllers/aiRegistryController.go @@ -0,0 +1,215 @@ +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 + } + 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) +} diff --git a/controllers/clientOnboardingController.go b/controllers/clientOnboardingController.go new file mode 100644 index 0000000..f575382 --- /dev/null +++ b/controllers/clientOnboardingController.go @@ -0,0 +1,566 @@ +package controllers + +import ( + "errors" + "net/mail" + "regexp" + "strings" + "time" + "unicode/utf8" + + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// Client onboarding: one call creates everything a new client needs to sign in +// to the console, in one transaction — +// +// tenants the client company (the tenant every booking is scoped to) +// doormile_auth the console login LoginAdmin checks: email, bcrypt hash, +// role "manager", tenantid = the new tenant +// appusers the user row LoginAdmin reads the userid and name from, +// roleid 3, tenantid = the new tenant +// +// A client needs all three: an appusers row alone cannot log in (LoginAdmin +// authenticates against doormile_auth), and a doormile_auth row without a +// tenantid would be Doormile STAFF — unscoped, seeing every client's data. +// +// The client login gets role "manager" (roleid 3), not "admin": nothing a +// client does needs roleid 1, and roleid 1 is what gates the agent-registry +// writes. Their data scope comes from the tenantid in the token. +// +// Routes sit behind ClientOnboardingOwnerOnly (see routes.go). + +const clientLoginRole = "manager" +const clientLoginRoleID = 3 + +var indianMobile = regexp.MustCompile(`^[6-9]\d{9}$`) + +type onboardClientRequest struct { + Companyname string `json:"companyname"` + Contactname string `json:"contactname"` + Email string `json:"email"` + Phone string `json:"phone"` + Password string `json:"password"` + Applocationid int `json:"applocationid"` + Requiredeliveryotp bool `json:"requiredeliveryotp"` +} + +// normalisePhone strips spaces, dashes and a +91/91/0 prefix. +func normalisePhone(p string) string { + p = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(strings.TrimSpace(p)) + p = strings.TrimPrefix(p, "+91") + if len(p) == 12 && strings.HasPrefix(p, "91") { + p = p[2:] + } + if len(p) == 11 && strings.HasPrefix(p, "0") { + p = p[1:] + } + return p +} + +// validate normalises the request in place and returns an operator-readable +// message for the first problem, or "". +func (r *onboardClientRequest) validate() string { + r.Companyname = strings.Join(strings.Fields(r.Companyname), " ") + r.Contactname = strings.Join(strings.Fields(r.Contactname), " ") + r.Email = strings.ToLower(strings.TrimSpace(r.Email)) + r.Phone = normalisePhone(r.Phone) + + switch n := utf8.RuneCountInString(r.Companyname); { + case n < 2: + return "company name is required" + case n > 120: + return "company name is too long (at most 120 characters)" + } + if utf8.RuneCountInString(r.Contactname) < 2 || utf8.RuneCountInString(r.Contactname) > 80 { + return "contact person's name is required (at most 80 characters)" + } + if addr, err := mail.ParseAddress(r.Email); err != nil || addr.Address != r.Email || !strings.Contains(r.Email[strings.LastIndex(r.Email, "@"):], ".") { + return "enter a valid email address" + } + if !indianMobile.MatchString(r.Phone) { + return "enter a valid 10-digit mobile number" + } + switch n := utf8.RuneCountInString(r.Password); { + case n < 8: + return "password must be at least 8 characters" + case n > 72: // bcrypt ignores everything past 72 bytes + return "password is too long (at most 72 characters)" + } + if strings.EqualFold(r.Password, r.Email) || strings.EqualFold(r.Password, r.Phone) { + return "password must not be the email or the phone number" + } + if r.Applocationid <= 0 { + return "choose the client's operating city" + } + return "" +} + +// errOnboardingConflict carries a 409 message out of the transaction. +type errOnboardingConflict struct{ msg string } + +func (e errOnboardingConflict) Error() string { return e.msg } + +func isUniqueViolation(err error) bool { + s := err.Error() + return strings.Contains(s, "23505") || strings.Contains(strings.ToLower(s), "duplicate key") +} + +// onboardingOwnerStillValid re-reads the caller's doormile_auth row: still an +// admin, still Doormile staff. The middleware checked the token; this checks +// the account behind it has not been removed or demoted since it was issued. +func onboardingOwnerStillValid(email string) bool { + var n int64 + db.DB.Model(&models.DoormileAuth{}). + Where("LOWER(email) = ? AND role = ? AND tenantid IS NULL", strings.ToLower(email), "admin"). + Count(&n) + return n == 1 +} + +// OnboardClient — POST /admin/clients/onboard +func OnboardClient(c *fiber.Ctx) error { + actor := actorOf(c) + if !onboardingOwnerStillValid(actor.Email) { + return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account") + } + + req := new(onboardClientRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if msg := req.validate(); msg != "" { + return utils.BadRequest(c, msg) + } + + 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 + + err = db.DB.Transaction(func(tx *gorm.DB) error { + var city models.AppLocation + if err := tx.Where("applocationid = ?", req.Applocationid).First(&city).Error; err != nil { + return errOnboardingConflict{"that operating city does not exist"} + } + + var n int64 + tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?)", req.Companyname).Count(&n) + if n > 0 { + return errOnboardingConflict{"a client with this company name already exists"} + } + tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ?", req.Email).Count(&n) + if n > 0 { + return errOnboardingConflict{"this email already has a console login"} + } + tx.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&n) + if n > 0 { + return errOnboardingConflict{"this email is already used by another user"} + } + + tenant = models.Tenant{ + Tenantname: req.Companyname, + Primaryemail: req.Email, + Primarycontact: req.Phone, + Status: "Active", + Requiredeliveryotp: req.Requiredeliveryotp, + } + if err := tx.Create(&tenant).Error; err != nil { + return err + } + + tenantID := tenant.Tenantid + 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, + }, + "login": fiber.Map{ + "email": auth.Email, + "role": auth.Role, + "userid": user.Userid, + "name": user.Authname, + "tenantid": tenant.Tenantid, + }, + }) +} + +type onboardedClient struct { + Authid uint64 `json:"authid"` + Tenantid int `json:"tenantid"` + Tenantname string `json:"tenantname"` + Primaryemail string `json:"primaryemail"` + Primarycontact string `json:"primarycontact"` + Status string `json:"status"` + Requiredeliveryotp bool `json:"requiredeliveryotp"` + Contactname string `json:"contactname"` + Loginemail string `json:"loginemail"` + Loginrole string `json:"loginrole"` + Logincreatedat *time.Time `json:"logincreatedat"` +} + +// realTime drops the zero/placeholder timestamps some older logins carry (they +// render as "1 Jan 0001"), so the console shows "—" instead of a fake date. +func realTime(t *time.Time) *time.Time { + if t == nil || t.Year() < 2000 { + return nil + } + return t +} + +// GetOnboardedClients — GET /admin/clients/onboarded: the clients that have a +// console login, newest first. One row per login. Never returns a password hash. +func GetOnboardedClients(c *fiber.Ctx) error { + if !onboardingOwnerStillValid(actorOf(c).Email) { + return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account") + } + var rows []onboardedClientRow + err := db.DB.Table("doormile_auth AS a"). + Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status, + t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname, + a.email AS loginemail, a.role AS loginrole, + a.created_at AS authcreatedat, t.createdat AS tenantcreatedat`). + 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"). + Where("a.tenantid IS NOT NULL"). + Order("a.id DESC"). + Limit(200). + Scan(&rows).Error + if err != nil { + utils.Error("list onboarded clients", "error", err.Error()) + return utils.Internal(c, "failed to list clients") + } + out := make([]onboardedClient, 0, len(rows)) + for _, r := range rows { + out = append(out, r.toClient()) + } + return utils.List(c, out, int64(len(out))) +} + +// onboardedClientRow is what the list query scans into. It is deliberately +// FLAT with every column named: GORM silently skips an embedded struct of an +// unexported type, which once left every field but the dates empty (and every +// authid 0). TestOnboardedClientRowMapsEveryColumn guards this. +type onboardedClientRow struct { + Authid uint64 `gorm:"column:authid"` + Tenantid int `gorm:"column:tenantid"` + Tenantname string `gorm:"column:tenantname"` + Primaryemail string `gorm:"column:primaryemail"` + Primarycontact string `gorm:"column:primarycontact"` + Status string `gorm:"column:status"` + Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"` + Contactname string `gorm:"column:contactname"` + Loginemail string `gorm:"column:loginemail"` + Loginrole string `gorm:"column:loginrole"` + Authcreatedat *time.Time `gorm:"column:authcreatedat"` + Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"` +} + +func (r onboardedClientRow) toClient() onboardedClient { + created := realTime(r.Authcreatedat) // timestamptz: already the right instant + if created == nil { + // tenants.createdat is a legacy timestamp WITHOUT zone holding IST + // digits; read as UTC it shows 5h30m late. utils.IST puts it right. + if t := realTime(r.Tenantcreatedat); t != nil { + ist := utils.IST(*t) + created = &ist + } + } + return onboardedClient{ + Authid: r.Authid, Tenantid: r.Tenantid, Tenantname: r.Tenantname, + Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status, + Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname, + Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created, + } +} + +// loadClientLogin finds a CLIENT login by doormile_auth id. A Doormile staff +// login (tenantid NULL) is reported as not found: these routes never touch one. +func loadClientLogin(authID string) (*models.DoormileAuth, error) { + var auth models.DoormileAuth + if err := db.DB.Where("id = ? AND tenantid IS NOT NULL", authID).First(&auth).Error; err != nil { + return nil, err + } + return &auth, nil +} + +type updateClientRequest struct { + Companyname *string `json:"companyname"` + Contactname *string `json:"contactname"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Status *string `json:"status"` + Requiredeliveryotp *bool `json:"requiredeliveryotp"` + Password *string `json:"password"` // optional reset; empty = unchanged +} + +var clientStatuses = map[string]string{"active": "Active", "pending": "Pending", "inactive": "Inactive"} + +// UpdateOnboardedClient — PUT /admin/clients/:id (id = the login's authid). +// Edits the client company (tenants) and that login (doormile_auth + appusers) +// in one transaction. Only fields sent are changed. +func UpdateOnboardedClient(c *fiber.Ctx) error { + actor := actorOf(c) + if !onboardingOwnerStillValid(actor.Email) { + return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account") + } + auth, err := loadClientLogin(c.Params("id")) + if err != nil { + return utils.NotFound(c, "client login not found") + } + req := new(updateClientRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + // Validate by reusing the onboarding rules on a filled-in copy. + var tenant models.Tenant + if err := db.DB.First(&tenant, *auth.Tenantid).Error; err != nil { + return utils.NotFound(c, "client not found") + } + check := onboardClientRequest{ + Companyname: tenant.Tenantname, Contactname: "xx", Email: auth.Email, + Phone: tenant.Primarycontact, Password: "unchanged-ok", Applocationid: 1, + } + if req.Companyname != nil { + check.Companyname = *req.Companyname + } + if req.Contactname != nil { + check.Contactname = *req.Contactname + } + if req.Email != nil { + check.Email = *req.Email + } else { + check.Email = "unchanged@doormile.example" // as with the phone: only a changed email is validated + } + if req.Phone != nil { + check.Phone = *req.Phone + } else { + // An older client may carry a phone that fails today's rule; only a + // phone the caller is actually changing is validated. + check.Phone = "9000000000" + } + newPassword := "" + if req.Password != nil && *req.Password != "" { + newPassword = *req.Password + check.Password = newPassword + } + if msg := check.validate(); msg != "" { + return utils.BadRequest(c, msg) + } + status := tenant.Status + if req.Status != nil { + s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))] + if !ok { + return utils.BadRequest(c, "status must be Active, Pending or Inactive") + } + status = s + } + + var hash string + if newPassword != "" { + if hash, err = utils.HashPassword(newPassword); err != nil { + return utils.Internal(c, "failed to process the password") + } + } + + oldEmail := auth.Email + err = db.DB.Transaction(func(tx *gorm.DB) error { + var n int64 + if req.Companyname != nil && !strings.EqualFold(check.Companyname, tenant.Tenantname) { + tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?) AND tenantid <> ?", check.Companyname, tenant.Tenantid).Count(&n) + if n > 0 { + return errOnboardingConflict{"a client with this company name already exists"} + } + } + emailChanged := req.Email != nil && check.Email != strings.ToLower(oldEmail) + if emailChanged { + tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ? AND id <> ?", check.Email, auth.ID).Count(&n) + if n > 0 { + return errOnboardingConflict{"this email already has a console login"} + } + tx.Model(&models.AppUser{}).Where("LOWER(email) = ? AND LOWER(email) <> LOWER(?)", check.Email, oldEmail).Count(&n) + if n > 0 { + return errOnboardingConflict{"this email is already used by another user"} + } + } + + tenantUpdates := map[string]any{"status": status, "updatedat": gorm.Expr("CURRENT_TIMESTAMP")} + if req.Companyname != nil { + tenantUpdates["tenantname"] = check.Companyname + } + if req.Phone != nil { + tenantUpdates["primarycontact"] = check.Phone + } + if emailChanged && strings.EqualFold(tenant.Primaryemail, oldEmail) { + tenantUpdates["primaryemail"] = check.Email + } + if req.Requiredeliveryotp != nil { + tenantUpdates["requiredeliveryotp"] = *req.Requiredeliveryotp + } + if err := tx.Model(&models.Tenant{}).Where("tenantid = ?", tenant.Tenantid).Updates(tenantUpdates).Error; err != nil { + return err + } + + authUpdates := map[string]any{"updated_at": time.Now()} + if emailChanged { + authUpdates["email"] = check.Email + } + if hash != "" { + authUpdates["password_hash"] = hash + } + if err := tx.Model(&models.DoormileAuth{}).Where("id = ?", auth.ID).Updates(authUpdates).Error; err != nil { + return err + } + + userUpdates := map[string]any{} + if emailChanged { + userUpdates["email"] = check.Email + } + if req.Contactname != nil { + userUpdates["authname"] = check.Contactname + } + if req.Phone != nil { + userUpdates["contactno"] = check.Phone + } + if hash != "" { + userUpdates["password"] = hash + } + if len(userUpdates) > 0 { + userUpdates["updatedat"] = gorm.Expr("CURRENT_TIMESTAMP") + if err := tx.Model(&models.AppUser{}). + Where("LOWER(email) = LOWER(?) AND tenantid = ?", oldEmail, tenant.Tenantid). + Updates(userUpdates).Error; err != nil { + return err + } + } + 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)) + return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenant.Tenantid, "status": status, "password_reset": hash != ""}) +} + +// 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))) +} diff --git a/controllers/clientOnboarding_test.go b/controllers/clientOnboarding_test.go new file mode 100644 index 0000000..f4e11a0 --- /dev/null +++ b/controllers/clientOnboarding_test.go @@ -0,0 +1,106 @@ +package controllers + +import ( + "strings" + "sync" + "testing" + "time" + + "gorm.io/gorm/schema" +) + +func validOnboarding() onboardClientRequest { + return onboardClientRequest{ + Companyname: " Acme Foods ", + Contactname: "Priya Raman", + Email: " Ops@Acme.Example ", + Phone: "+91 98765-43210", + Password: "s3cure-pass", + Applocationid: 1, + } +} + +func TestOnboardingValidateNormalises(t *testing.T) { + r := validOnboarding() + if msg := r.validate(); msg != "" { + t.Fatalf("valid request refused: %s", msg) + } + if r.Companyname != "Acme Foods" || r.Contactname != "Priya Raman" || r.Email != "ops@acme.example" || r.Phone != "9876543210" { + t.Fatalf("not normalised: %+v", r) + } +} + +func TestOnboardingValidateRefuses(t *testing.T) { + cases := map[string]func(*onboardClientRequest){ + "company name is required": func(r *onboardClientRequest) { r.Companyname = " " }, + "company name is too long": func(r *onboardClientRequest) { r.Companyname = strings.Repeat("a", 121) }, + "contact person's name": func(r *onboardClientRequest) { r.Contactname = "" }, + "valid email": func(r *onboardClientRequest) { r.Email = "not-an-email" }, + "valid email ": func(r *onboardClientRequest) { r.Email = "Ops " }, + "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", + } { + 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") + } +} diff --git a/docs/customer-app-api-reference.md b/docs/customer-app-api-reference.md new file mode 100644 index 0000000..970f693 --- /dev/null +++ b/docs/customer-app-api-reference.md @@ -0,0 +1,1877 @@ +# Doormile Customer App (`doormile_cx`) — API Reference + +| | | +|---|---| +| Document version | 1.0 | +| Date | 2026-09-28 | +| Generated from code at commit | `44ba33e` (working tree clean at time of writing) | +| Audience | `doormile_cx` mobile app developers | +| Source of truth | The Go code in this repository. Where this document and older docs disagree, this document follows the code. Differences are listed in [§9.3](#93-changes-vs-previous-docs). | + +Every endpoint section ends with a **Source:** line (`file:line`) so reviewers can check it against the code. + +--- + +## Contents + +1. [Overview](#1-overview) +2. [Quick reference: all endpoints](#2-quick-reference-all-endpoints) +3. [Authentication](#3-authentication) +4. [Conventions](#4-conventions) +5. [Endpoint reference](#5-endpoint-reference) +6. [Booking lifecycle](#6-booking-lifecycle) +7. [WebSockets and live tracking](#7-websockets-and-live-tracking) +8. [Push notifications](#8-push-notifications) +9. [Known limitations, open issues, and changes vs previous docs](#9-known-limitations-open-issues-and-changes-vs-previous-docs) + +--- + +## 1. Overview + +### 1.1 Base URL and environments + +| Environment | Base URL | Notes | +|---|---|---| +| Production | `https://api.doormile.com/api/v1` | When the server runs with `ENV=production`, the staging OTP and the QA stage override are switched off (§3.5, §5.10). **Check the environment:** `docs/customer-app-integration-handbook.md` reports that `api.doormile.com` was **not** running with `ENV=production` when it was written. The code cannot confirm or deny this. | +| Staging / development | Depends on the deployment (not determinable from code). The path prefix is still `/api/v1`. | Staging OTP and the QA stage override can be switched on here. | + +All REST paths in this document are relative to the base URL. For example, `POST /customer/bookings` means `POST https://api.doormile.com/api/v1/customer/bookings`. + +WebSocket paths are **not** under `/api/v1`. They sit at the server root: `wss://api.doormile.com/ws/...` (§7). + +Source: `routes/routes.go:35` (`app.Group("/api/v1")`), `routes/routes.go:545-552` (WebSocket routes on `app`, not `api`). + +### 1.2 Content type and headers + +| Header | Direction | Notes | +|---|---|---| +| `Content-Type: application/json` | Request | Required on every request that has a body. The handlers use Fiber's `BodyParser`. A body that cannot be parsed returns `400 invalid` ("We could not read that request"). | +| `Authorization: Bearer ` | Request | Required on authenticated endpoints (§3.6). | +| `Idempotency-Key: ` | Request | Optional. Honoured only on `POST /customer/auth/otp/verify` and `POST /customer/bookings` (§4.4). | +| `X-Request-Id: ` | Request and response | Optional on the request. The server echoes it on every response, including errors. If the client does not send one, the server creates a 16-hex-character id. Include it in support reports. Source: `middlewares/requestid.go:18-33`. | +| `If-None-Match: ""` | Request | Optional on the endpoints that send an `ETag` (§4.6). | +| `ETag`, `Cache-Control` | Response | See §4.6. | +| `Idempotent-Replay: true` | Response | Present when the response is a stored replay (§4.4). | +| `Retry-After: ` | Response | Sent with the OTP hourly-cap `429` (§5.1.1). | + +### 1.3 Time and time zone + +- Every timestamp in a `/customer/*` response is an **integer number of milliseconds since the Unix epoch, UTC** (for example `createdAt`, `history[].at`, `verification.capturedAt`, `deliveredAt`). +- The database stores IST (Asia/Kolkata, UTC+05:30) wall-clock values. The server converts them to real UTC instants with `utils.EpochMillis` before sending, so the app does not need to adjust for IST. +- Some fields are **display strings** that the server formats in IST: slot `day` ("Today", "Tomorrow", "Mon, 8 Sep"), slot `window` ("2:00 – 4:00 PM", with an en dash), and booking `expectedDelivery` ("Thu, 12 Sep"). Show these as they are. Do not parse them. +- Slot ids contain the IST calendar date (`slot_20260928_t1`). + +Source: `utils/epoch.go:1-90`. + +### 1.4 Identifier formats + +| Identifier | Format | Example | Notes | +|---|---|---|---| +| Booking reference | `DM-` + 6 digits | `DM-482913` | This is the `reference` used in every booking URL. It comes from a Postgres sequence and is then scrambled (keyed Feistel permutation), so consecutive bookings do not get adjacent numbers. After 900,000 bookings it grows to 7 or more digits. Older rows can still carry the legacy `DM-BK-…` format. Treat it as an opaque string. | +| Tracking number (one per destination / order) | `DMX` + 8 digits | `DMX10482913` | Created only when the miler completes the pickup (stage `order_created`). It is `null` before that. Legacy rows can carry `DM-TRK-…`. Treat it as an opaque string. | +| Customer id | `cust_` + integer | `cust_1042` | Returned in the `customer` object. | +| Saved location id | Decimal integer **as a string** | `"17"` | Used as `:id` in the `/customer/locations/:id` URLs. | +| Slot id | `slot__` | `slot_20260928_t2` | Opaque. Send it back exactly as you received it. | +| Pagination cursor | Decimal integer as a string | `"5120"` | This is the internal booking id. Treat it as opaque. | + +If the identifier sequences are unavailable (a database or migration problem), the server falls back to a time-and-random number in the same format. + +Source: `controllers/cxIdentifiers.go:63-95`, `controllers/cxIdentifierScramble.go:1-60`, `controllers/cxAuthController.go:137-144`. + +### 1.5 Money + +- All customer-facing amounts are **whole Indian rupees (INR) as integers**, not paise: `fare.min`, `fare.max`, `amountPaid`, and the `min`/`max` of the fare estimate. +- The one exception is `details.codAmount` on a destination (request only). It is a JSON number (float) in rupees. It is the cash the miler collects **for the customer** at that door. It is not a Doormile charge. +- `paymentMethod` is currently always the display string `"UPI · Cash at doorstep"`. There is no in-app payment API. + +Source: `controllers/cxFareController.go:140-155`, `controllers/cxBookingView.go:126-131,185-189`. + +### 1.6 Phone number rules + +Every phone number the customer types is normalised to E.164 India format before it is stored or compared. `normalizePhone` accepts: + +| Input shape (digits after stripping spaces, dashes, brackets) | Result | +|---|---| +| Leading `+` and 11–15 digits | `+` (any country code is accepted as is) | +| Exactly 10 digits | `+91` | +| 12 digits starting `91` | `+` | +| 11 digits starting `0` | `+91` | +| Anything else | Rejected (`400 invalid`) | + +So `9876543210`, `+91 98765 43210`, `919876543210` and `09876543210` all map to `+919876543210`, which is the same account. + +- The server does **not** check that a 10-digit number is a valid Indian mobile range. +- Email identifiers (OTP flow only) are trimmed and lowercased. The only check is that the address has an `@` that is not the first character, text after the `@`, and a `.` after the `@`. +- `details.recipientPhone` on a destination goes through the same normaliser. If it fails normalisation, **the raw trimmed string is stored anyway** and no error is returned. + +Source: `controllers/cxAuthController.go:68-111`, `controllers/cxBookingController.go:795-801`. + +--- + +## 2. Quick reference: all endpoints + +All paths below are under `/api/v1` unless marked **WS**. "Auth" means `Authorization: Bearer ` with role 9 (customer). + +| # | Method | Path | Auth | Purpose | +|---|---|---|---|---| +| 1 | POST | `/customer/auth/otp/request` | No | Send a 4-digit sign-in code to a phone or email | +| 2 | POST | `/customer/auth/signup` | No | Create an account (name + phone) and send a code | +| 3 | POST | `/customer/auth/otp/verify` | No | Exchange a code for a session (also completes a phone signup) | +| 4 | POST | `/customer/auth/refresh` | No (refresh token in body) | Rotate the refresh token and get a new access token | +| 5 | POST | `/customer/auth/login` | No | **Interim PIN flow:** decide the next screen for a phone number | +| 6 | POST | `/customer/auth/set-pin` | No | **Interim PIN flow:** set the first PIN (creates the account if needed) and sign in | +| 7 | POST | `/customer/auth/verify-pin` | No | **Interim PIN flow:** sign in with phone + PIN | +| 8 | GET | `/customer/auth/me` | Yes | Get the signed-in customer (restore a session on cold start) | +| 9 | POST | `/customer/auth/logout` | Yes | Revoke the session and remove the device push token | +| 10 | GET | `/customer/serviceability/states` | No | List destination states | +| 11 | GET | `/customer/serviceability/states/:stateCode/districts` | No | List districts in a state | +| 12 | GET | `/customer/pickup-slots` | No | List pickup windows for today and tomorrow | +| 13 | GET | `/customer/config/booking-limits` | No | Get the package and destination caps for one pickup | +| 14 | GET | `/customer/profile` | Yes | Read the profile | +| 15 | PUT | `/customer/profile` | Yes | Update name, email and default location | +| 16 | GET | `/customer/locations` | Yes | List saved addresses | +| 17 | POST | `/customer/locations` | Yes | Save an address (up to 10) | +| 18 | PUT | `/customer/locations/:id` | Yes | Update a saved address | +| 19 | DELETE | `/customer/locations/:id` | Yes | Delete a saved address (soft delete) | +| 20 | POST | `/customer/devices` | Yes | Register a push token | +| 21 | DELETE | `/customer/devices/:token` | Yes | Unregister a push token | +| 22 | GET | `/customer/places/reverse-geocode` | Yes | Turn coordinates into a two-line address label | +| 23 | GET | `/customer/places/search` | Yes | Search places, or list saved and recent places when `q` is empty | +| 24 | POST | `/customer/fare/estimate` | Yes | Get a price range for a pickup | +| 25 | POST | `/customer/bookings` | Yes | Create a pickup booking (idempotent) | +| 26 | GET | `/customer/bookings` | Yes | List bookings (cursor pagination, status tabs) | +| 27 | GET | `/customer/bookings/:reference` | Yes | Get one booking (tracking screen and receipt) | +| 28 | POST | `/customer/bookings/:reference/cancel` | Yes | Cancel the whole pickup | +| 29 | PATCH | `/customer/bookings/:reference/destinations/:index` | Yes | Fill in or correct one destination's details | +| 30 | GET | `/customer/orders/:trackingId` | Yes | Get a booking by an order's tracking number (for push deep links) | +| 31 | POST | `/customer/ops/bookings/:reference/stage` | Yes + env flags | **QA only.** Force a booking to a stage. Refused in production. | +| 32 | WS | `/ws/bookings/:bookingid/track` | **None** | Live rider position before pickup (see the security note in §7.1) | +| 33 | WS | `/ws/bookings/:bookingid/chat` | JWT in `?token=` | Customer ↔ miler chat room | +| 34 | GET | `/pricing/meta` | No | Pricing dropdown metadata. Not part of the customer contract (§5.12). | +| 35 | POST | `/pricing/check` | No | Generic price check. Not part of the customer contract (§5.12). | + +The `/customer` group has 31 routes. With the 2 WebSocket routes and the 2 public pricing routes, this document covers **35 endpoints**. The health probes `GET /health` and `GET /ready` also exist but are for infrastructure, not the app. + +Source: `routes/routes.go:105-178, 501-502, 545-552`. + +--- + +## 3. Authentication + +### 3.1 Summary + +There are two sign-in flows. Both end in the **same session response** (§3.4), and both give a role-9 JWT. + +| Flow | Endpoints | Status | +|---|---|---| +| **OTP** — a 4-digit code sent to a phone (SMS) or an email address | `otp/request`, `signup`, `otp/verify` | The intended long-term flow. It depends on a working SMS gateway (§3.5). | +| **Interim PIN** — a 4-digit PIN that the customer sets | `login`, `set-pin`, `verify-pin` | Added in commit `f1dbf7e` "until the SMS/OTP gateway is live". **It does not prove the customer owns the phone number** (§9.1). | + +Refresh, logout and `me` are the same for both flows. + +### 3.2 OTP flow + +``` +New customer: POST /auth/signup {name, phone, email?} ─► code sent + POST /auth/otp/verify {identifier: phone, code} ─► session + + (or skip signup: /otp/request with the phone, then + /otp/verify with {identifier, code, name} — the account + is created on verify when a name is supplied) + +Returning customer: POST /auth/otp/request {identifier} ─► code sent + POST /auth/otp/verify {identifier, code} ─► session +``` + +OTP rules (from `controllers/cxAuthController.go:38-58, 169-248`): + +| Rule | Value | +|---|---| +| Code length | 4 digits (`codeLength: 4` is returned by `otp/request`) | +| Code lifetime | 5 minutes | +| Resend cooldown | 30 seconds per identifier. A request during the cooldown returns `200` with `sent: false` and the seconds left. | +| Codes per identifier | 5 per rolling hour (counter starts on the first request). The 6th returns `429 rate_limited`. | +| Wrong attempts | 3 per issued code. On the 3rd wrong attempt the code is deleted and a new one must be requested. | +| Single use | A correct code is deleted as soon as it is used. | +| Account enumeration | `otp/request` answers the same way whether or not an account exists. | + +### 3.3 Interim PIN flow + +``` +POST /auth/login {phone} + ├─ registered:false ─► collect name + new PIN ─► POST /auth/set-pin {phone, new_pin, name} + ├─ registered:true, pin_set:false ─► collect new PIN ─► POST /auth/set-pin {phone, new_pin} + └─ registered:true, pin_set:true ─► collect PIN ─► POST /auth/verify-pin {phone, pin} +``` + +- A PIN is exactly 4 ASCII digits. It is stored as a bcrypt-style hash (`utils.HashPassword`). +- `set-pin` never overwrites an existing PIN (`409 pin_already_set`). +- There is **no PIN reset or change endpoint** for customers. +- There is **no per-account lockout** for wrong PINs. The only protection is the per-IP `authThrottle` (§3.7). + +Source: `controllers/cxAuthController.go:365-519`, `routes/routes.go:117-123`. + +### 3.4 Session response (all sign-in endpoints and refresh) + +`otp/verify`, `set-pin`, `verify-pin` and `refresh` all return: + +```json +{ + "success": true, + "data": { + "accessToken": "", + "refreshToken": "", + "expiresIn": 3600, + "customer": { + "id": "cust_1042", + "name": "Priya Raman", + "phone": "+919876543210", + "email": "priya@example.com" + } + }, + "message": "" +} +``` + +| Field | Type | Notes | +|---|---|---| +| `accessToken` | string | HS256 JWT. Lifetime **1 hour**. Claims: `UserID` (the customer id), `Email` (holds the **phone number**), `RoleID` = 9, `TenantID` = 0, `ConfigID`, `exp`, `iat`. Treat it as opaque. | +| `refreshToken` | string | 64 hex characters (32 random bytes). Lifetime **60 days**. Only a SHA-256 hash is stored on the server. | +| `expiresIn` | integer | Access-token lifetime in seconds. Always `3600`. | +| `customer.id` | string | `cust_` | +| `customer.name` | string | First and last name joined with a space. Can be `""`. | +| `customer.phone` | string | E.164 | +| `customer.email` | string | Never `null`. `""` when unknown. | + +Source: `controllers/cxAuthController.go:52-53, 131-144, 707-759`, `utils/helper.go:93-109`. + +### 3.5 Staging OTP and SMS delivery + +- SMS goes through `internal/sms`. If `SMS_GATEWAY_URL` is not configured, the "log sink" is used: + - **Non-production:** the code is written to the server log (masked phone) and the API reports `sent: true`. + - **Production:** sending fails, and `otp/request` / `signup` return `500 server_error`. The readiness probe `GET /api/v1/ready` reports `checks.sms.configured`. +- **Staging bypass:** if the environment variable `CX_STAGING_OTP` is set **and** `ENV` is not `production`, every issued code (SMS and email) is that fixed value instead of a random one. The value is deployment configuration and is not written here; ask the backend team for it. The code ignores it in production. +- Email codes are sent over SMTP (`internal/mail`). SMTP settings come from the environment. + +Source: `internal/sms/sms.go:32-120`, `internal/sms/http_sender.go:140-190`, `controllers/cxAuthController.go:190-214`. + +### 3.6 Using the access token + +- Send `Authorization: Bearer ` on every authenticated call. The word `Bearer` and exactly one space are required. +- Authenticated `/customer/*` routes run `AuthMiddleware` and then `RoleCheckMiddleware(9)`. A miler, console or hub token is refused with `403`. +- **These two middlewares do not use the customer envelope.** Their failures look like this (no `error.code`): + +| Condition | Status | Body | +|---|---|---| +| No `Authorization` header | 401 | `{"success": false, "message": "authorization header is required"}` | +| Header not in `Bearer ` form | 401 | `{"success": false, "message": "authorization header must be in format: Bearer "}` | +| Bad signature or expired token | 401 | `{"success": false, "message": "invalid or expired token"}` | +| Valid token, but not role 9 | 403 | `{"success": false, "message": "insufficient permissions for this resource"}` | + + The app should treat **any 401** as "refresh, then retry once; if that fails, sign in again", whether or not `error.code` is present. +- The access token is **not checked against the database** on each request. Blocking a customer, or logging out, does not end an access token that was already issued. It keeps working until it expires (at most 1 hour). Only `GET /auth/me` re-checks the customer's `Blocked` status. +- Because the middleware applies to the whole `/customer` prefix, an **unknown path** under `/customer/` returns the 401 above when no token is sent, not a 404. + +Source: `middlewares/auth.go:12-113`, `routes/routes.go:134`. + +### 3.7 Throttling of auth endpoints + +- All seven public auth endpoints (`otp/request`, `signup`, `otp/verify`, `refresh`, `login`, `set-pin`, `verify-pin`) share **one** limiter: **10 requests per minute per client IP**. The budget is also shared with `/miler/login`, `/miler/verify-pin`, `/miler/set-pin`, `/miler/reset-pin`, `/admin/login` and `/hub/login` (one `authThrottle` instance). +- When the limit is reached: `429 {"success": false, "message": "too many attempts, please try again in a minute"}`. This body has **no `error.code`**. +- The client IP is the socket peer, or `X-Forwarded-For` only when `TRUSTED_PROXIES` is configured on the server. Mobile carriers often put many users behind one IP (CGNAT), so many real customers can share one budget (§9.2). +- `refresh` counts against this budget. Do not refresh more often than needed. + +Source: `routes/routes.go:19-40, 110-123`, `main.go:116-133`. + +### 3.8 Refresh flow + +1. Call `POST /customer/auth/refresh` with `{"refreshToken": ""}` when the access token has expired, or is about to (use `expiresIn`), or when a call returns 401. +2. The server **rotates** the token. The token you sent is revoked, and a new pair is returned in the session shape (§3.4). +3. **If a revoked refresh token is sent again, the server revokes all of that customer's sessions on all devices** (theft detection). This means: + - Store the new refresh token atomically before using it. + - **Never send two refresh calls at the same time.** Use a single in-flight refresh (a mutex). If two parallel calls send the same token, the second one signs the customer out everywhere. +4. Any refresh failure (`401 unauthorized`, "Please sign in again") means the app must go back to the sign-in screen. + +Source: `controllers/cxAuthController.go:594-648`. + +### 3.9 Logout + +`POST /customer/auth/logout` (authenticated). With `refreshToken` in the body, it revokes only that session. **Without it, it revokes every session the customer has.** With `deviceToken`, it also removes that push token. Details are in §5.1.9. + +--- + +## 4. Conventions + +### 4.1 Response envelope + +Every `/customer/*` handler uses the helpers in `utils/response_cx.go`. + +**Success (single object):** `CxOK` gives HTTP 200. `CxCreated` gives HTTP 201 with the same body. + +```json +{ "success": true, "data": { }, "message": "" } +``` + +**Success (list):** `CxList` gives HTTP 200. + +```json +{ "success": true, "data": [ ], "total": 12, "nextCursor": "5120", "message": "" } +``` + +- `data` is always an array. It is never `null`. +- `total` is an integer. For `GET /bookings` it is the total count for the selected tab. For other lists it is the length of `data`. +- `nextCursor` is a string, or `null` on the last page (and on every list that is not paginated). + +**Error:** `CxFail`. + +```json +{ "success": false, "message": "That pickup window just filled up", "error": { "code": "conflict" } } +``` + +- `message` is customer-safe English. The code comments say it is meant to be shown to the user as is. +- Branch on `error.code`, not on `message`. + +Source: `utils/response_cx.go:35-100`. + +**Responses that do NOT use this envelope** (the app must handle them too): + +| Source | Status | Body shape | +|---|---|---| +| Auth / role middleware (§3.6) | 401 / 403 | `{success:false, message}` | +| `authThrottle` (§3.7) | 429 | `{success:false, message}` | +| Global rate limiter (§4.5) | 429 | `{success:false, message:"too many requests, please slow down"}` | +| Idempotency lock (§4.4) | 409 | `{success:false, code:"IDEMPOTENCY_IN_PROGRESS", message}` | +| `CityGateMiddleware` (§4.7) | 400 | `{error:"We are not yet operating in your city. Stay tuned!", code:"CITY_NOT_SUPPORTED"}` | +| Fiber's global error handler (unknown route after auth, panics, framework errors) | 404 / 500 / other | `{success:false, message}` | +| `ETag` match | 304 | Empty body | +| WebSockets (§7) | — | Their own frame formats | + +Source: `main.go:60-81, 191-205`, `middlewares/idempotency.go:505-512`, `middlewares/city_gate.go:424-427`. + +### 4.2 Error codes + +The full set is defined in `utils/response_cx.go:19-33`. Every code below is used by at least one `/customer/*` handler. + +| `error.code` | HTTP status | Meaning | Returned by (examples) | +|---|---|---|---| +| `invalid` | 400 | The request is malformed or fails validation. `message` says what to fix. | Almost every handler | +| `invalid_name` | 400 | The name is missing or has fewer than 2 characters after trimming | `signup`, `otp/verify` (new phone), `set-pin` (new phone), `PUT /profile` | +| `invalid_otp` | 401 | The code is wrong, expired, used up, or was never issued | `otp/verify` | +| `invalid_pin` | 401 | Wrong PIN, **or** the phone has no account (the same message for both) | `verify-pin` | +| `pin_not_set` | 409 | The account exists but has no PIN yet. Send the user to set-pin. | `verify-pin` | +| `pin_already_set` | 409 | The account already has a PIN. Send the user to verify-pin. | `set-pin` | +| `unauthorized` | 401 | The session is invalid. Sign in again. | `refresh`, `me` | +| `forbidden` | 403 | The account is `Blocked` | `signup`, `login`, `set-pin`, `verify-pin`, `otp/verify`, `refresh`, `me` | +| `not_found` | 404 | The resource does not exist or belongs to another customer. The QA override also uses it when disabled. | Bookings, orders, locations, districts, `otp/verify` (email with no account), `ops/.../stage` | +| `conflict` | 409 | A state conflict: slot full, booking no longer cancellable, destination locked after pickup | `POST /bookings`, `cancel`, `PATCH destination` | +| `unserviceable` | 422 | The pickup point is outside operating cities, or a destination district closed | `POST /bookings` | +| `rate_limited` | 429 | Too many OTP codes for this identifier this hour. A `Retry-After` header is sent. | `otp/request`, `signup` | +| `server_error` | 500 | Unexpected failure. `message` is always "Something went wrong". | Any handler | + +### 4.3 Pagination + +Only `GET /customer/bookings` is paginated. It uses **keyset (cursor) pagination**, not page numbers. + +| Query param | Type | Default | Rules | +|---|---|---|---| +| `limit` | integer | 20 | Values ≤ 0 or non-numeric fall back to 20. Values above 50 are capped at 50. | +| `cursor` | string | none | Send back the previous response's `nextCursor`. A non-numeric value is ignored (you get the first page). | +| `status` | string | none (all bookings) | `active`, `completed` or `cancelled` (case-insensitive). Any other value means no filter. | + +The response has `total` (count for the whole filtered tab, not the page) and `nextCursor` (`null` on the last page). Rows are newest first. The generic `pageno`/`pagesize` helper in `utils/pagination.go` is **not** used on the customer surface. + +Source: `controllers/cxBookingController.go:29-31, 443-515`. + +### 4.4 Idempotency (`Idempotency-Key`) + +| Item | Behaviour | +|---|---| +| Endpoints | `POST /customer/auth/otp/verify` and `POST /customer/bookings` only. The header is ignored everywhere else. | +| Header | `Idempotency-Key: `. A UUID made when the user taps the button is recommended. Reuse the same key for retries of the same action only. | +| Optional | If the header is missing, or Redis is unavailable, the request runs normally with no protection. | +| Scope | On an authenticated request the key is scoped to the customer id. On `otp/verify` (no token), it is scoped to a hash of the request path **plus the exact request body**, so a retry must send a byte-identical body to be recognised. | +| What is stored | Only **2xx** responses (status and body). 4xx and 5xx are never stored, so fixing a typo and retrying with the same key works. | +| TTL | 24 hours | +| Replay | Same status code and the same body as the first success, plus the response header `Idempotent-Replay: true`. The handler does not run again, so no second booking or session is created. | +| Concurrent duplicate | While the first request is still running (lock up to 30 s), a second request with the same key gets `409 {"success": false, "code": "IDEMPOTENCY_IN_PROGRESS", "message": "an identical request is still being processed"}`. This is not the customer envelope. Wait and retry with the same key. | + +Warning for `otp/verify`: a replay returns the **original** tokens. If the app has since refreshed, that refresh token is already revoked, and sending it again triggers the "revoke all sessions" rule (§3.8). Only replay `otp/verify` to recover from a response that never arrived. + +Source: `middlewares/idempotency.go:15-135`, `routes/routes.go:114, 164`. + +### 4.5 Rate limits + +| Limiter | Scope | Limit | Response on limit | +|---|---|---|---| +| Global | Every route except `/health`, `/ready`, `/ws/*` | 300 requests / minute / IP | `429 {success:false, message:"too many requests, please slow down"}` | +| `authThrottle` | The 7 public customer auth endpoints (shared with miler, admin and hub logins) | 10 requests / minute / IP, one shared budget | `429 {success:false, message:"too many attempts, please try again in a minute"}` | +| OTP issue cap | Per identifier (phone or email) | 5 codes / hour, 30 s cooldown | `429 rate_limited` with `Retry-After`, or `200 sent:false` during the cooldown | +| OTP attempts | Per issued code | 3 wrong attempts | The code is destroyed and later attempts return `401 invalid_otp` | + +Source: `main.go:191-205`, `routes/routes.go:23-32`, `controllers/cxAuthController.go:38-50`. + +### 4.6 Caching and ETags + +| Endpoint | Cache-Control | ETag / 304 | +|---|---|---| +| `GET /serviceability/states` | `max-age=300` | Yes | +| `GET /serviceability/states/:stateCode/districts` | `max-age=300` | Yes | +| `GET /bookings/:reference` | `no-cache` on 200; `max-age=300` on a 304 | Yes | +| `GET /pickup-slots` | `max-age=30` | No | +| `POST /fare/estimate` | `max-age=60` | No | + +The ETag is a hash of the `data` payload. Send it back in `If-None-Match` to get `304 Not Modified` with an empty body when nothing changed. `*` and weak (`W/`) forms are accepted. Because a booking's `milerDistanceKm` and `milerEtaMinutes` change as the rider moves, the booking ETag changes often while a rider is on the way. + +Source: `controllers/cxCatalogueController.go:168-191`, `controllers/cxBookingController.go:531-537`, `controllers/cxFareController.go:86-88`. + +### 4.7 City gate (operating area) + +Operating pincode prefixes (`middlewares/city_gate.go:397-404`): + +| Prefix | City | +|---|---| +| `641` | Coimbatore | +| `600` | Chennai | +| `560` | Bengaluru | +| `500` | Hyderabad | +| `629` | Nagercoil | + +How it is enforced for customer bookings: + +- `POST /customer/bookings` has `CityGateMiddleware` in its chain. That middleware looks only for a **top-level `pickuppincode` field** in the body, which the customer request does not have. So normally it does nothing. **Do not send `pickuppincode`.** If you do and it is outside the list, you get `400 {"error": "...", "code": "CITY_NOT_SUPPORTED"}` (not the customer envelope). +- The real check is inside the handler. The server takes the pickup `lat`/`lng`, finds the **nearest active hub that has a pincode and coordinates** (`pincodeForPoint`), and checks that hub's pincode prefix. If it is not in the list, it returns `422 unserviceable` "We are not collecting from that area yet". +- The nearest-hub lookup has **no distance limit** (§9.2). In practice the check fails only for `lat=0, lng=0`, or when no active hub has coordinates. + +Source: `middlewares/city_gate.go:406-446`, `controllers/cxBookingController.go:155-164`, `controllers/cxFareController.go:232-258`. + +--- + +## 5. Endpoint reference + +Paths are relative to `/api/v1`. "Envelope" means the customer envelope from §4.1. + +### 5.1 Auth + +#### 5.1.1 `POST /customer/auth/otp/request` + +- **Auth:** No. **Middlewares:** `authThrottle`. +- **Purpose:** Send a 4-digit sign-in code to a phone (SMS) or an email address. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `identifier` | string | Yes | A phone number (normalised per §1.6) or an email address (contains `@`). | + +```json +{ "identifier": "9876543210" } +``` + +**Success — 200** + +```json +{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 }, "message": "" } +``` + +During the 30-second cooldown the response is still `200`, but with `"sent": false` and `resendAfterSeconds` set to the seconds left (plus 1). Show the countdown. It is not an error. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed, or the identifier is not a valid phone or email | +| 429 | `rate_limited` | More than 5 codes for this identifier in an hour. `Retry-After: 3600`. | +| 500 | `server_error` | Redis is unavailable, or SMS/email sending failed (including production with no SMS gateway) | +| 429 | (no code) | `authThrottle` | + +**Rules:** The response does not reveal whether an account exists. Codes can be requested for identifiers with no account. An email code can only sign in to an existing account whose stored `email` matches (§5.1.3). + +Source: `controllers/cxAuthController.go:252-296` (issue logic `169-217`). + +#### 5.1.2 `POST /customer/auth/signup` + +- **Auth:** No. **Middlewares:** `authThrottle`. +- **Purpose:** Create an account and send an SMS code in one call. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `name` | string | Yes | Whitespace is collapsed. It needs at least 2 characters. The last word becomes the last name, and the rest becomes the first name. | +| `phone` | string | Yes | Normalised per §1.6 | +| `email` | string | No | Trimmed and lowercased. **The format is not checked.** It is saved only when a **new** account is created. | + +```json +{ "name": "Priya Raman", "phone": "+91 98765 43210", "email": "priya@example.com" } +``` + +**Success — 200** + +```json +{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 }, "message": "" } +``` + +There is no `codeLength` here, unlike `otp/request`. During the cooldown the response is `sent: false`. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed, or the phone is invalid | +| 400 | `invalid_name` | The name is missing or too short | +| 403 | `forbidden` | The phone belongs to a `Blocked` account | +| 429 | `rate_limited` | OTP hourly cap | +| 500 | `server_error` | Database, Redis or send failure | + +**Rules and side effects** + +- If the phone **already has an account**, this is treated as a sign-in. A code is sent, and the new name and email are **ignored**. +- A new account row is created **before** the code is verified, with status `Active`, no PIN and `configid` 1001. An account that never verifies stays in the database. See §9.1 for how this interacts with `set-pin`. +- Next step: `POST /customer/auth/otp/verify` with `identifier` = the phone. + +Source: `controllers/cxAuthController.go:298-363`. + +#### 5.1.3 `POST /customer/auth/otp/verify` + +- **Auth:** No. **Middlewares:** `authThrottle`, `Idempotency` (§4.4). +- **Purpose:** Exchange a code for a session. A phone signup can also be finished here in one step. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `identifier` | string | Yes | The same phone or email the code was sent to | +| `code` | string | Yes | Trimmed and compared exactly | +| `name` | string | Conditional | Required when the identifier is a phone **with no account yet**. It is also used to fill in the name of an existing account that has no first name. It never overwrites an existing name. | + +```json +{ "identifier": "9876543210", "code": "", "name": "Priya Raman" } +``` + +**Success — 200:** the session response (§3.4). + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed, the identifier is invalid, or the code is empty | +| 401 | `invalid_otp` | Wrong, expired, destroyed after 3 misses, or never issued. The attempt counts. | +| 400 | `invalid_name` | New phone and no usable `name`. **The code has already been used up**, so the user must request a new one. | +| 404 | `not_found` | Email identifier with no account whose `email` matches. The code has been used up. | +| 403 | `forbidden` | The account is `Blocked` | +| 409 | (non-envelope `IDEMPOTENCY_IN_PROGRESS`) | The same key is already in flight | +| 500 | `server_error` | Could not create the account or the session | + +**Side effects:** Creates the account (phone only) when needed and stamps `lastloginat`. Each successful verify creates a new refresh-token row. + +Source: `controllers/cxAuthController.go:521-592`. + +#### 5.1.4 `POST /customer/auth/refresh` + +- **Auth:** No (the refresh token is the credential). **Middlewares:** `authThrottle`. +- **Purpose:** Rotate the refresh token and get a new access token. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `refreshToken` | string | Yes | Trimmed | + +```json +{ "refreshToken": "" } +``` + +**Success — 200:** the session response (§3.4) with a **new** `refreshToken`. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed | +| 401 | `unauthorized` | Empty, unknown, expired or revoked token, or the account no longer exists. **For a revoked token, all of the customer's sessions are revoked as well.** | +| 403 | `forbidden` | The account is `Blocked` | +| 500 | `server_error` | Could not revoke the old token or issue the new one | + +Source: `controllers/cxAuthController.go:594-648`. + +#### 5.1.5 `POST /customer/auth/login` (interim PIN) + +- **Auth:** No. **Middlewares:** `authThrottle`. +- **Purpose:** Tell the app which screen comes next for a phone number. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `phone` | string | Yes | Normalised per §1.6 | + +```json +{ "phone": "9876543210" } +``` + +**Success — 200.** The keys use **snake_case** here (`pin_set`), unlike the rest of the customer surface. + +No account: + +```json +{ "success": true, "data": { "phone": "+919876543210", "registered": false, "pin_set": false }, "message": "" } +``` + +Account exists: + +```json +{ "success": true, "data": { "phone": "+919876543210", "registered": true, "pin_set": true, "name": "Priya Raman" }, "message": "" } +``` + +`name` is present only when `registered` is `true`. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed, or the phone is invalid | +| 403 | `forbidden` | The account is `Blocked` | + +**Note:** This endpoint tells an anonymous caller whether a phone is registered, and returns the account holder's name (§9.2). + +Source: `controllers/cxAuthController.go:387-415`. + +#### 5.1.6 `POST /customer/auth/set-pin` (interim PIN) + +- **Auth:** No. **Middlewares:** `authThrottle`. +- **Purpose:** Set the first PIN, creating the account if the phone is new, and sign in. + +**Request body** (snake_case `new_pin`) + +| Field | Type | Required | Rules | +|---|---|---|---| +| `phone` | string | Yes | Normalised per §1.6 | +| `new_pin` | string | Yes | Exactly 4 digits `0-9`. Leading and trailing spaces are tolerated when validating, but the **untrimmed** value is hashed, so send the PIN with no spaces. | +| `name` | string | Required for a new phone | Same rules as signup. Ignored for an existing account. | + +```json +{ "phone": "9876543210", "new_pin": "<4-digit PIN>", "name": "Priya Raman" } +``` + +**Success — 200:** the session response (§3.4). + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed, the phone is invalid, or the PIN is not 4 digits | +| 400 | `invalid_name` | New phone with a missing or short name | +| 403 | `forbidden` | The account is `Blocked` | +| 409 | `pin_already_set` | The account already has a PIN. Go to verify-pin. | +| 500 | `server_error` | Hash, create or save failure | + +**Side effects:** A new phone creates an account (`Active`, `configid` 1001, no email). An existing account **without a PIN** gets this PIN. This includes any account made by OTP signup, by `otp/verify`, or by the console. **No proof of phone ownership is required** (§9.1). + +Source: `controllers/cxAuthController.go:417-480`. + +#### 5.1.7 `POST /customer/auth/verify-pin` (interim PIN) + +- **Auth:** No. **Middlewares:** `authThrottle`. +- **Purpose:** Sign in with phone and PIN. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `phone` | string | Yes | Normalised per §1.6 | +| `pin` | string | Yes | Must not be blank. Trimmed and checked against the stored hash. | + +```json +{ "phone": "9876543210", "pin": "<4-digit PIN>" } +``` + +**Success — 200:** the session response (§3.4). It also stamps `lastloginat`. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The body cannot be parsed, the phone is invalid, or the PIN is blank ("Enter your PIN") | +| 401 | `invalid_pin` | Unknown phone **or** wrong PIN: "That phone number or PIN is incorrect" | +| 403 | `forbidden` | The account is `Blocked` | +| 409 | `pin_not_set` | The account has no PIN yet. Go to set-pin. | + +Source: `controllers/cxAuthController.go:482-519`. + +#### 5.1.8 `GET /customer/auth/me` + +- **Auth:** Yes. **Purpose:** Restore a session on cold start. This is the only endpoint that re-checks `Blocked` status on each call. + +**Success — 200** + +```json +{ "success": true, "data": { "id": "cust_1042", "name": "Priya Raman", "phone": "+919876543210", "email": "priya@example.com" }, "message": "" } +``` + +**Errors:** `401 unauthorized` (the customer row no longer exists), `403 forbidden` (the account is blocked), plus the middleware errors in §3.6. + +Source: `controllers/cxAuthController.go:689-701`. + +#### 5.1.9 `POST /customer/auth/logout` + +- **Auth:** Yes. **Purpose:** End the session and stop pushes to this device. + +**Request body** (optional; an unreadable body is ignored) + +| Field | Type | Required | Rules | +|---|---|---|---| +| `refreshToken` | string | No | If present, only this session is revoked (and only if it belongs to the caller). **If absent, every session of this customer is revoked.** | +| `deviceToken` | string | No | If present, this push token is deleted for this customer. | + +```json +{ "refreshToken": "", "deviceToken": "" } +``` + +**Success — 200** + +```json +{ "success": true, "data": { "signedOut": true }, "message": "" } +``` + +**Errors:** `500 server_error` if revoking the given refresh token fails. A failure to delete the device token is only logged. + +**Notes:** The access token is not revoked and works until it expires (≤ 1 h). Discard it on the client. An unknown or foreign `refreshToken` still returns `signedOut: true`. + +Source: `controllers/cxAuthController.go:650-687`. + +--- + +### 5.2 Catalogue, serviceability and configuration (public) + +These four reads need no token, so the booking form can be explored before sign-in. + +#### 5.2.1 `GET /customer/serviceability/states` + +- **Auth:** No. **Caching:** `ETag`, `max-age=300`. +- **Purpose:** List the states a destination can be in (states with status `Active`, sorted by `displayorder` and then name). + +**Success — 200 (list)** + +```json +{ + "success": true, + "data": [ + { "code": "TN", "name": "Tamil Nadu", "districtCount": 12, "transitTag": "Ultra-fast transit" }, + { "code": "KL", "name": "Kerala", "districtCount": 0, "transitTag": "Opening soon" } + ], + "total": 2, "nextCursor": null, "message": "" +} +``` + +| Field | Type | Notes | +|---|---|---| +| `code` | string | State code (up to 8 characters) | +| `name` | string | | +| `districtCount` | integer | Counts **available** districts only. The app hides a state that shows `0`. | +| `transitTag` | string | Display copy, up to 22 characters. Can be `""`. | + +An empty list is a valid answer (no service anywhere). + +**Errors:** `500 server_error`. + +Source: `controllers/cxCatalogueController.go:30-75`. + +#### 5.2.2 `GET /customer/serviceability/states/:stateCode/districts` + +- **Auth:** No. **Caching:** `ETag`, `max-age=300`. +- **Purpose:** List every district in a state, **including unavailable ones**. Available districts come first. + +**Path params** + +| Name | Type | Rules | +|---|---|---| +| `stateCode` | string | Trimmed and uppercased. It must be an `Active` state. | + +**Success — 200 (list)** + +```json +{ + "success": true, + "data": [ + { "code": "TN-CBE", "name": "Coimbatore", "available": true, "hub": "Coimbatore Central", "promise": "Next-day delivery", "lat": 11.0168, "lng": 76.9558 }, + { "code": "TN-NLG", "name": "The Nilgiris", "available": false, "note": "Opening soon" } + ], + "total": 2, "nextCursor": null, "message": "" +} +``` + +| Field | Type | Present | Notes | +|---|---|---|---| +| `code` | string | Always | District code (up to 16 characters). Send it as `districtCode`. | +| `name` | string | Always | | +| `available` | boolean | Always | Only available districts can be booked. | +| `note` | string | Only when not empty | Why it is closed | +| `hub` | string | Only when a serving hub is set and found | Hub name | +| `promise` | string | Only when not empty | Delivery promise copy | +| `lat`, `lng` | number | Only when the district centre is known (not 0,0) | District centre. It is used for pricing and map placement. | + +The example codes above are illustrative. Real codes come from the `serviceabledistricts` table. + +**Errors:** `400 invalid` (empty code), `404 not_found` "That state is no longer serviceable", `500 server_error`. + +Source: `controllers/cxCatalogueController.go:77-140`. + +#### 5.2.3 `GET /customer/pickup-slots` + +- **Auth:** No. **Caching:** `max-age=30`. +- **Purpose:** List the pickup windows that can be offered at a location, for **today and tomorrow** (IST). + +**Query params** + +| Name | Type | Required | Notes | +|---|---|---|---| +| `lat` | float | No | Pickup latitude. It picks the city templates (nearest active hub within 60 km) and the capacity zone. | +| `lng` | float | No | Pickup longitude | + +If `lat`/`lng` are missing or unparseable they count as `0,0`. Then only city-agnostic templates are used, capacity is counted city-wide, and `milersNearby` is left out. + +**Success — 200 (list)** + +```json +{ + "success": true, + "data": [ + { "id": "slot_20260928_t2", "day": "Today", "window": "2:00 – 4:00 PM", "available": true, "tag": "Fastest pickup", "milersNearby": 4, "caption": "Most riders free" }, + { "id": "slot_20260928_t3", "day": "Today", "window": "4:00 – 6:00 PM", "available": false, "note": "Fully booked", "milersNearby": 4 }, + { "id": "slot_20260929_t1", "day": "Tomorrow", "window": "10:00 AM – 12:00 PM", "available": true, "milersNearby": 4 } + ], + "total": 3, "nextCursor": null, "message": "" +} +``` + +| Field | Type | Present | Notes | +|---|---|---|---| +| `id` | string | Always | Opaque slot id. Send it as `slotId`. | +| `day` | string | Always | "Today", "Tomorrow" or "Mon, 8 Sep" (IST) | +| `window` | string | Always | "2:00 – 4:00 PM". The meridiem is shown once when both ends share it. | +| `available` | boolean | Always | `false` when the zone's booked count ≥ the template capacity | +| `note` | string | When not available | `"Fully booked"` | +| `tag` | string | At most one slot, only when available | Promotional label from the template | +| `caption` | string | Only when available and set | | +| `milersNearby` | integer | Only when > 0 | Riders reporting a position within 6 km (Redis GEO) | + +**Rules** + +- Windows that start within **45 minutes** (or earlier) are left out entirely. +- Capacity counts bookings whose preferred pickup start falls inside the window, within **12 km** of the given point, excluding `Cancelled` and `Converted_To_Consignment` bookings. If that query fails, the window is shown as open. +- This list is advisory. Capacity is checked again when the booking is created (`409 conflict`). + +**Errors:** `500 server_error` (template query failed). + +Source: `controllers/cxCatalogueController.go:193-296` (id format `302-304`, capacity `390-430`). + +#### 5.2.4 `GET /customer/config/booking-limits` + +- **Auth:** No. +- **Purpose:** Get the caps for one pickup. Fetch it again when the pickup point moves. + +**Query params:** `lat`, `lng` (float, optional). They resolve the city through the nearest active hub within 60 km. + +**Success — 200** + +```json +{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 1 }, "message": "" } +``` + +**Rules:** The city row in `customerbookinglimits` is used first, then the global row (`applocationid IS NULL`), then the built-in defaults: **`maxPackages` 20, `maxDestinations` 1**. The destination default is deliberately 1, so multi-destination stays off until ops adds a configuration row. A configured value of 0 or less is ignored. The same function is the authority at booking create. + +Source: `controllers/cxCatalogueController.go:494-563`. + +--- + +### 5.3 Profile + +#### 5.3.1 `GET /customer/profile` + +- **Auth:** Yes. **Purpose:** Read the profile. + +**Success — 200:** the same shape as `/auth/me`: `{id, name, phone, email}`. + +**Errors:** `404 not_found` "We could not find your profile". + +Source: `controllers/customerController.go:40-49`. + +#### 5.3.2 `PUT /customer/profile` + +- **Auth:** Yes. **Purpose:** Update the profile. A field that is left out is not changed. + +**Request body** (camelCase; every field optional) + +| Field | Type | Rules | +|---|---|---| +| `name` | string | Same rules as signup. `400 invalid_name` if too short. | +| `email` | string | Stored exactly as sent. **Not validated, not lowercased, and not checked for uniqueness** (§9.2). | +| `defaultLatitude` | number | Stored as is | +| `defaultLongitude` | number | Stored as is | +| `defaultPincode` | string | Stored as is | + +```json +{ "name": "Priya R", "email": "priya@example.com" } +``` + +**Success — 200:** `{id, name, phone, email}`. The default location fields are stored but are **not returned** by any customer endpoint. + +**Errors:** `400 invalid`, `400 invalid_name`, `404 not_found`, `500 server_error`. + +**Note:** The phone number cannot be changed. + +Source: `controllers/customerController.go:51-100`. + +--- + +### 5.4 Saved locations + +The request body for create and update uses the **all-lowercase** keys of `dto.LocationCreateRequest` (for example `receivername`, `isdefault`). The response uses **camelCase** (for example `recipientName`, `isDefault`). This mismatch is how the code works today. + +**Saved address object (response)** + +```json +{ + "id": "17", + "label": "Home", + "title": "Home", + "sub": "12 Race Course Rd, Near Park, Coimbatore, 641018", + "recipientName": "Priya Raman", + "recipientPhone": "9876543210", + "lat": 11.0015, + "lng": 76.9711, + "isDefault": true +} +``` + +| Field | Type | Notes | +|---|---|---| +| `id` | string | Location id | +| `label` | string | Can be `""` | +| `title` | string | `label`, or `address` when the label is empty | +| `sub` | string | The non-empty parts of `address`, `landmark`, `city` and `pincode`, joined with ", " | +| `recipientName`, `recipientPhone` | string | Stored as sent. The phone is **not** normalised. | +| `lat`, `lng` | number | | +| `isDefault` | boolean | | + +Source: `controllers/customerController.go:242-262`, `dto/booking.go:5-17`. + +#### 5.4.1 `GET /customer/locations` + +- **Auth:** Yes. **Purpose:** List active saved addresses, default first, then newest. +- **Success — 200 (list):** saved address objects. `total` = count, `nextCursor` = `null`. +- **Errors:** `500 server_error`. + +Source: `controllers/customerController.go:102-117`. + +#### 5.4.2 `POST /customer/locations` + +- **Auth:** Yes. **Purpose:** Save an address. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `address` | string | Yes | Must not be empty | +| `pincode` | string | Yes | Must not be empty (the format is not checked) | +| `latitude` | number | Yes | Must not be `0` | +| `longitude` | number | Yes | Must not be `0` | +| `label` | string | No | For example "Home" or "Work" | +| `receivername` | string | No | | +| `receiverphone` | string | No | Not normalised | +| `landmark` | string | No | | +| `city` | string | No | | +| `state` | string | No | | +| `isdefault` | boolean | No | When `true`, every other saved address of the customer is set to not-default. | + +```json +{ + "label": "Home", + "receivername": "Priya Raman", + "receiverphone": "9876543210", + "address": "12 Race Course Rd", + "landmark": "Near Park", + "city": "Coimbatore", + "state": "Tamil Nadu", + "pincode": "641018", + "latitude": 11.0015, + "longitude": 76.9711, + "isdefault": true +} +``` + +**Success — 201:** a saved address object. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 400 | `invalid` | The customer already has 10 active addresses ("You can save up to 10 addresses"). This is checked **before** the body is parsed. | +| 400 | `invalid` | The body cannot be parsed, or a required field is missing ("An address needs a street, a pincode and a map location") | +| 500 | `server_error` | Insert failed | + +Source: `controllers/customerController.go:119-165`. + +#### 5.4.3 `PUT /customer/locations/:id` + +- **Auth:** Yes. **Purpose:** Update a saved address that the caller owns. + +**Path params:** `id` (integer string). + +**Request body:** the same fields as create, none required. The update rules are **mixed**, so send the full object: + +| Field(s) | If the field is left out or empty | +|---|---| +| `label`, `address`, `pincode` | Kept (only overwritten when not empty) | +| `latitude`, `longitude` | Kept (only overwritten when not 0) | +| `receivername`, `receiverphone`, `landmark`, `city`, `state` | **Cleared to `""`** | +| `isdefault` | **Set to `false`** | + +**Success — 200:** a saved address object. + +**Errors:** `400 invalid` (id not numeric, or body cannot be parsed), `404 not_found` (not the caller's, or does not exist), `500 server_error`. + +**Note:** The update also works on a soft-deleted address. It does not reactivate it. + +Source: `controllers/customerController.go:167-218`. + +#### 5.4.4 `DELETE /customer/locations/:id` + +- **Auth:** Yes. **Purpose:** Soft-delete a saved address (its status is set to `InActive`). + +**Success — 200** + +```json +{ "success": true, "data": { "id": "17", "deleted": true }, "message": "" } +``` + +**Errors:** `400 invalid` (id not numeric), `404 not_found`, `500 server_error`. Deleting an address that is already deleted returns `200` again. + +Source: `controllers/customerController.go:220-240`. + +--- + +### 5.5 Devices (push registration) + +#### 5.5.1 `POST /customer/devices` + +- **Auth:** Yes. **Purpose:** Register an FCM push token for the signed-in customer. Call it after sign-in and whenever FCM gives a new token. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `token` | string | Yes | Trimmed and must not be empty. It is unique across all customers. | +| `platform` | string | No | `android` or `ios` (case-insensitive). Any other value is stored as `""`. | +| `appVersion` | string | No | Trimmed | + +```json +{ "token": "", "platform": "android", "appVersion": "1.0.3" } +``` + +**Success — 200** + +```json +{ "success": true, "data": { "registered": true }, "message": "" } +``` + +**Errors:** `400 invalid` (body cannot be parsed, or the token is empty), `500 server_error`. + +**Rules:** This is an upsert on the token. If the token was registered to another customer (shared handset), it moves to the caller. A customer can have many devices, and all of them receive pushes. + +Source: `controllers/cxDeviceController.go:22-67`. + +#### 5.5.2 `DELETE /customer/devices/:token` + +- **Auth:** Yes. **Purpose:** Remove a push token (only if it belongs to the caller). + +**Path params:** `token`. URL-encode it, because FCM tokens can contain `:`. + +**Success — 200** + +```json +{ "success": true, "data": { "registered": false }, "message": "" } +``` + +This is returned even if nothing was deleted. + +**Errors:** `400 invalid` (empty token), `500 server_error`. + +Source: `controllers/cxDeviceController.go:69-85`. + +--- + +### 5.6 Places + +Both endpoints proxy a Nominatim-compatible geocoder (`GEOCODER_URL`, default public OSM Nominatim). The app is never given a maps key. Results are cached in Redis: reverse geocode for 7 days (key rounded to 5 decimals), search for 24 hours. + +**Place object:** `{ "title": string (≤ 32 characters), "sub": string, "lat": number, "lng": number }`. `title` and `sub` are never `null`. + +#### 5.6.1 `GET /customer/places/reverse-geocode` + +- **Auth:** Yes. **Purpose:** Turn coordinates into a two-line pickup label. + +**Query params** + +| Name | Type | Required | Rules | +|---|---|---|---| +| `lat` | float | Yes | Must parse. `lat=0` together with `lng=0` is rejected. | +| `lng` | float | Yes | Must parse | + +**Success — 200** + +```json +{ "success": true, "data": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 }, "message": "" } +``` + +If the geocoder fails or times out (4 s), the response is **still 200**, with `title: "Selected location"` and `sub: ", "` (5 decimals). + +**Errors:** `400 invalid` "We need a location to look up". + +Source: `controllers/cxPlacesController.go:55-91, 153-162, 218-254`. + +#### 5.6.2 `GET /customer/places/search` + +- **Auth:** Yes. **Purpose:** Back the pickup-point search sheet. + +**Query params** + +| Name | Type | Required | Notes | +|---|---|---|---| +| `q` | string | No | Search text. **Empty or missing** returns the customer's own places (see below). There is no length limit. | +| `lat`, `lng` | float | No | Bias the results to about ±0.75° (about 80 km) around this point. The results are not strictly limited to that box. | + +**Success — 200 (list):** up to 6 place objects from the geocoder, limited to India (`countrycodes=in`). + +When `q` is empty, the list has **up to 4** places: the customer's active saved addresses (default first), then the pickup points of their 10 most recent bookings, de-duplicated by coordinates (4 decimals). Entries at 0,0 are skipped. + +If the geocoder fails, the response is `200` with an empty list (not an error). + +Source: `controllers/cxPlacesController.go:93-130, 164-188, 265-321`. + +--- + +### 5.7 Fare estimate + +#### 5.7.1 `POST /customer/fare/estimate` + +- **Auth:** Yes. **Caching:** `max-age=60`. +- **Purpose:** Get a price **range** for a whole pickup (one visit, all destinations). The app calls it whenever the route or the package count changes. This is never the final price. The miler weighs the parcels at the door. + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `pickup.lat` | number | No | 0 is allowed (then no distance is used) | +| `pickup.lng` | number | No | | +| `destinations` | array | Yes | At least 1. **There is no upper limit** (unlike booking create). | +| `destinations[].stateCode` | string | No | Not validated here | +| `destinations[].districtCode` | string | No | Looked up case-insensitively. An unknown district is priced with the fallback formula and zone `National`. | +| `destinations[].packageCount` | integer | No | Values < 1 count as 1 | + +```json +{ + "pickup": { "lat": 11.0015, "lng": 76.9711 }, + "destinations": [ + { "stateCode": "TN", "districtCode": "TN-CHN", "packageCount": 2 }, + { "stateCode": "KA", "districtCode": "KA-BLR", "packageCount": 1 } + ] +} +``` + +**Success — 200** + +```json +{ + "success": true, + "data": { "min": 312, "max": 468, "paymentMethod": "UPI · Cash at doorstep", "parcel": "3 boxes (up to 3 kg each)", "routeKm": 356.4 }, + "message": "" +} +``` + +| Field | Type | Notes | +|---|---|---| +| `min`, `max` | integer (INR) | `min` is rounded down and `max` is rounded up. `max` ≥ `min`. | +| `paymentMethod` | string | Always `"UPI · Cash at doorstep"` | +| `parcel` | string | `"Standard box (up to 3 kg)"` for 1 package, otherwise `" boxes (up to 3 kg each)"` | +| `routeKm` | number | Straight-line distance from the pickup to the **farthest** district centre, 1 decimal place | + +**How the price is computed** + +- Each destination is one leg. The assumed weight is `packageCount × 3 kg`. +- The zone (`Local`, `Regional` or `National`) comes from the nearest hub's pincode (pickup) and the district's `pincodeprefix` (`resolveZone`). +- If `doormilepricing` rules exist for that zone, service type `Normal` and the weight (category `General`, or any category), the leg range is the lowest `minprice` to the highest `maxprice` among the matching rules. +- Otherwise the fallback is `base = 50 + 5 × km + 10 × kg`, with a range of `0.8 × base` to `1.2 × base`. +- Every destination after the first is multiplied by **1.35** (+35% multi-stop uplift). +- The legs are added up. + +**Errors:** `400 invalid` (body cannot be parsed, or no destinations). Pricing failures fall back to the formula and never return an error. + +Source: `controllers/cxFareController.go:27-97, 99-204, 232-266`. + +--- + +### 5.8 Bookings + +#### 5.8.0 The booking object + +Every endpoint that returns a booking (create, list rows, detail, patch, order lookup, QA override) uses this one shape, built by `renderCxBooking`. List rows and the detail response are identical, except that `milersInZone` is only calculated on single-booking reads. + +```json +{ + "reference": "DM-482913", + "stage": "on_the_way", + "status": "active", + "cancellable": true, + "createdAt": 1790583000000, + "pickup": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 }, + "slotId": "slot_20260928_t2", + "destinations": [ + { + "stateCode": "TN", + "stateName": "Tamil Nadu", + "districtCode": "TN-CHN", + "districtName": "Chennai", + "packageCount": 2, + "district": { "code": "TN-CHN", "name": "Chennai", "available": true, "hub": "Chennai Central", "promise": "Next-day delivery" }, + "details": { + "street": "4th Cross Street", + "building": "Flat 3B, Lotus Apartments", + "landmark": "Opp. bus depot", + "recipientName": "Arun Kumar", + "recipientPhone": "+919876543210", + "instructions": "Call before arriving", + "pin": { "lat": 13.0418, "lng": 80.2341 } + }, + "trackingId": null, + "stage": null, + "verification": null + } + ], + "miler": { "name": "Ravi", "vehicle": "TN38XX0001", "phone": "", "rating": 4.8, "trips": 212, "vehicleType": "Bike" }, + "deliveryAgent": null, + "milerDistanceKm": 1.4, + "milerEtaMinutes": 6, + "milersInZone": 0, + "routeKm": 498.2, + "expectedDelivery": "", + "fare": { "min": 180, "max": 260, "paymentMethod": "UPI · Cash at doorstep", "parcel": "2 boxes (up to 3 kg each)" }, + "amountPaid": null, + "deliveredAt": null, + "cancelReason": null, + "history": [ + { "stage": "booked", "at": 1790583000000 }, + { "stage": "assigned", "at": 1790583300000 }, + { "stage": "on_the_way", "at": 1790583420000 } + ] +} +``` + +| Field | Type | Nullable | Notes | +|---|---|---|---| +| `reference` | string | No | `DM-######` (§1.4) | +| `stage` | string | No | One of the 9 stage keys (§6.1). For a booking with no stored stage, it is derived from the operational status (§6.2). | +| `status` | string | No | `active`, `completed` or `cancelled`. It is **forced to `cancelled`** whenever the operational status is `Cancelled`, even if ops cancelled it. | +| `cancellable` | boolean | No | `status == "active"` and stage rank ≤ `arrived`. Use it to show or hide the button. The server checks again on cancel. | +| `createdAt` | integer (epoch ms) | No | | +| `pickup` | object | No | `{title, sub, lat, lng}`. Always present. Bookings made in the console get a title and sub split from the flat address ("Pickup address" or "Address not recorded" as a last resort). | +| `slotId` | string | No | Can be `""` for bookings not made by the app | +| `destinations` | array | No | Can be empty for bookings made in the console. See the table below. | +| `miler` | object | Yes | The rider whose assignment is `Assigned`, `Accepted` or `Completed`. `{name, vehicle, phone, rating, trips, vehicleType}`. `phone` is the `MILER_CALL_PROXY` number if that is configured, **otherwise the rider's real mobile number**. | +| `deliveryAgent` | object | Yes | The same shape as `miler`: the rider who recorded `Out_for_Delivery` on the first destination that has one | +| `milerDistanceKm` | number | Yes | Only when `stage` is `on_the_way` or `arrived` and a miler exists. `0` at `arrived`, or when the rider has no position. Straight-line distance, 1 decimal. | +| `milerEtaMinutes` | integer | Yes | The same condition. `km / 18 km/h × 60 + 2`. `0` at `arrived`. | +| `milersInZone` | integer | No | Riders within 6 km of the pickup. Calculated **only** on a single-booking read, at stage `booked`, with no miler. Otherwise `0`. | +| `routeKm` | number | No | Stored from the quote at create | +| `expectedDelivery` | string | No | The latest `expecteddeliveryat` across the destinations, formatted `"Mon, 2 Jan"` (IST). `""` until one is set (at pickup-complete). | +| `fare` | object | No | `{min, max, paymentMethod, parcel}` from the estimate stored at create. It stays on the booking permanently. | +| `amountPaid` | integer (INR) | Yes | The sum of `Paid` payment rows. Present only from stage `picked_up` onward and only if a payment exists. | +| `deliveredAt` | integer (epoch ms) | Yes | Set only when **every** destination is delivered (the latest delivery time) | +| `cancelReason` | string | Yes | Set when a cancel reason is stored. A customer cancel with no reason leaves it `null`. | +| `history` | array | No | `[{stage, at}]`. One entry per stage actually reached, with real timestamps. For per-order stages (`in_transit` onward) the timestamp is when the **slowest** order reached it. Cancel and release audit rows are left out. **Order:** entries are sorted by occurrence time, then by event id. At pickup-complete, `order_created` (and possibly `in_transit`/`out_for_delivery`) rows are written **before** the `picked_up` row, at almost the same moment. Sort by stage rank (§6.1) if strict order matters. | + +**Destination object** + +| Field | Type | Nullable | Notes | +|---|---|---|---| +| `stateCode`, `stateName`, `districtCode`, `districtName` | string | No | Names are copied at booking time | +| `packageCount` | integer | No | | +| `district` | object | Yes | Live district card `{code, name, available, hub?, promise?}`. `null` if the district row is gone. | +| `details` | object | No | Only the fields that are filled in: `street`, `building`, `landmark`, `recipientName`, `recipientPhone`, `instructions`, `pin{lat,lng}`. Can be `{}`. **`codAmount` is accepted in requests but never returned.** | +| `trackingId` | string | Yes | `DMX########` from `order_created` onward | +| `stage` | string | Yes | Per-order stage from `order_created` onward | +| `verification` | object | Yes | From pickup: `{weightKg: number, photos: [signed URL strings, valid 30 min], capturedAt: epoch ms, capturedBy: rider name or ""}`. Photos that fail to sign are left out. | + +Source: `controllers/cxBookingView.go:66-307, 368-508`. + +#### 5.8.1 `POST /customer/bookings` + +- **Auth:** Yes. **Middlewares (in order):** Auth → Role 9 → `CityGateMiddleware` → `Idempotency`. +- **Purpose:** Create a pickup booking: one visit, 1..N destinations. No tracking number exists yet. +- **Send `Idempotency-Key`** on every create and reuse it for retries (§4.4). + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `pickup` | object | Yes | | +| `pickup.title` | string | Recommended | Short label from place search. Not validated. | +| `pickup.sub` | string | Recommended | Full address line. Not validated. | +| `pickup.lat` | number | Yes | Must resolve to an operating city (§4.7). `0,0` returns `422`. | +| `pickup.lng` | number | Yes | | +| `slotId` | string | Yes | From `GET /pickup-slots` | +| `destinations` | array | Yes | 1 to `maxDestinations` (configured; default **1**). Hard ceiling 25. | +| `destinations[].stateCode` | string | Yes | Must not be blank. **The stored state comes from the district row, not this value.** | +| `destinations[].districtCode` | string | Yes | Case-insensitive. Must exist in `serviceabledistricts` and be `available`. | +| `destinations[].packageCount` | integer | No | < 1 becomes 1. The total across destinations must be ≤ `maxPackages` (default 20). | +| `destinations[].details` | object | No | Optional address details (below) | +| `estimate` | object | No | `{min: int, max: int}`, the range shown on the Review screen. See the price-tamper rule below. | +| `remarks` | string | No | Free-text note (for example "Handle with care"). Stored in the booking notes and shown in the admin console. | + +`details` object (every field optional; also the body of the PATCH endpoint): + +| Field | Type | Rules | +|---|---|---| +| `street` | string | Trimmed | +| `building` | string | Trimmed | +| `landmark` | string | Trimmed | +| `recipientName` | string | Trimmed | +| `recipientPhone` | string | Normalised per §1.6. If that fails, the raw trimmed value is stored. | +| `instructions` | string | Trimmed | +| `pin` | object `{lat, lng}` | An exact destination pin. `{0,0}` clears it. | +| `codAmount` | number | Cash to collect at this door for the customer. **Not validated** (a negative number is accepted). Never returned. | + +```json +{ + "pickup": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 }, + "slotId": "slot_20260928_t2", + "destinations": [ + { + "stateCode": "TN", + "districtCode": "TN-CHN", + "packageCount": 2, + "details": { + "building": "Flat 3B, Lotus Apartments", + "street": "4th Cross Street", + "recipientName": "Arun Kumar", + "recipientPhone": "9876543210", + "pin": { "lat": 13.0418, "lng": 80.2341 }, + "codAmount": 0 + } + } + ], + "estimate": { "min": 180, "max": 260 }, + "remarks": "Handle with care" +} +``` + +**Validation order** (the first failure wins): + +| # | Check | Result on failure | +|---|---|---| +| 1 | The body parses | 400 `invalid` "We could not read that request" | +| 2 | At least one destination | 400 `invalid` "Add at least one destination" | +| 3 | `slotId` is not blank | 400 `invalid` "Pick a pickup slot" | +| 4 | The slot date is not before today (IST) | 400 `invalid` "That pickup time has passed — pick a new slot" | +| 5 | At most 25 destinations | 400 `invalid` "That is more destinations than one pickup can carry" | +| 6 | The pickup point is in an operating city | **422 `unserviceable`** "We are not collecting from that area yet" | +| 7 | At most `maxDestinations` | 400 `invalid` "Up to N destinations per pickup" | +| 8 | Every destination has a known district and a non-blank `stateCode` | 400 `invalid` "Every destination needs a serviceable state and district" | +| 9 | Every district is `available` | **422 `unserviceable`** "That district is no longer available" | +| 10 | Total packages ≤ `maxPackages` | 400 `invalid` "Up to N packages per pickup" | +| 11 | The slot id resolves to an active template | 400 `invalid` "Pick a pickup slot" | +| 12 | The slot start is after now (IST) | 400 `invalid` "That pickup time has passed — pick a new slot" | +| 13 | The slot has capacity in the pickup zone | **409 `conflict`** "That pickup window just filled up" | + +Other errors: `500 server_error` (any database failure; the whole create is one transaction, so nothing partial is left behind), the non-envelope `409 IDEMPOTENCY_IN_PROGRESS`, and the non-envelope `400 CITY_NOT_SUPPORTED` (only if you send `pickuppincode`). + +**Success — 201:** the booking object (§5.8.0), with `stage: "booked"` and `status: "active"`. + +**Business rules and side effects** + +- **Price-tamper protection (commit `ba2cd22`):** The server always calculates its own quote with the same function as `/fare/estimate`. The client `estimate` is kept **only if** `min ≥ 0`, `max ≥ min`, the server quote midpoint is > 0, and the client midpoint is within **±15%** of the server midpoint. Otherwise the server quote is stored silently: no error, only a server log line. The stored range becomes `fare.min`/`fare.max`, and its midpoint becomes the booking's `Estimatedprice` (which feeds rider pay and billing). **Always send the exact `min`/`max` from the most recent `/fare/estimate` response.** +- A zero or failed quote never blocks the booking. +- The slot lead time (45 min) is **not** checked again at create. Only "the start is in the future" is checked. +- The capacity check is not atomic. Two bookings at the same moment can both succeed in the last place of a window. +- Created rows: one `pickupbookings` row (status `Pending_Pickup`, source `Customer_App`, destination 0 copied onto the flat delivery columns), one `bookingdestinations` row per destination (`seq` 0..N-1), one `bookingparcels` row per package (weight 0), one `bookingserviceoptions` row (`Normal`, estimated delivery = slot end + 24 h, SLA = slot end + 48 h), and a `booked` stage event. +- After commit: automatic miler assignment starts in the background (`assignment.AssignCustomerMiler`), and the NATS event `api.v1.bookings.create` is published (best effort). +- There is **no per-customer limit** on the number of bookings or active bookings. + +Source: `controllers/cxBookingController.go:48-118` (types and tamper check), `120-440` (handler), `895-922` (event). + +#### 5.8.2 `GET /customer/bookings` + +- **Auth:** Yes. **Purpose:** List bookings for the Orders tabs and the Home screen's recent list. + +**Query params:** `status`, `limit`, `cursor` (§4.3). + +Tab filters: + +| `status` | Rows included | +|---|---| +| `active` | `customerstatus = 'active'`, **or** (no customer status **and** operational status is not `Cancelled`) | +| `completed` | `customerstatus = 'completed'` | +| `cancelled` | `customerstatus = 'cancelled'` **or** operational status `Cancelled` | +| any other value, or missing | All bookings of the customer | + +**Success — 200 (list):** `data` is an array of booking objects. There is also `total` and `nextCursor`. + +**Errors:** `500 server_error`. If only the count fails, `total` falls back to the page size. + +**Known edge case:** A booking cancelled by ops through a path that sets only the operational status (for example the admin "update status" endpoint) can match **both** the `active` and `cancelled` filters, because its stored `customerstatus` stays `active`. It is still rendered with `status: "cancelled"`. Filter on the returned `status` on the client if needed. + +Source: `controllers/cxBookingController.go:442-515`. + +#### 5.8.3 `GET /customer/bookings/:reference` + +- **Auth:** Yes. **Caching:** `ETag` / `If-None-Match` → `304`. +- **Purpose:** The canonical single booking read. It drives the tracking screen and the receipt, and the app polls it while tracking is open. The poll interval is decided by the app. The code comment says "every few seconds". + +**Path params:** `reference` (for example `DM-482913`). It is matched exactly and is **scoped to the caller**. + +**Success — 200:** the booking object. `Cache-Control: no-cache`. + +**Errors:** `404 not_found` "We could not find that pickup" (does not exist, or belongs to someone else). + +Source: `controllers/cxBookingController.go:517-538`. + +#### 5.8.4 `POST /customer/bookings/:reference/cancel` + +- **Auth:** Yes. **Purpose:** Cancel the whole pickup. Cancelling part of a pickup is not supported. + +**Request body** (optional; an unreadable body is ignored) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `reason` | string | No | Trimmed and stored as `cancelReason` | + +```json +{ "reason": "Changed my mind" } +``` + +**Success — 200** + +```json +{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled", "cancelReason": "Changed my mind" }, "message": "" } +``` + +If the booking is **already cancelled**, the response is also `200`, with the stored reason (a retry-safe no-op). + +**Errors** + +| Status | Code | When | +|---|---|---| +| 404 | `not_found` | Unknown reference, or not the caller's | +| 409 | `conflict` | Stage is `picked_up` or later: "This pickup can no longer be cancelled" | +| 500 | `server_error` | Transaction failure | + +**Rules and side effects** + +- Allowed at stages `booked`, `assigned`, `on_the_way` and `arrived`. If the booking has no stored stage, the stage is derived from the operational status. +- In one transaction: the booking gets status `Cancelled` and customer status `cancelled`, a cancel audit event is written, any open (`Assigned`/`Accepted`) assignment becomes `Cancelled` ("cancelled by customer"), and the assigned rider is set back to `Available`. +- After commit: the NATS event `api.v1.bookings.cancel` is published. **No push is sent to the customer.** Code for notifying the rider is not visible in this handler. +- There is no cancellation fee logic. +- The endpoint does not take an `Idempotency-Key`. It is naturally idempotent (the already-cancelled case returns 200). + +Source: `controllers/cxBookingController.go:570-657`, `internal/cxstage/stage.go:261-303`. + +#### 5.8.5 `PATCH /customer/bookings/:reference/destinations/:index` + +- **Auth:** Yes. **Purpose:** Fill in or correct one destination's address details before the parcels are collected. + +**Path params** + +| Name | Type | Rules | +|---|---|---| +| `reference` | string | The caller's booking | +| `index` | integer | 0-based position (`seq`) of the destination in `destinations[]`. It must be ≥ 0. | + +**Request body:** a `details` object (the same fields as §5.8.1). + +- **A field that is left out is not changed.** +- A string field sent as `null` is also left unchanged (the code only writes non-nil values). To clear a text field, send `""`. +- To clear the pin, send `"pin": {"lat": 0, "lng": 0}`. +- `stateCode`, `districtCode` and `packageCount` **cannot** be changed. + +```json +{ "landmark": "Opp. bus depot", "recipientPhone": "9876543210", "instructions": "Call before arriving" } +``` + +**Success — 200:** the full updated booking object. + +**Errors** + +| Status | Code | When | +|---|---|---| +| 404 | `not_found` | `index` is not numeric or is negative, the booking is not found, or no destination exists at that index (bookings from the console have none) | +| 409 | `conflict` | Stage is `picked_up` or later: "Your packages have been collected — these details can no longer be changed" | +| 409 | `conflict` | The booking is cancelled: "This pickup was cancelled" | +| 400 | `invalid` | The body cannot be parsed (including an empty body) | +| 500 | `server_error` | Save failure | + +**Side effects:** For `index` 0, the booking's flat `deliveryaddress` (and the delivery lat/lng when a pin is set) is updated as well, so the rider app sees the change straight away. No push or event is sent. + +Source: `controllers/cxBookingController.go:659-741, 776-816`. + +--- + +### 5.9 Orders / tracking + +#### 5.9.1 `GET /customer/orders/:trackingId` + +- **Auth:** Yes. **Purpose:** Open a booking from a push deep link (`doormile://track/`). + +**Path params:** `trackingId`, for example `DMX10482913` (matched exactly). + +**Success — 200:** the **whole booking object** (§5.8.0) that contains this order, not a separate order shape. Find the matching destination by `destinations[].trackingId`. + +**Errors:** `404 not_found` "We could not find that order". This is returned for an unknown tracking number **and** for one that belongs to another customer. + +**Note:** Push deep links fall back to `doormile://track/` when there is no tracking number yet (§8). That value is a booking reference, not a tracking id. Route it to `GET /customer/bookings/:reference` instead. + +Source: `controllers/cxBookingController.go:540-568`. + +--- + +### 5.10 Ops / QA endpoint (must not be used by the shipped app) + +#### 5.10.1 `POST /customer/ops/bookings/:reference/stage` + +- **Auth:** Yes (a normal customer token). **Environment gate:** works only when `ENV` is **not** `production` **and** `CX_ALLOW_STAGE_OVERRIDE=true`. Otherwise it returns `404 not_found` "Not available". +- **Purpose:** Move one of your own bookings to any stage, so that every tracking screen can be checked in QA. **Remove any client code that calls this from release builds.** + +**Request body** + +| Field | Type | Required | Rules | +|---|---|---|---| +| `stage` | string | Yes | One of the 9 stage keys (§6.1) | +| `reason` | string | No | Default "forced on staging for QA". Stored on every audit row. | + +```json +{ "stage": "out_for_delivery", "reason": "design QA" } +``` + +**Success — 200:** the updated booking object. + +**Errors:** `404 not_found` (disabled, or booking not found), `400 invalid` (body cannot be parsed, or unknown stage), `500 server_error`. + +**Behaviour** + +- Writes a stage event for every stage from `booked` up to the target, with no gaps. Per-order stages are written for each destination. +- Creates tracking numbers for destinations that have none when the target is `order_created` or later. +- Stages only move **forward**. You cannot force a stage lower than the current one. +- It does **not** change the operational status, assignments, payments or `verification`. So `miler`, `amountPaid` and `verification` stay `null`. +- It sends no pushes. +- It does not work on bookings not made by the customer app. + +Source: `controllers/cxOpsController.go:16-160`, `routes/routes.go:173-178`. + +--- + +### 5.11 WebSockets + +See §7. + +### 5.12 Public pricing endpoints (not part of the customer contract) + +These are registered at the API root, not under `/customer`. They use the **older** envelope `{success, data}` with no `message`/`error.code`, and field names in snake_case. The customer app does not need them, because `/customer/fare/estimate` covers the booking flow. They are listed here only because they are public. + +| Endpoint | Auth | Body / response | +|---|---|---| +| `GET /pricing/meta` | No | `data: {zones:[{value,label}], categories:[{value,label}], service_types:[{value,label}]}` | +| `POST /pricing/check` | No | Body `{zone?, service_type, weight, category?, pickup_pincode?, delivery_pincode?}`. `zone` is `Local`, `Regional` or `National` (or resolved from the two pincodes). `service_type` is `Normal` or `Express`. `weight` must be > 0. Errors are `400 {success:false, message, valid_*}`. | + +Source: `routes/routes.go:498-502`, `controllers/doormilePricingController.go:159-231, 457-479`. + +--- + +## 6. Booking lifecycle + +### 6.1 Customer stages + +Nine keys, lowercase snake_case, sent exactly as written. **The client falls back to `booked` for an unknown key**, so stages cannot be added or renamed without an app release. + +| Rank | `stage` | Level | Meaning | Cancel allowed | Destination PATCH allowed | +|---|---|---|---|---|---| +| 0 | `booked` | Booking | Pickup requested, no rider yet | Yes | Yes | +| 1 | `assigned` | Booking | A rider has been assigned | Yes | Yes | +| 2 | `on_the_way` | Booking | The rider accepted and is heading to the pickup (distance and ETA shown) | Yes | Yes | +| 3 | `arrived` | Booking | The rider is at the pickup. **Last cancellable stage.** | Yes | Yes | +| 4 | `picked_up` | Booking | Parcels collected, weighed and photographed | No | No | +| 5 | `order_created` | Booking | One order and tracking number per destination | No | No | +| 6 | `in_transit` | Per order | The order is in the hub network | No | No | +| 7 | `out_for_delivery` | Per order | A delivery rider is carrying it | No | No | +| 8 | `delivered` | Per order | Handed over | No | No | + +- Stages 0–5 belong to the booking. Stages 6–8 belong to each destination (`destinations[].stage`). The booking-level `stage` is the **slowest** destination's stage. A destination with no stage yet counts as `order_created`. +- `status`: `active` until every destination is `delivered` (then `completed`). `cancelled` is terminal. A cancelled booking keeps the stage it had. +- Stages only move forward. The exception is a **rider release**: when the rider cancels their assignment, the booking goes back to `booked` (the history keeps the earlier entries). + +Source: `constants/constants.go:206-275`, `internal/cxstage/stage.go:62-259`. + +### 6.2 What moves each stage + +| Stage | Written by (backend event) | Push to customer? | +|---|---|---| +| `booked` | `POST /customer/bookings` | No | +| `assigned` | A rider is assigned: automatically (`internal/assignment/crm_assignment.go:257`) or by the console/hub (`controllers/booking_assignment_service.go:80`) | Yes | +| `on_the_way` | The rider accepts: `POST /miler/assignments/:id/accept` (`controllers/milerController.go:612`) | No (see §8) | +| `arrived` | The rider taps reached: `POST /miler/bookings/:id/reached` (`controllers/milerController.go:869`) | Yes | +| `picked_up`, `order_created` | `POST /miler/bookings/:id/pickup-complete` (`controllers/milerController.go:1435-1490`). It also writes `in_transit` or `out_for_delivery` straight away when the new consignment's status already implies it (hub-routed parcels are `Inwarded_at_Hub`; hyperlocal parcels go straight to `Out_for_Delivery`, while the hub-handover feature flag is off). | Yes (`picked_up` only) | +| `in_transit` | Base handover: `POST /miler/consignments/:id/inward-at-hub` (`controllers/logisticsHandoverController.go:541`) | Yes | +| `out_for_delivery` | `POST /miler/consignments/:id/start-delivery` (`controllers/milerAppController.go:733`) | Yes | +| `delivered` | `POST /miler/consignments/:id/deliver` (`controllers/milerAppController.go:928`) | Yes | +| back to `booked` | The rider cancels: `POST /miler/bookings/:id/cancel` (`controllers/milerController.go:793`) | No | +| cancelled | Customer cancel; admin cancel and bulk cancel (`controllers/adminController.go:2964, 3052`) | Customer: no. Admin: only through the legacy device-token column (§8). | + +### 6.3 Mapping from backend status (bookings with no stored stage) + +Bookings made in the console, and rows from before this surface existed, have no `customerstage`. Their stage is derived from `pickupbookings.status`: + +| Operational booking status | Customer stage | +|---|---| +| `Cancelled` | `booked` (and `status` = `cancelled`) | +| `Picked_Up` | `picked_up` | +| `Converted_To_Consignment` | `order_created` | +| `Miler_Assigned`, `Pickup_Scheduled` | `arrived` if the arrival time is recorded, else `assigned` | +| Anything else (`Pending_Pickup`, `Created`, `Arrived_At_Pickup`, …) | `booked` | + +Consignment status to per-order stage (used when consignment events are recorded): + +| Consignment status | Per-order stage | +|---|---| +| `Inwarded_at_Hub`, `Tripsheet_Loaded`, `In_Transit` | `in_transit` | +| `Out_for_Delivery` | `out_for_delivery` | +| `Delivered` | `delivered` | +| `Created`, `Collected_By_Miler` | none (stays `order_created`) | +| `RTO_Initiated`, `Returned_to_Sender`, `Missing`, `Damaged`, and failed attempts | **none. The customer keeps seeing the last stage** (§9.2). | + +Source: `controllers/cxBookingView.go:370-402`, `controllers/cxConsignmentHooks.go:20-41`. + +### 6.4 Allowed customer actions by stage + +| Action | Allowed when | +|---|---| +| Cancel | `status = active` and stage rank ≤ 3 (`arrived`). Already cancelled returns 200 (no-op). | +| PATCH destination details | Stage rank < 4 (before `picked_up`) and not cancelled | +| Change pickup point, slot, destinations or package counts after creation | **Not supported.** No endpoint exists. | + +--- + +## 7. WebSockets and live tracking + +WebSocket routes are at the server root (`wss:///ws/...`), not under `/api/v1`. A non-upgrade HTTP request to `/ws/*` gets `426 Upgrade Required`. They are exempt from the global rate limiter. + +### 7.1 `WS /ws/bookings/:bookingid/track` — live rider position + +> **SECURITY NOTE: THIS ENDPOINT HAS NO AUTHENTICATION.** Anyone who knows or guesses a numeric internal booking id can open it and receive the assigned rider's name and live GPS position until pickup. Internal booking ids are sequential integers. See §9.2. + +| Item | Value | +|---|---| +| URL | `wss://api.doormile.com/ws/bookings//track` | +| `bookingid` | The **internal numeric booking id**, **not** the `DM-…` reference. The customer booking object does not contain it. The app can only get it from the push payload field `booking_id` (§8), or indirectly from the `nextCursor` value. | +| Auth | None | +| Client → server | Nothing is expected. Messages are read only to detect disconnects. | +| Server → client frequency | One text frame every **2 seconds** | +| Frame | `{"lat": number, "lon": number, "eta_minutes": number, "status": string, "miler_name": string}` | +| `status` | The **operational** booking status (for example `Pending_Pickup`, `Miler_Assigned`), **not** the customer stage | +| `lat`/`lon`/`eta_minutes` | `0` when no rider is assigned or no position is known. The ETA assumes 20 km/h + 2 min (the REST booking object uses 18 km/h). | +| Closes when | The status becomes `Picked_Up`, `Converted_To_Consignment` or `Cancelled` (one last frame, then close), the client disconnects, or the booking can no longer be read | +| Errors | `{"error": "invalid booking ID"}` or `{"error": "booking not found"}`, then close | + +Example frame: + +```json +{ "lat": 11.0102, "lon": 76.9655, "eta_minutes": 6, "status": "Miler_Assigned", "miler_name": "Ravi" } +``` + +The recommended source for the tracking screen is polling `GET /customer/bookings/:reference` (with ETag). It is authenticated and carries `milerDistanceKm`/`milerEtaMinutes`. + +Source: `internal/ws/tracking.go:20-215`, `routes/routes.go:542-551`. + +### 7.2 `WS /ws/bookings/:bookingid/chat` — customer ↔ miler chat + +| Item | Value | +|---|---| +| URL | `wss://api.doormile.com/ws/bookings//chat?token=&role=customer` | +| Auth | `WsChatAuth`: a JWT in the `token` query parameter (any valid token of **any role**). Missing token: `401 {"error": "token query parameter is required"}`. Bad token: `401 {"error": "invalid or expired token"}`. | +| Ownership check | **None.** The server does not check that the caller owns the booking or matches `role` (§9.2). | +| `role` | `customer` or `miler`. Each role slot can be held by one connection. A second connection gets `{"error": "customer is already connected to this room"}`. | +| Client → server | Send plain text messages. Each message is forwarded to the other participant. | +| Server → client | `{"sender": "customer"|"miler", "message": string, "timestamp": RFC3339 UTC string}` | +| History / persistence | None. Messages sent while the other side is not connected are lost. Rooms are held in memory per server process. | +| Closes when | The booking reaches `Picked_Up`, `Converted_To_Consignment` or `Cancelled` (checked every 5 s; NATS `chat.room.closed.` is published), or both participants leave. It refuses to open for a booking that is already in one of those states. | + +Source: `internal/ws/chat.go:17-271`, `middlewares/ws_auth.go:10-36`. + +--- + +## 8. Push notifications + +### 8.1 Registration + +- Register with `POST /customer/devices` after every sign-in and on every FCM token refresh. Unregister with `DELETE /customer/devices/:token`, or pass `deviceToken` to `/auth/logout`. +- Pushes are sent through Firebase Cloud Messaging (Admin SDK). If the server has no `FIREBASE_SERVICE_ACCOUNT_PATH`, every push is silently skipped. + +Source: `internal/notify/fcm.go:15-80`. + +### 8.2 Stage-change pushes (sent to every registered device of the customer) + +Sent by `cxstage.Notify` **after** the database transaction commits: + +| Stage | Title | Body | +|---|---|---| +| `assigned` | Miler assigned | Your Miler is on the way to collect your packages. | +| `arrived` | Your Miler has arrived | They are at your pickup address now. | +| `picked_up` | Packages collected | Your Miler has collected and weighed your packages. | +| `in_transit` | In transit | Your package is on its way to the destination. | +| `out_for_delivery` | Out for delivery | Arriving today at the delivery address. | +| `delivered` | Delivered | Your package has been handed over. | + +`booked`, `on_the_way` and `order_created` do not trigger a push. + +FCM `data` payload (all values are strings): + +```json +{ + "type": "stage_change", + "reference": "DM-482913", + "stage": "out_for_delivery", + "title": "Out for delivery", + "body": "Arriving today at the delivery address.", + "trackingId": "DMX10482913", + "deepLink": "doormile://track/DMX10482913", + "booking_id": "5120" +} +``` + +- `trackingId` is present only for per-order stages whose destination has a tracking number. +- Otherwise `deepLink` is `doormile://track/` (the booking reference). +- `booking_id` is the internal numeric id. + +Source: `internal/cxstage/stage.go:305-400`. + +### 8.3 Legacy pushes (not delivered to `doormile_cx` devices) + +Several older code paths still send to the single `appcustomers.device_token` column: "Miler Accepted" on accept, "Parcel Picked Up", "Out for Delivery", the delivery push in `milerAppController.go`, and "Booking Cancelled" from admin cancel. **No `/customer/*` endpoint writes that column**, so for `doormile_cx` users these pushes are only sent if the column was filled by the retired app or the console. As a result: + +- A customer cancel sends no push. +- An ops cancel usually sends no push to `doormile_cx` devices. + +Source: `controllers/milerController.go:640-652, 1595-1620`, `controllers/milerAppController.go:758-765, 959-961`, `controllers/adminController.go:2989-2990, 3074-3075`. + +--- + +## 9. Known limitations, open issues, and changes vs previous docs + +### 9.1 Blockers and interim behaviour the app team must know + +1. **Interim PIN auth does not prove the customer owns the phone.** `POST /auth/set-pin` will (a) create an account for **any** phone number, and (b) set the first PIN on **any existing account that has no PIN**. That includes every account created through OTP signup or verify, or by the console. Whoever calls it first owns the account. This is an account-takeover and phone-squatting risk until OTP (with a real SMS gateway) replaces it. There is also no PIN reset or change endpoint for customers. +2. **SMS gateway.** Without `SMS_GATEWAY_URL`, phone OTP fails in production (`500`) and only reaches the log elsewhere. Staging can use `CX_STAGING_OTP` (§3.5). +3. **Default `maxDestinations` is 1.** Multi-destination bookings stay off until ops adds a `customerbookinglimits` row. Always read `GET /config/booking-limits`. +4. **The QA endpoint `POST /customer/ops/bookings/:reference/stage` must not ship** in release builds. It is disabled in production (returns 404). +5. **Public OSM Nominatim is the default geocoder.** It has a strict usage policy (about 1 request per second, and an identifying contact is required). With `GEOCODER_EMAIL` empty by default, production search and reverse geocode may be throttled. Failures degrade quietly: an empty list, or "Selected location". +6. **The rider's real phone number is exposed** in `miler.phone` and `deliveryAgent.phone` unless `MILER_CALL_PROXY` is configured. The code marks this as "a setting to close before launch". + +### 9.2 Other limitations and security concerns + +- **The production `ENV` value is unverified.** The staging OTP bypass and the QA stage override are only blocked when `ENV=production`. An older doc reports that `api.doormile.com` was not running with `ENV=production`. If that is still true and `CX_STAGING_OTP` is set there, anyone could sign in to any account with the fixed code. Ops should confirm this. +- **Unauthenticated WebSocket tracking** (`/ws/bookings/:bookingid/track`). It streams a rider's name and live location for any booking id. The ids are sequential, so they can be enumerated. The endpoint is not rate-limited. +- **Chat WebSocket has no ownership check.** Any valid JWT (any role, any customer) can join any booking's chat as `customer` or `miler`. The token travels in the URL query string, where it can end up in proxy logs. +- **The operating-city check is ineffective** for far-away pickups. `pincodeForPoint` takes the nearest active hub with **no distance limit**, so a pickup anywhere in India resolves to some operating-city hub and passes. Only `0,0` fails. +- **PIN brute force.** There is no per-account lockout on `verify-pin`. The only limit is 10/min per IP (4-digit PINs are 10,000 combinations). +- **`/auth/login` discloses** whether a phone is registered **and the account holder's name** to anonymous callers. +- **The shared `authThrottle`** (10/min per IP across all apps' logins and `refresh`) can block many real users behind one carrier NAT IP. If `TRUSTED_PROXIES` is not set behind a proxy, the whole fleet shares a single budget. +- **Access tokens cannot be revoked.** Logout and blocking take effect only when the token expires (≤ 1 h). Only `/auth/me` re-checks `Blocked`. The `Deleted` customer status is never checked by any auth path. +- **Refresh rotation is strict.** Two parallel refreshes sign the user out everywhere (§3.8). +- **Profile email** is not validated, not lowercased and not unique. Email-OTP sign-in looks up the first account with that email, so two accounts can share an address and the sign-in result is unclear. +- **`codAmount`** (money) and `recipientPhone` are not validated on booking create or PATCH. +- **`/fare/estimate` has no limit on the number of destinations** and runs a pricing query for each one. It is also not Redis-cached, although a code comment says it is (`matchedPricingRules` calls `loadFromPostgres`). `/places/search` has no limit on the length of `q`. +- **A rider rejecting before accepting** (`RejectMilerAssignment`) does not move the stage back. The customer can see `assigned` with `miler: null` until the booking is assigned again. A pre-pickup skip (`MilerSkipPickup`) also leaves the stage unchanged. +- **Failed delivery, RTO, missing and damaged are invisible to the customer.** There is no stage key for them, so the order keeps showing its last stage (for example `out_for_delivery`). +- **Slot handling:** the lead time is not checked again at create; the capacity check is not atomic; slot templates of other cities are not rejected at create. +- **Chat is in memory per process.** With several backend replicas, the customer and the miler may land on different pods and never see each other. +- **The `Replacedbyid` refresh-chain column is never written**, although the code comment says the chain is recorded. +- **No latency measurements exist.** The code does not include integration tests against real Postgres, Redis or NATS for these endpoints (per `CLAUDE.md` §8.5). Treat the behaviour as verified by unit tests and reading the code only. +- **Hard-coded fallback secrets in `config/config.go`** (backend concern, not an app concern). If `JWT_SECRET_KEY` (and the database and NATS credentials) are not set in the deployment environment, built-in default values from the source code are used. If the JWT default were ever used in production, anyone who can read the source could forge customer tokens. The deployment should be checked to confirm these variables are set. + +### 9.3 Changes vs previous docs + +The older documents were compared against the code at `44ba33e`. **The code wins in every case below.** The main points for the app team: + +**Missing from all earlier docs** + +- The interim PIN routes `POST /customer/auth/login`, `/auth/set-pin` and `/auth/verify-pin` (commit `f1dbf7e`). The error codes `invalid_pin`, `pin_not_set` and `pin_already_set` are also missing. +- The route count is **31** customer routes (11 public + 20 authenticated), not 28. +- The WebSocket routes (`/ws/bookings/:bookingid/track`, which has no auth, and `/ws/bookings/:bookingid/chat`). +- The responses that do not use the customer envelope: middleware 401/403, throttle 429, idempotency 409 `IDEMPOTENCY_IN_PROGRESS`, and city gate 400 `CITY_NOT_SUPPORTED`. Several docs say "every `/customer/*` response puts its payload in `data`". That is not true for these. +- The price-tamper behaviour from `ba2cd22`. A client `estimate` more than 15% away from the server quote is **silently replaced**, not rejected and not stored as sent. `openapi-customer.yaml` says it is "stored verbatim". The handbook says it is "rejected". + +**`docs/customer-app-api-crisp.md`** (most payloads are wrong; do not use it) + +- The error envelope is shown as `{success, error:{code, message}}`. The real shape is `{success, message, error:{code}}`. +- All error codes are invented upper-case values (`INVALID_INPUT`, `SLOT_EXPIRED`, `SLOT_CAPACITY_FULL`, `BOOKING_NOT_CANCELLABLE`, `UNSERVICEABLE_PINCODE`, `INTERNAL_ERROR`, …). The real codes are the lower-case set in §4.2. +- OTP verify uses the field `otp`. The real field is `code`, and `name` is also accepted. `customer.id` is shown as an integer. It is the string `cust_`. +- Fare request and response: the doc uses `pickup.latitude/longitude`, per-package `weightKg`, and `minRupees/maxRupees/breakdown`. The code uses `pickup.{lat,lng}` and `packageCount`, and returns `{min, max, paymentMethod, parcel, routeKm}`. +- Booking request: the doc uses `pickup.latitude/longitude/contactName/contactPhone` and puts recipient and address fields flat on the destination. The code uses `pickup.{title,sub,lat,lng}` and nests those fields under `details{}` (with `pin{lat,lng}`). **Flat fields are silently dropped.** `estimate` and `remarks` are missing from the doc. +- Booking response: the doc shows `pickup.latitude/longitude`, `index`, and `codAmount` on destinations. The real object is §5.8.0. +- The doc says booking-limits returns `maxCodAmount`. It does not. +- The doc says pickup-slots supports ETag. It does not (only states, districts and booking detail do). +- The doc says cancel is allowed "strictly before arrived". It is allowed **through** `arrived`. The stage diagram leaves out `on_the_way` and lists `cancelled` as a stage. `cancelled` is a status. + +**`docs/customer-app-integration-handbook.md`** + +- It names the target app `doormile_customer_app`, which is retired. The target is `doormile_cx`. It says 28 routes. +- `unserviceable` is listed as HTTP 400. It is **422**. +- `forbidden` is described as "not your resource". Resources you do not own return **404**. `403` means a blocked account or the wrong role. +- The throttle is described as shared across 4 endpoints. It is shared across all 7 customer auth routes plus the miler, admin and hub logins. +- The example slot id is `"2026-09-16T10:00"`. The real format is `slot_YYYYMMDD_`. The example reference is `DM2609160042`. The real format is `DM-######`. +- `expectedDelivery` is shown as an epoch number. It is a display string (`"Mon, 2 Jan"`, or `""`). +- History entries are shown with an `actor` field. Only `{stage, at}` is sent. +- It says the stage walks back to `booked` when a rider "cancels or skips". Only the rider **cancel** does that. +- It says logout revokes "this session". Without `refreshToken`, logout revokes **all** sessions. +- The verify flow leaves out `name`, `400 invalid_name` for a new phone, and `404 not_found` for an email with no account. +- It says staging is "not yet provisioned", but the OpenAPI spec and the testing doc list a staging host. The docs contradict each other. +- Some file:line citations are out of date (for example `sms.go:105` should be `:110`, and `routes.go:105-171` should be `105-178`). + +**`docs/openapi-customer.yaml`** + +- The PIN operations are missing. The `Error.code` enum is missing the 3 PIN codes. +- `Unauthorized`, `Forbidden` and `RateLimited` are modelled with `error.code`. The middleware and throttle responses do not have it. +- `remarks` is missing from the booking create body. The example `maxDestinations: 5` should be 1 (the default when no configuration row exists). +- It says an "explicit `null` clears" a destination detail field. **It does not.** `null` is treated as "not sent". Send `""` to clear a text field, or `pin {0,0}` to clear the pin. +- `Destination.district` points to the full `District` schema. The booking's district card has only `code, name, available, hub?, promise?`. +- `details` is described as including `codAmount`. `codAmount` is never returned. +- `PushPayload.trackingId` is described as "null before order_created". The key is **left out** instead. The payload also has an undocumented `booking_id` key. +- `DELETE /devices` returns `data: {registered:false}`, not a bare envelope. +- Missing responses: `otp/verify` → 400 `invalid_name`, 404, 429. `refresh` → 403. Booking create → the past-slot 400, the >25 guard, `CITY_NOT_SUPPORTED`, `IDEMPOTENCY_IN_PROGRESS`. PATCH → 409 for a cancelled booking, 404 for a bad index. Ops → 400 "Unknown stage". +- The pickup `title` has `maxLength: 32`, but the server does not enforce it. + +**`docs/customer-app-api.md`** and **`docs/customer-api-testing.md`** + +- They say PIN auth was deleted. It was re-added as an interim flow. +- They count 28 routes / 24 paths and "9 error codes". The real numbers are 31 routes and 13 codes. +- They say `null` clears a PATCH field. It does not. +- WebSockets are not covered. +- Some line citations are out of date (for example the AutoMigrate call is at `main.go:105`, not `:97`). + +**`docs/CHANGELOG.md`** (the customer entries name many endpoints that do not exist) + +| CHANGELOG says | Code has | +|---|---| +| `/customer/auth/send-otp`, `/verify-otp` | `/auth/otp/request`, `/auth/otp/verify` (and `/auth/signup`) | +| `/customer/auth/logout-all` | No such route. Logout without `refreshToken` signs out everywhere. | +| `/customer/catalogue`, `/customer/serviceability/limits`, `/serviceability/slots`, a parcel category catalogue | `/config/booking-limits`, `/pickup-slots`. There is no catalogue or category route. | +| `/customer/places/autocomplete` | `/places/search` | +| `/customer/devices/register`, `/deregister` | `POST /devices`, `DELETE /devices/:token` | +| `/ops/bookings/:ref/stage` | `/customer/ops/bookings/:reference/stage` | +| Stages `created`, `at_hub`; `cancelled` as a stage | The 9 stages in §6.1. `cancelled` is a status. | +| "Strict 5-minute cancellation window" | Stage-based: allowed through `arrived` | +| "Weight-tiered, peak-hour pricing" | No peak-hour logic (§5.7.1) | +| "Sqids/hash" identifiers | A keyed Feistel permutation over Postgres sequences (§1.4) | + +**Sensitive content found in the older docs** (the values are not repeated here; the owners should remove them): + +- The fixed staging OTP value is written out in `customer-app-api.md`, `customer-app-api-crisp.md`, `customer-app-integration-handbook.md` and `customer-api-testing.md`. +- Seeded test-account phone numbers, and realistic-looking recipient phone numbers, appear in `customer-api-testing.md` and `customer-app-api-crisp.md`. +- A test customer's phone number and PIN also appear in the git history (the commit message of `29e189b`, and `scratch/seed_pin_customer.go`). diff --git a/internal/ai/playground/openai_compat.go b/internal/ai/playground/openai_compat.go new file mode 100644 index 0000000..cdd13b0 --- /dev/null +++ b/internal/ai/playground/openai_compat.go @@ -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) +} diff --git a/internal/ai/playground/openai_compat_test.go b/internal/ai/playground/openai_compat_test.go new file mode 100644 index 0000000..f24df98 --- /dev/null +++ b/internal/ai/playground/openai_compat_test.go @@ -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) + } +} diff --git a/internal/ai/playground/playground.go b/internal/ai/playground/playground.go new file mode 100644 index 0000000..59f0f83 --- /dev/null +++ b/internal/ai/playground/playground.go @@ -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 +} diff --git a/internal/ai/playground/playground_test.go b/internal/ai/playground/playground_test.go new file mode 100644 index 0000000..f94c5fb --- /dev/null +++ b/internal/ai/playground/playground_test.go @@ -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") + } +} diff --git a/internal/ai/playground/redact.go b/internal/ai/playground/redact.go new file mode 100644 index 0000000..481a9d0 --- /dev/null +++ b/internal/ai/playground/redact.go @@ -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 +} diff --git a/internal/ai/playground/tools.go b/internal/ai/playground/tools.go new file mode 100644 index 0000000..fcfd86c --- /dev/null +++ b/internal/ai/playground/tools.go @@ -0,0 +1,178 @@ +package playground + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "time" + + "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 := rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{ + GeoSearchQuery: redis.GeoSearchQuery{ + Longitude: *in.Lon, Latitude: *in.Lat, + Radius: in.RadiusKm, RadiusUnit: "km", Sort: "ASC", Count: nearbyMaxCount, + }, + WithDist: true, + }).Result() + 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 + } +} diff --git a/internal/ai/playground/tools_integration_test.go b/internal/ai/playground/tools_integration_test.go new file mode 100644 index 0000000..c872897 --- /dev/null +++ b/internal/ai/playground/tools_integration_test.go @@ -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) + } +} diff --git a/internal/ai/registry/registry_test.go b/internal/ai/registry/registry_test.go new file mode 100644 index 0000000..d480324 --- /dev/null +++ b/internal/ai/registry/registry_test.go @@ -0,0 +1,480 @@ +package registry + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "doormile/models" +) + +// No database here: these pin the seed's integrity and every rule a write must +// pass. Seed/Load/Update against Postgres are not exercised by this file. + +func boolp(b bool) *bool { return &b } +func strp(s string) *string { return &s } +func isValidation(err error) bool { var v *ValidationError; return errors.As(err, &v) } + +// ── Seed integrity ────────────────────────────────────────────────────────── + +func TestSeedIDsAreUnique(t *testing.T) { + seen := map[string]bool{} + for _, a := range SeedAgents { + if seen["agent:"+a.Agentid] { + t.Errorf("agent %s seeded twice", a.Agentid) + } + seen["agent:"+a.Agentid] = true + } + for _, tl := range SeedTools { + if seen["tool:"+tl.Toolname] { + t.Errorf("tool %s seeded twice", tl.Toolname) + } + seen["tool:"+tl.Toolname] = true + } + for _, s := range SeedSkills { + if seen["skill:"+s.Skill.Skillid] { + t.Errorf("skill %s seeded twice", s.Skill.Skillid) + } + seen["skill:"+s.Skill.Skillid] = true + } +} + +func TestSeedAgentsUseKnownValues(t *testing.T) { + for _, a := range SeedAgents { + if !validStatuses[a.Status] { + t.Errorf("%s: unknown status %q", a.Agentid, a.Status) + } + if !validRuntimes[a.Runtime] { + t.Errorf("%s: unknown runtime %q", a.Agentid, a.Runtime) + } + if a.Autonomous { + t.Errorf("%s is seeded autonomous; every agent must start with autonomy off", a.Agentid) + } + if a.Name == "" || a.Purpose == "" || a.Classref == "" { + t.Errorf("%s: name, purpose and classref are all required", a.Agentid) + } + } +} + +// Autonomy can only be switched on the three agents whose writes AI_engine +// actually gates. A gate on any other agent would be a switch wired to nothing. +func TestOnlyTheThreeGatedAgentsHaveAutonomyGates(t *testing.T) { + want := map[string]bool{"DISPATCH_AGENT": true, "EXCEPTION_AGENT": true, "EXPRESS_DISPATCH_AGENT": true} + for _, a := range SeedAgents { + if a.Hasautonomygate != want[a.Agentid] { + t.Errorf("%s: hasautonomygate = %v, want %v", a.Agentid, a.Hasautonomygate, want[a.Agentid]) + } + } +} + +// The simulated agents must say so — the console renders this badge, and an +// agent that looks live and is not is the failure this registry exists to end. +func TestSimulatedAgentsAreSeededAsSimulation(t *testing.T) { + for _, id := range []string{"HUB_AGENT", "FLEET_AGENT", "ROUTE_OPTIMIZER"} { + for _, a := range SeedAgents { + if a.Agentid == id && a.Status != StatusSimulation { + t.Errorf("%s status = %q, want simulation", id, a.Status) + } + } + } +} + +func TestSeedToolsAreWellFormed(t *testing.T) { + for _, tl := range SeedTools { + if !validKinds[tl.Kind] { + t.Errorf("%s: unknown kind %q", tl.Toolname, tl.Kind) + } + if (tl.Kind == KindWrite || tl.Kind == KindNotify) && !tl.Requiresconfirmation { + t.Errorf("%s is a %s tool but does not require confirmation", tl.Toolname, tl.Kind) + } + if tl.Kind == KindRead && tl.Requiresconfirmation { + t.Errorf("%s is read-only but requires confirmation", tl.Toolname) + } + var schema map[string]any + if err := json.Unmarshal([]byte(tl.Inputschema), &schema); err != nil || schema["type"] != "object" { + t.Errorf("%s: inputschema is not a JSON-schema object: %s", tl.Toolname, tl.Inputschema) + } + if tl.Description == "" || tl.Target == "" || tl.Implementedat == "" { + t.Errorf("%s: description, target and implementedat are all required", tl.Toolname) + } + } +} + +func TestEverySkillPointsAtRealAgentsAndTools(t *testing.T) { + agents := map[string]bool{} + for _, a := range SeedAgents { + agents[a.Agentid] = true + } + tools := map[string]bool{} + for _, tl := range SeedTools { + tools[tl.Toolname] = true + } + for _, s := range SeedSkills { + if !agents[s.Skill.Agentid] { + t.Errorf("skill %s belongs to unknown agent %s", s.Skill.Skillid, s.Skill.Agentid) + } + if len(s.Tools) == 0 { + t.Errorf("skill %s has no tools", s.Skill.Skillid) + } + for _, tl := range s.Tools { + if !tools[tl] { + t.Errorf("skill %s uses unknown tool %s", s.Skill.Skillid, tl) + } + } + if s.Skill.Source != SourceEngine && s.Skill.Source != SourceConsole { + t.Errorf("seeded skill %s has source %q; custom is for operator-made skills only", s.Skill.Skillid, s.Skill.Source) + } + } +} + +// Every seeded tool is used by some skill — the registry lists capabilities in +// use, not a catalogue of ideas. +func TestEveryToolIsUsedBySomeSkill(t *testing.T) { + used := map[string]bool{} + for _, s := range SeedSkills { + for _, tl := range s.Tools { + used[tl] = true + } + } + for _, tl := range SeedTools { + if !used[tl.Toolname] { + t.Errorf("tool %s is seeded but no skill uses it", tl.Toolname) + } + } +} + +func TestSeedThresholdDefaultsAreValid(t *testing.T) { + for _, s := range SeedSkills { + keys := map[string]bool{} + for _, spec := range s.Schema { + if keys[spec.Key] { + t.Errorf("%s: threshold %s declared twice", s.Skill.Skillid, spec.Key) + } + keys[spec.Key] = true + if spec.Min >= spec.Max { + t.Errorf("%s.%s: min %v is not below max %v", s.Skill.Skillid, spec.Key, spec.Min, spec.Max) + } + if err := spec.check(spec.Default); err != nil { + t.Errorf("%s.%s: default is itself invalid: %v", s.Skill.Skillid, spec.Key, err) + } + } + } +} + +// Rebalancing has no endpoint behind it. It must never ship switched on. +func TestRebalanceShipsDisabled(t *testing.T) { + for _, s := range SeedSkills { + if s.Skill.Skillid == "dispatch_rebalance" && s.Skill.Enabled { + t.Fatal("dispatch_rebalance is seeded enabled; nothing implements it") + } + } +} + +// The ops-layer skills keep the branch's ids and threshold keys so the Phase 3 +// port maps one to one. Pin them. +func TestConsoleOpsSkillsKeepBranchIDsAndKeys(t *testing.T) { + want := map[string][]string{ + "skill_sla_guardian": {"slaRiskWindowMin", "unassignedAgingMin"}, + "skill_doorstep_stall": {"arrivedStalledMin"}, + "skill_fleet_balancer": {"riderActiveCap"}, + "skill_high_value_cod": {"codRiskThresholdAmount"}, + "skill_rider_battery_safety": {"criticalBatteryPercent"}, + "skill_hub_congestion": {"hubDwellMinutes"}, + "skill_late_dispatch": {"lateDispatchMinutes", "criticalDispatchMinutes"}, + "skill_cash_exposure": {"maxCashPerRider", "warningCashPercent"}, + } + for _, s := range SeedSkills { + keys, ok := want[s.Skill.Skillid] + if !ok { + continue + } + delete(want, s.Skill.Skillid) + var got []string + for _, spec := range s.Schema { + got = append(got, spec.Key) + } + if strings.Join(got, ",") != strings.Join(keys, ",") { + t.Errorf("%s threshold keys = %v, want %v", s.Skill.Skillid, got, keys) + } + } + for id := range want { + t.Errorf("branch skill %s is missing from the seed", id) + } +} + +// ── Thresholds ────────────────────────────────────────────────────────────── + +var testSchema = []ThresholdSpec{ + {Key: "minutes", Default: 20, Min: 10, Max: 60, Step: 5}, + {Key: "confidence", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05}, +} + +func TestEffectiveThresholdsFillsDefaultsAndDropsStrays(t *testing.T) { + got := EffectiveThresholds(testSchema, map[string]float64{"minutes": 30, "removed": 9, "confidence": 5}) + if got["minutes"] != 30 { + t.Errorf("a valid stored value was not kept: %v", got["minutes"]) + } + if got["confidence"] != 0.7 { + t.Errorf("an out-of-range stored value was not replaced by the default: %v", got["confidence"]) + } + if _, stray := got["removed"]; stray { + t.Error("a key the schema no longer has was kept") + } +} + +func TestApplyThresholdPatchAcceptsValidValues(t *testing.T) { + got, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 45.0, "confidence": 0.85}) + if err != nil { + t.Fatalf("valid patch refused: %v", err) + } + if got["minutes"] != 45 || got["confidence"] != 0.85 { + t.Errorf("patch not applied: %v", got) + } +} + +func TestApplyThresholdPatchRefusesBadInput(t *testing.T) { + cases := map[string]map[string]any{ + "unknown key": {"minuts": 30.0}, + "not a number": {"minutes": "30"}, + "a boolean": {"minutes": true}, + "below min": {"minutes": 5.0}, + "above max": {"minutes": 65.0}, + "off the step": {"minutes": 33.0}, + "off the fstep": {"confidence": 0.72}, + } + for name, patch := range cases { + if _, err := ApplyThresholdPatch(testSchema, nil, patch); !isValidation(err) { + t.Errorf("%s: want a ValidationError, got %v", name, err) + } + } +} + +// Every problem is reported at once, so an operator fixes the form in one pass. +func TestApplyThresholdPatchReportsEveryProblem(t *testing.T) { + _, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 5.0, "nope": 1.0}) + if err == nil || !strings.Contains(err.Error(), "minutes") || !strings.Contains(err.Error(), "nope") { + t.Fatalf("want both problems named, got %v", err) + } +} + +// A refused patch changes nothing — the whole set, not the valid half, is rejected. +func TestApplyThresholdPatchIsAllOrNothing(t *testing.T) { + got, err := ApplyThresholdPatch(testSchema, map[string]float64{"minutes": 20}, map[string]any{"minutes": 40.0, "confidence": 9.0}) + if err == nil || got != nil { + t.Fatalf("partial patch was applied: %v, %v", got, err) + } +} + +func TestParseThresholdsToleratesJunk(t *testing.T) { + for _, raw := range []string{"", "null", "not json", "[]"} { + if got := ParseThresholds(raw); got == nil || len(got) != 0 { + t.Errorf("ParseThresholds(%q) = %v, want an empty map", raw, got) + } + } + if got := ParseSchema("garbage"); got == nil || len(got) != 0 { + t.Errorf("ParseSchema(garbage) = %v, want empty", got) + } +} + +// ── Agent patches ─────────────────────────────────────────────────────────── + +var gated = models.AIAgent{Agentid: "EXCEPTION_AGENT", Runtime: RuntimeEngine, Hasautonomygate: true} + +func TestAutonomyOnNeedsTypedConfirmation(t *testing.T) { + if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true)}); !isValidation(err) { + t.Errorf("autonomy switched on with no confirmation: %v", err) + } + if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "exception_agent"}); !isValidation(err) { + t.Errorf("a near-miss confirmation was accepted: %v", err) + } + if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}); err != nil { + t.Errorf("correct confirmation refused: %v", err) + } +} + +// Switching autonomy OFF is always allowed without ceremony — the safe direction. +func TestAutonomyOffNeedsNoConfirmation(t *testing.T) { + on := gated + on.Autonomous = true + if err := CheckAgentPatch(on, AgentPatch{Autonomous: boolp(false)}); err != nil { + t.Errorf("switching autonomy off was refused: %v", err) + } +} + +func TestAutonomyRefusedOnUngatedAgent(t *testing.T) { + hub := models.AIAgent{Agentid: "HUB_AGENT", Runtime: RuntimeEngine} + if err := CheckAgentPatch(hub, AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}); !isValidation(err) { + t.Errorf("autonomy set on an agent with no gate: %v", err) + } +} + +func TestModelIDRules(t *testing.T) { + for _, ok := range []string{"", "claude-sonnet-5-5", "claude-haiku-4-5-20251001", "claude-opus-5-5"} { + if err := CheckAgentPatch(gated, AgentPatch{Model: strp(ok)}); err != nil { + t.Errorf("model %q refused: %v", ok, err) + } + } + for _, bad := range []string{"gpt-4o", "claude-", "Claude-Sonnet", "claude-x; drop table", strings.Repeat("claude-a", 20)} { + if err := CheckAgentPatch(gated, AgentPatch{Model: strp(bad)}); !isValidation(err) { + t.Errorf("model %q accepted", bad) + } + } + console := models.AIAgent{Agentid: "CONSOLE_ASSISTANT", Runtime: RuntimeConsole} + if err := CheckAgentPatch(console, AgentPatch{Model: strp("claude-sonnet-5-5")}); !isValidation(err) { + t.Error("a model was set on a console agent, which has no model setting") + } +} + +func TestEmptyAgentPatchRefused(t *testing.T) { + if err := CheckAgentPatch(gated, AgentPatch{}); !isValidation(err) { + t.Error("an empty patch was accepted") + } +} + +// ── New skills ────────────────────────────────────────────────────────────── + +func TestCheckNewSkill(t *testing.T) { + good := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"lookup_order"}} + if err := CheckNewSkill(good); err != nil { + t.Fatalf("valid skill refused: %v", err) + } + bad := map[string]NewSkill{ + "no agent": {Title: "x", Tools: []string{"a"}}, + "no title": {Agentid: "A", Title: " ", Tools: []string{"a"}}, + "no tools": {Agentid: "A", Title: "x"}, + "duplicate tool": {Agentid: "A", Title: "x", Tools: []string{"a", "a"}}, + "title too long": {Agentid: "A", Title: strings.Repeat("x", 121), Tools: []string{"a"}}, + } + for name, n := range bad { + if err := CheckNewSkill(n); !isValidation(err) { + t.Errorf("%s: accepted", name) + } + } +} + +func TestCustomSkillID(t *testing.T) { + cases := map[string]string{ + "Night Shift Watch": "custom_night_shift_watch", + " COD > ₹5,000 alerts!! ": "custom_cod_5_000_alerts", + "!!!": "custom_skill", + } + for in, want := range cases { + if got := customSkillID(in); got != want { + t.Errorf("customSkillID(%q) = %q, want %q", in, got, want) + } + } + if got := customSkillID(strings.Repeat("abc ", 40)); len(got) > 64 { + t.Errorf("id %q exceeds the 64-char column", got) + } +} + +// ── Build & ETag ──────────────────────────────────────────────────────────── + +func seededRows() ([]models.AIAgent, []models.AITool, []models.AISkill, []models.AISkillTool) { + var skills []models.AISkill + var links []models.AISkillTool + for _, s := range SeedSkills { + row := s.Skill + row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema)) + row.Thresholds = mustJSON(DefaultThresholds(s.Schema)) + skills = append(skills, row) + for _, tl := range s.Tools { + links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: tl}) + } + } + return SeedAgents, SeedTools, skills, links +} + +func TestBuildCountsSkillsAndToolsPerAgent(t *testing.T) { + snap := Build(seededRows()) + for _, a := range snap.Agents { + if a.Agentid == "EXPRESS_DISPATCH_AGENT" && (a.Skillcount != 1 || a.Toolcount != 4) { + t.Errorf("EXPRESS_DISPATCH_AGENT: %d skills, %d tools; want 1 and 4", a.Skillcount, a.Toolcount) + } + if a.Agentid == "HUB_AGENT" && (a.Skillcount != 0 || a.Toolcount != 0) { + t.Errorf("HUB_AGENT has no skills, got %d/%d", a.Skillcount, a.Toolcount) + } + } +} + +// The API must emit thresholds and schemas as JSON values, not as strings of JSON. +func TestSnapshotSerialisesJSONColumnsAsJSON(t *testing.T) { + b, err := json.Marshal(Build(seededRows())) + if err != nil { + t.Fatal(err) + } + var out struct { + Skills []struct { + Skillid string `json:"skillid"` + Thresholds map[string]float64 `json:"thresholds"` + Thresholdsschema []ThresholdSpec `json:"thresholdsschema"` + Tools []string `json:"tools"` + } `json:"skills"` + Tools []struct { + Inputschema map[string]any `json:"inputschema"` + } `json:"tools"` + } + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("snapshot JSON does not decode as structured values: %v", err) + } + for _, s := range out.Skills { + if s.Skillid == "skill_cash_exposure" { + if s.Thresholds["maxCashPerRider"] != 10000 || len(s.Thresholdsschema) != 2 || len(s.Tools) != 2 { + t.Errorf("skill_cash_exposure serialised wrongly: %+v", s) + } + } + } + if len(out.Tools) == 0 || out.Tools[0].Inputschema["type"] != "object" { + t.Error("tool inputschema is not emitted as a JSON object") + } +} + +func TestETagIsStableAndChangesWithContent(t *testing.T) { + a := ETag(Build(seededRows())) + if a != ETag(Build(seededRows())) { + t.Fatal("ETag differs for identical content") + } + agents, tools, skills, links := seededRows() + skills[0].Enabled = !skills[0].Enabled + if a == ETag(Build(agents, tools, skills, links)) { + t.Fatal("ETag did not change when a skill was toggled") + } + if !strings.HasPrefix(a, `W/"`) { + t.Errorf("ETag %s is not a weak validator", a) + } +} + +// These three read fields /admin/bookings rows do not carry (payment amounts, +// battery). Enabled, they would read undefined on every row and report an +// all-clear board. They must ship off, in step with the console's defaults. +func TestSkillsWithNoDataSourceShipDisabled(t *testing.T) { + off := map[string]bool{"skill_high_value_cod": true, "skill_cash_exposure": true, "skill_rider_battery_safety": true} + for _, s := range SeedSkills { + if off[s.Skill.Skillid] { + delete(off, s.Skill.Skillid) + if s.Skill.Enabled { + t.Errorf("%s is seeded enabled but its rows carry no data for its rule", s.Skill.Skillid) + } + if !strings.Contains(s.Skill.Description, "OFF:") { + t.Errorf("%s does not say why it is off", s.Skill.Skillid) + } + } + } + for id := range off { + t.Errorf("%s missing from the seed", id) + } +} + +// Only notify_riders has an executor in the console. Every other console write +// must say REVIEW ONLY, or Agent Studio would advertise an action that cannot run. +func TestConsoleWritesWithoutExecutorSayReviewOnly(t *testing.T) { + for _, tl := range SeedTools { + if !strings.HasPrefix(tl.Implementedat, consoleSrc+"lib/assistant/skills/") && !strings.Contains(tl.Implementedat, "(no executor)") { + continue + } + if tl.Kind != KindRead && !strings.HasPrefix(tl.Description, "REVIEW ONLY") { + t.Errorf("%s has no executor but its description does not start with REVIEW ONLY", tl.Toolname) + } + } +} diff --git a/internal/ai/registry/rules.go b/internal/ai/registry/rules.go new file mode 100644 index 0000000..ab635a4 --- /dev/null +++ b/internal/ai/registry/rules.go @@ -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 +} diff --git a/internal/ai/registry/seed.go b/internal/ai/registry/seed.go new file mode 100644 index 0000000..45231ef --- /dev/null +++ b/internal/ai/registry/seed.go @@ -0,0 +1,304 @@ +package registry + +import "doormile/models" + +// The registry as the code actually stands (verified 2026-09-29). Every entry +// says what the code DOES, not what a doc claims: an agent that only simulates +// is seeded as "simulation", and the console must show that badge. Source of +// this inventory: krow_talent_app/docs/agent-platform-plan.md §2. +// +// Upserted on every boot by Seed. Changing a code-defined field here changes +// it in the database on the next start; operator-owned fields (skill enabled +// and thresholds, agent autonomous and model) are only written on first insert. +// +// Deliberately NOT seeded: +// - AI_engine's ask_question, order_intake_skill and repeat_run_skill — +// registered in core/tool_registry.py but never loaded in production. +// - The four Krow workforce-training tools the console mock carried. +// - ORDER_AGENT's crmbooking calls — that route was renamed to +// expressbooking, so they reach nothing. +// - /internal/agent-decisions — an append-only log the Go side writes, not a +// capability any skill chooses to use. + +// Agent statuses and runtimes. The console renders these verbatim. +const ( + StatusLive = "live" + StatusPartial = "partial" + StatusSimulation = "simulation" + StatusBroken = "broken" + StatusUnmerged = "unmerged" + StatusRetired = "retired" + + RuntimeEngine = "engine" + RuntimeConsole = "console" + + KindRead = "read" + KindWrite = "write" + KindNotify = "notify" + // KindEvent is an internal bus event or record: no effect on an order, a + // rider or a customer by itself, so it is never behind confirmation. + KindEvent = "event" + + SourceEngine = "engine" + SourceConsole = "console" + SourceCustom = "custom" +) + +var ( + validStatuses = map[string]bool{StatusLive: true, StatusPartial: true, StatusSimulation: true, StatusBroken: true, StatusUnmerged: true, StatusRetired: true} + validRuntimes = map[string]bool{RuntimeEngine: true, RuntimeConsole: true} + validKinds = map[string]bool{KindRead: true, KindWrite: true, KindNotify: true, KindEvent: true} +) + +const consoleSrc = "krow_talent_app/src/" + +// SeedAgents — AI_engine's nine, then the console's two. +var SeedAgents = []models.AIAgent{ + {Agentid: "JARVIS", Name: "JARVIS", Runtime: RuntimeEngine, Classref: "AI_engine/core/agent.py:196 MasterAgent", + Purpose: "Orchestrator and escalation inbox. Receives EXCEPTION_DETECTED from other agents.", + Wakeon: "NATS logistics.direct.JARVIS", Status: StatusPartial, Sortorder: 10}, + {Agentid: "DISPATCH_AGENT", Name: "Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/dispatch_agent.py:72", + Purpose: "Watches assignment outcomes and flags coverage gaps. Alerts; notifies customers only when autonomous.", + Wakeon: "JetStream booking.assigned, booking.assignment_failed", + Status: StatusLive, Llmdecision: "decide_assignment_failure", Hasautonomygate: true, Sortorder: 20}, + {Agentid: "EXCEPTION_AGENT", Name: "Exception", Runtime: RuntimeEngine, Classref: "AI_engine/agents/exception_agent.py:106", + Purpose: "Detects stalled riders and decides the response. Reassigns only when autonomous and confident.", + Wakeon: "TRACKING miler.location.updated, miler.stalled; 60 s database sweep", + Status: StatusLive, Llmdecision: "decide_stall_response", Hasautonomygate: true, Sortorder: 30}, + {Agentid: "EXPRESS_DISPATCH_AGENT", Name: "Express Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/express_dispatch_agent.py:80", + Purpose: "Tenant-scoped batch assignment for DoormileExpress, then road sequencing of each rider's stops.", + Wakeon: "JetStream express.dispatch_requested", Status: StatusLive, Hasautonomygate: true, Sortorder: 40}, + {Agentid: "CUSTOMER_AGENT", Name: "Customer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/customer_agent.py:50", + Purpose: "Customer notifications and tracking. Reached only from an autonomous Dispatch agent.", + Wakeon: "Direct task", Status: StatusPartial, Sortorder: 50}, + {Agentid: "ORDER_AGENT", Name: "Order", Runtime: RuntimeEngine, Classref: "AI_engine/agents/order_agent.py:15", + Purpose: "Order intake and validation. Not connected: it has no working backend route (/admin/* needs a console JWT, crmbooking is gone), so its backend calls are refused and logged.", + Wakeon: "Direct task (no sender in production)", Status: StatusBroken, Sortorder: 60}, + {Agentid: "HUB_AGENT", Name: "Hub", Runtime: RuntimeEngine, Classref: "AI_engine/agents/hub_agent.py:35", + Purpose: "Hub capacity over 8 hard-coded fictional hubs.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 70}, + {Agentid: "FLEET_AGENT", Name: "Fleet", Runtime: RuntimeEngine, Classref: "AI_engine/agents/fleet_agent.py:34", + Purpose: "An in-memory fleet of 19 fake vehicles.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 80}, + {Agentid: "ROUTE_OPTIMIZER", Name: "Route Optimizer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/route_optimizer_agent.py:41", + Purpose: "Haversine routing with traffic multipliers. Real sequencing is done by Express Dispatch via routes.workolik.com.", + Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 90}, + {Agentid: "CONSOLE_ASSISTANT", Name: "Console Assistant", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant", + Purpose: "The Home chat: orders, bulk upload, assignment, repeat runs. Regex intent catalogue; every write is a proposal the operator confirms.", + Wakeon: "Operator prompt", Status: StatusLive, Sortorder: 100}, + {Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant/agent", + Purpose: "The Exceptions early-warnings banner and the chat's 'what needs attention' briefing: eight rule-based monitoring skills over the booking scan. Nothing runs on its own; an action runs only when an operator clicks it.", + Wakeon: "Exceptions page load, 60 s poll, and the chat briefing", Status: StatusLive, Sortorder: 110}, +} + +// obj builds a JSON-schema object for a tool's input. Only parameters the code +// provably takes are listed; where the body is not pinned down, the schema is +// left open rather than invented. +func obj(props map[string]map[string]string, required ...string) string { + properties := map[string]any{} + for name, p := range props { + properties[name] = p + } + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return mustJSON(s) +} + +var ( + integer = func(desc string) map[string]string { return map[string]string{"type": "integer", "description": desc} } + number = func(desc string) map[string]string { return map[string]string{"type": "number", "description": desc} } + str = func(desc string) map[string]string { return map[string]string{"type": "string", "description": desc} } + open = obj(map[string]map[string]string{}) +) + +// SeedTools — every capability a seeded skill uses, and nothing else. +var SeedTools = []models.AITool{ + // AI_engine + {Toolname: "reassign_booking", Kind: KindWrite, Requiresconfirmation: true, + Description: "Reassign a booking whose rider has stalled.", Target: "doormile_backend POST /internal/bookings/:id/reassign", + Implementedat: "AI_engine/agents/exception_agent.py:466", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to reassign")}, "booking_id")}, + {Toolname: "notify_customer", Kind: KindNotify, Requiresconfirmation: true, + Description: "Send a customer a delivery update.", Target: "doormile_backend POST /internal/notify", + Implementedat: "AI_engine/agents/exception_agent.py:476, customer_agent.py:349", Inputschema: open}, + {Toolname: "list_express_bookings", Kind: KindRead, + Description: "Read the bookings in an express dispatch batch.", Target: "doormile_backend GET /internal/express/bookings", + Implementedat: "AI_engine/agents/express_dispatch_agent.py:351", Inputschema: open}, + {Toolname: "list_express_riders", Kind: KindRead, + Description: "Read a tenant's riders available for an express batch.", Target: "doormile_backend GET /internal/express/riders", + Implementedat: "AI_engine/agents/express_dispatch_agent.py:342", Inputschema: open}, + {Toolname: "assign_express_batch", Kind: KindWrite, Requiresconfirmation: true, + Description: "Write back the rider assignments decided for an express batch.", Target: "doormile_backend POST /internal/express/assign", + Implementedat: "AI_engine/agents/express_dispatch_agent.py:222", Inputschema: open}, + {Toolname: "sequence_stops", Kind: KindRead, + Description: "Order a rider's stops by road (computes, writes nothing).", Target: "routes.workolik.com POST /api/v1/optimization/doormile/sequence", + Implementedat: "AI_engine/agents/express_dispatch_agent.py:308", Inputschema: open}, + {Toolname: "get_booking_cache", Kind: KindRead, + Description: "Read a booking from the backend's booking cache.", Target: "doormile_backend GET /bookings/cache/:id", + Implementedat: "AI_engine/agents/customer_agent.py:243", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to read")}, "booking_id")}, + {Toolname: "nearby_milers", Kind: KindRead, + Description: "Find riders near a point from live positions.", Target: "Redis GEO milers:locations", + Implementedat: "AI_engine/agents/dispatch_agent.py:170-240", + Inputschema: obj(map[string]map[string]string{ + "lat": number("Latitude of the point, decimal degrees"), "lon": number("Longitude of the point, decimal degrees"), + "radius_km": number("Search radius in km (default 5, at most 10)"), + }, "lat", "lon")}, + {Toolname: "publish_miler_stalled", Kind: KindEvent, + Description: "Publish a stalled-rider event for other agents.", Target: "NATS miler.stalled", + Implementedat: "AI_engine/agents/exception_agent.py:377", Inputschema: open}, + {Toolname: "decide_stall_response", Kind: KindRead, + Description: "Ask the model how to respond to a stalled rider (structured output).", Target: "Claude via AI_engine/core/llm.py", + Implementedat: "AI_engine/core/llm.py:157", Inputschema: open}, + {Toolname: "decide_assignment_failure", Kind: KindRead, + Description: "Ask the model why an assignment failed and what to do (structured output).", Target: "Claude via AI_engine/core/llm.py", + Implementedat: "AI_engine/core/llm.py:227", Inputschema: open}, + + // Console ops layer (krow_talent_app, ported onto main in Phase 3). The + // tool names are the proposal VERBS the findings carry, because that is what + // an operator's click resolves (lib/assistant/agent/actions.js). Only + // notify_riders has an executor; every other write is review-only and the + // console renders it disabled — the descriptions say so. + {Toolname: "scan_bookings", Kind: KindRead, + Description: "Read open and recent bookings (drained page by page) for the rules to evaluate.", Target: "doormile_backend GET /admin/bookings", + Implementedat: consoleSrc + "lib/assistant/scan.js", + Inputschema: obj(map[string]map[string]string{ + "status": str("Only bookings in this status, e.g. Created, Miler_Assigned, Picked_Up, Cancelled"), + "limit": integer("How many of the newest bookings (default 20, at most 50)"), + })}, + {Toolname: "notify_riders", Kind: KindNotify, Requiresconfirmation: true, + Description: "Message the riders on a finding's orders. Runs only when an operator clicks it; partial success is reported as partial.", Target: "doormile_backend POST /admin/milers/:id/notify", + Implementedat: consoleSrc + "lib/assistant/agent/actions.js", Inputschema: open}, + {Toolname: "assign_riders", Kind: KindWrite, Requiresconfirmation: true, + Description: "REVIEW ONLY. Would assign a finding's orders to riders, but POST /hub/bookings/batch-assign accepts hub-staff logins only, so the console cannot run it.", Target: "doormile_backend POST /hub/bookings/batch-assign (hub staff only)", + Implementedat: consoleSrc + "lib/assistant/agent/actions.js (no executor)", Inputschema: open}, + {Toolname: "enforce_otp_verification", Kind: KindWrite, Requiresconfirmation: true, + Description: "REVIEW ONLY. Flag a high-value COD order as requiring the receiver's OTP at handover. No executor.", Target: "none yet", + Implementedat: consoleSrc + "lib/assistant/skills/definitions/HighValueCodSkill.js", Inputschema: obj(map[string]map[string]string{"bookingId": integer("Booking to flag")}, "bookingId")}, + {Toolname: "alert_low_battery_rider", Kind: KindNotify, Requiresconfirmation: true, + Description: "REVIEW ONLY. Tell a rider on a low battery to charge or report to the nearest hub. No executor.", Target: "doormile_backend POST /admin/milers/:id/notify", + Implementedat: consoleSrc + "lib/assistant/skills/definitions/RiderBatterySafetySkill.js", Inputschema: obj(map[string]map[string]string{"milerId": integer("Rider to alert")}, "milerId")}, + {Toolname: "dispatch_hub_idle_parcels", Kind: KindWrite, Requiresconfirmation: true, + Description: "REVIEW ONLY. Send an idle rider to collect parcels dwelling at a hub. No executor.", Target: "none yet", + Implementedat: consoleSrc + "lib/assistant/skills/definitions/HubCongestionSkill.js", + Inputschema: obj(map[string]map[string]string{"hubId": str("Hub where parcels are waiting"), "milerId": integer("Idle rider")}, "hubId", "milerId")}, + {Toolname: "trigger_auto_dispatch", Kind: KindWrite, Requiresconfirmation: true, + Description: "REVIEW ONLY. Auto-assign orders that have waited too long for dispatch. No executor.", Target: "none yet", + Implementedat: consoleSrc + "lib/assistant/skills/definitions/LateDispatchSkill.js", Inputschema: open}, + {Toolname: "enforce_cash_handoff", Kind: KindWrite, Requiresconfirmation: true, + Description: "REVIEW ONLY. Route a rider carrying too much COD via the nearest hub. No executor.", Target: "none yet", + Implementedat: consoleSrc + "lib/assistant/skills/definitions/CashExposureSkill.js", Inputschema: open}, + + // Console assistant + {Toolname: "simulate_pricing_quote", Kind: KindRead, + Description: "Quote a delivery from the tenant's pricing row and the routed distance, without booking it.", Target: "doormile_backend GET /admin/pricing + OSRM route", + Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open}, + {Toolname: "create_single_order", Kind: KindWrite, Requiresconfirmation: true, + Description: "Create one express booking. Returns a proposal; the operator confirms.", Target: "doormile_backend POST /admin/expressbooking", + Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open}, + {Toolname: "rebalance_riders", Kind: KindWrite, Requiresconfirmation: true, + Description: "Move idle riders between zones. NOT IMPLEMENTED: no endpoint does zone rebalancing yet.", Target: "none yet", + Implementedat: "none", Inputschema: open}} + +// SeedSkill pairs a skill row with its tool links and threshold schema. +type SeedSkill struct { + Skill models.AISkill + Tools []string + Schema []ThresholdSpec +} + +func minutes(key, label string, def, min, max, step float64) ThresholdSpec { + return ThresholdSpec{Key: key, Label: label, Unit: "min", Default: def, Min: min, Max: max, Step: step} +} + +// SeedSkills. The console ops skills keep the ids and threshold keys of the +// feat/agentic-ops-layer branch, which were ported onto main one to one (Phase 3). +var SeedSkills = []SeedSkill{ + // AI_engine. Thresholds mirror the env knobs the agents read today; the + // engine starts reading them from here in Phase 5. + {Skill: models.AISkill{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stalled-rider response", Category: "rider_operations", Source: SourceEngine, Enabled: true, + Description: "Detect a rider who has stopped moving, ask the model what to do, and alert or (when autonomous) reassign."}, + Tools: []string{"nearby_milers", "decide_stall_response", "reassign_booking", "notify_customer", "publish_miler_stalled"}, + Schema: []ThresholdSpec{ + minutes("stallMinutes", "Stall threshold", 10, 5, 60, 5), + {Key: "reassignConfidence", Label: "Auto-reassign confidence floor", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05}, + }}, + {Skill: models.AISkill{Skillid: "assignment_failure_triage", Agentid: "DISPATCH_AGENT", Title: "Assignment-failure triage", Category: "dispatch", Source: SourceEngine, Enabled: true, + Description: "When no rider could be assigned, work out why from nearby supply and raise one alert per gap."}, + Tools: []string{"nearby_milers", "decide_assignment_failure", "notify_customer"}, + Schema: []ThresholdSpec{ + {Key: "realertEvery", Label: "Re-alert after N repeat failures", Unit: "failures", Default: 100, Min: 10, Max: 1000, Step: 10}, + }}, + {Skill: models.AISkill{Skillid: "express_batch_dispatch", Agentid: "EXPRESS_DISPATCH_AGENT", Title: "Express batch dispatch", Category: "dispatch", Source: SourceEngine, Enabled: true, + Description: "Assign a tenant's express batch to its riders greedily, then sequence each rider's stops by road."}, + Tools: []string{"list_express_bookings", "list_express_riders", "sequence_stops", "assign_express_batch"}, + Schema: []ThresholdSpec{ + {Key: "maxRadiusKm", Label: "Max rider radius", Unit: "km", Default: 30, Min: 5, Max: 60, Step: 1}, + {Key: "maxPerRider", Label: "Max stops per rider", Unit: "stops", Default: 5, Min: 1, Max: 10, Step: 1}, + {Key: "loadPenaltyKm", Label: "Load penalty per held stop", Unit: "km", Default: 3, Min: 0, Max: 10, Step: 0.5}, + }}, + {Skill: models.AISkill{Skillid: "customer_notifications", Agentid: "CUSTOMER_AGENT", Title: "Customer notifications", Category: "customer", Source: SourceEngine, Enabled: true, + Description: "Tell a customer what is happening to their delivery."}, + Tools: []string{"get_booking_cache", "notify_customer"}}, + + // Console ops layer (on main since Phase 3). Ids and threshold keys match + // lib/assistant/skills/definitions; defaults are copied from them. Every skill + // reads the booking scan; its other tools are the actions its findings propose. + {Skill: models.AISkill{Skillid: "skill_sla_guardian", Agentid: "CONSOLE_OPS_AGENT", Title: "SLA Breach Guardian", Category: "sla_management", Source: SourceConsole, Enabled: true, + Description: "Flags breached and imminent SLA violations against promised delivery ETAs."}, + Tools: []string{"scan_bookings", "notify_riders", "assign_riders"}, + Schema: []ThresholdSpec{ + minutes("slaRiskWindowMin", "At-risk warning window", 45, 15, 90, 5), + minutes("unassignedAgingMin", "Unassigned aging threshold", 60, 15, 120, 5), + }}, + {Skill: models.AISkill{Skillid: "skill_doorstep_stall", Agentid: "CONSOLE_OPS_AGENT", Title: "Doorstep Stall Rescuer", Category: "rider_operations", Source: SourceConsole, Enabled: true, + Description: "Flags riders who marked arrival at the doorstep but have made no progress since."}, + Tools: []string{"scan_bookings", "notify_riders"}, + Schema: []ThresholdSpec{minutes("arrivedStalledMin", "Doorstep stall timeout", 20, 10, 60, 5)}}, + {Skill: models.AISkill{Skillid: "skill_fleet_balancer", Agentid: "CONSOLE_OPS_AGENT", Title: "Fleet Load Balancer", Category: "fleet_optimization", Source: SourceConsole, Enabled: true, + Description: "Flags riders at maximum active capacity while queued work waits."}, + Tools: []string{"scan_bookings"}, // flags only; its findings propose no action + Schema: []ThresholdSpec{{Key: "riderActiveCap", Label: "Rider active capacity cap", Unit: "orders", Default: 3, Min: 1, Max: 6, Step: 1}}}, + {Skill: models.AISkill{Skillid: "skill_high_value_cod", Agentid: "CONSOLE_OPS_AGENT", Title: "High-Value Cash Guardian", Category: "loss_prevention", Source: SourceConsole, + Enabled: false, // no data: /admin/bookings rows carry no payment amount or mode + Description: "Audits large cash-on-delivery consignments. OFF: the booking rows it reads carry no payment amount or mode (bookingpayments is not preloaded), so enabled it would always report all-clear."}, + Tools: []string{"scan_bookings", "enforce_otp_verification"}, + Schema: []ThresholdSpec{{Key: "codRiskThresholdAmount", Label: "High-value COD threshold", Unit: "₹", Default: 3000, Min: 1000, Max: 20000, Step: 500}}}, + {Skill: models.AISkill{Skillid: "skill_rider_battery_safety", Agentid: "CONSOLE_OPS_AGENT", Title: "Rider Device & SOS Safety", Category: "rider_safety", Source: SourceConsole, + Enabled: false, // no data: battery lives on milerprofiles, not booking rows + Description: "Warns before a rider becomes unreachable on a flat battery. OFF: battery level is on the rider profile, not on the booking rows it reads, so enabled it would always report all-clear."}, + Tools: []string{"scan_bookings", "alert_low_battery_rider"}, + Schema: []ThresholdSpec{{Key: "criticalBatteryPercent", Label: "Critical battery level", Unit: "%", Default: 15, Min: 5, Max: 30, Step: 5}}}, + {Skill: models.AISkill{Skillid: "skill_hub_congestion", Agentid: "CONSOLE_OPS_AGENT", Title: "Hub Congestion Agent", Category: "sla_management", Source: SourceConsole, Enabled: true, + Description: "Detects parcels dwelling at a hub without rider pickup and proposes the nearest idle rider."}, + Tools: []string{"scan_bookings", "dispatch_hub_idle_parcels"}, + Schema: []ThresholdSpec{minutes("hubDwellMinutes", "Hub dwell threshold", 45, 15, 120, 5)}}, + {Skill: models.AISkill{Skillid: "skill_late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late Dispatch Agent", Category: "sla_management", Source: SourceConsole, Enabled: true, + Description: "Flags accepted orders still waiting for dispatch and proposes auto-assignment."}, + Tools: []string{"scan_bookings", "trigger_auto_dispatch"}, + Schema: []ThresholdSpec{ + minutes("lateDispatchMinutes", "Dispatch deadline", 30, 10, 90, 5), + minutes("criticalDispatchMinutes", "Critical dispatch deadline", 60, 30, 180, 10), + }}, + {Skill: models.AISkill{Skillid: "skill_cash_exposure", Agentid: "CONSOLE_OPS_AGENT", Title: "Cash Exposure Agent", Category: "loss_prevention", Source: SourceConsole, + Enabled: false, // no data: no COD amount per order in /admin/bookings rows + Description: "Tracks COD cash per rider and proposes a hub handoff. OFF: the booking rows it reads carry no COD amount, so enabled it would always report all-clear."}, + Tools: []string{"scan_bookings", "enforce_cash_handoff"}, + Schema: []ThresholdSpec{ + {Key: "maxCashPerRider", Label: "Max safe cash per rider", Unit: "₹", Default: 10000, Min: 2000, Max: 50000, Step: 1000}, + {Key: "warningCashPercent", Label: "Warning threshold", Unit: "%", Default: 75, Min: 50, Max: 95, Step: 5}, + }}, + {Skill: models.AISkill{Skillid: "ops_briefing", Agentid: "CONSOLE_OPS_AGENT", Title: "Ops briefing", Category: "operations", Source: SourceConsole, Enabled: true, + Description: "Answer \"what needs attention right now\" in the chat by running every enabled monitoring skill over the booking scan — the same engine as the Exceptions banner.", Sampleprompt: "What needs attention right now?"}, + Tools: []string{"scan_bookings"}}, + {Skill: models.AISkill{Skillid: "dispatch_rebalance", Agentid: "CONSOLE_OPS_AGENT", Title: "Dispatch Rebalance & Allocation", Category: "dispatch", Source: SourceConsole, + // Off: rebalance_riders has no implementation behind it. + Enabled: false, + Description: "Move idle riders into zones with a demand spike. Disabled until an endpoint implements it.", + Sampleprompt: "Rebalance available riders into Zone 1 to prevent SLA delays"}, + Tools: []string{"rebalance_riders"}}, + + // Console assistant + {Skill: models.AISkill{Skillid: "order_intake_auto_schedule", Agentid: "CONSOLE_ASSISTANT", Title: "Order Intake & Auto-Schedule", Category: "logistics", Source: SourceConsole, Enabled: true, + Description: "Parse orders from text or a sheet, price them, and create them once the operator confirms.", + Sampleprompt: "Repeat yesterday's orders for Neptune"}, + Tools: []string{"create_single_order", "simulate_pricing_quote"}}, +} diff --git a/internal/ai/registry/store.go b/internal/ai/registry/store.go new file mode 100644 index 0000000..d1eac3f --- /dev/null +++ b/internal/ai/registry/store.go @@ -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) +} diff --git a/internal/ai/registry/store_integration_test.go b/internal/ai/registry/store_integration_test.go new file mode 100644 index 0000000..43f9bb0 --- /dev/null +++ b/internal/ai/registry/store_integration_test.go @@ -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") + } +} diff --git a/internal/ai/registry/thresholds.go b/internal/ai/registry/thresholds.go new file mode 100644 index 0000000..c641dce --- /dev/null +++ b/internal/ai/registry/thresholds.go @@ -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) +} diff --git a/internal/ai/telemetry/insights.go b/internal/ai/telemetry/insights.go new file mode 100644 index 0000000..006d457 --- /dev/null +++ b/internal/ai/telemetry/insights.go @@ -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 +} diff --git a/internal/ai/telemetry/parse.go b/internal/ai/telemetry/parse.go new file mode 100644 index 0000000..6e370f7 --- /dev/null +++ b/internal/ai/telemetry/parse.go @@ -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 } diff --git a/internal/ai/telemetry/recorder.go b/internal/ai/telemetry/recorder.go new file mode 100644 index 0000000..f5302f0 --- /dev/null +++ b/internal/ai/telemetry/recorder.go @@ -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 diff --git a/internal/ai/telemetry/store_integration_test.go b/internal/ai/telemetry/store_integration_test.go new file mode 100644 index 0000000..59fca61 --- /dev/null +++ b/internal/ai/telemetry/store_integration_test.go @@ -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") + } +} diff --git a/internal/ai/telemetry/telemetry_test.go b/internal/ai/telemetry/telemetry_test.go new file mode 100644 index 0000000..b9b3216 --- /dev/null +++ b/internal/ai/telemetry/telemetry_test.go @@ -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) + } +} diff --git a/internal/testpg/testpg.go b/internal/testpg/testpg.go new file mode 100644 index 0000000..4a856bc --- /dev/null +++ b/internal/testpg/testpg.go @@ -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 +} diff --git a/main.go b/main.go index 93a55cb..8f14a7c 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,8 @@ import ( "doormile/config" "doormile/controllers" "doormile/db" + "doormile/internal/ai/playground" + "doormile/internal/ai/telemetry" "doormile/internal/assignment" "doormile/internal/notify" "doormile/internal/routing" @@ -85,6 +87,14 @@ 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 @@ -220,6 +230,20 @@ func main() { // 9. Point the stop sequencer at the Route Optimization API. routing.BaseURL = cfg.RouteOptimizerURL + // 10. Record AI_engine agent runs (telemetry.task) and live state + // (telemetry.agent). Observability only: a nil NATS connection logs and + // skips, and nothing here can block a request. + if db.DB != nil { + telemetry.NewRecorder(db.DB, db.Rdb).Start(db.Nc) + } + + // Agent Studio Test playground: switched on only when a model API key is + // configured. Without one the endpoint answers 503 and the console says so. + if cfg.PlaygroundLLMAPIKey != "" { + controllers.PlaygroundModel = playground.NewOpenAICompat(cfg.PlaygroundLLMBaseURL, cfg.PlaygroundLLMAPIKey, cfg.PlaygroundLLMModel) + utils.Info("ai playground: enabled", "base_url", cfg.PlaygroundLLMBaseURL, "model", cfg.PlaygroundLLMModel) + } + // 7. Startup server in a background thread go func() { utils.Info("Server starting", "port", cfg.Port) diff --git a/middlewares/onboarding_owner.go b/middlewares/onboarding_owner.go new file mode 100644 index 0000000..ed1240e --- /dev/null +++ b/middlewares/onboarding_owner.go @@ -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() + } +} diff --git a/middlewares/staff_only.go b/middlewares/staff_only.go new file mode 100644 index 0000000..b522cb4 --- /dev/null +++ b/middlewares/staff_only.go @@ -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() +} diff --git a/migrations/migrate.go b/migrations/migrate.go index d943141..a77871d 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -1,6 +1,7 @@ package migrations import ( + "doormile/internal/ai/registry" "doormile/models" "doormile/utils" "gorm.io/gorm" @@ -60,6 +61,18 @@ func Migrate(db *gorm.DB) error { &models.BookingStageEvent{}, &models.CustomerRefreshToken{}, &models.CustomerDevice{}, + + // AI agent registry (agent-platform-plan Phase 1). Five new tables, + // nothing existing touched. + &models.AIAgent{}, + &models.AITool{}, + &models.AISkill{}, + &models.AISkillTool{}, + &models.AIRegistryAudit{}, + + // Agent runs recorded from AI_engine telemetry (plan Phase 4). One new + // append-only table, pruned after 30 days. + &models.AIAgentRun{}, ) if err != nil { @@ -69,6 +82,13 @@ func Migrate(db *gorm.DB) error { utils.Info("✅ Database migration completed successfully!") + // Upsert the code-defined agent registry. A failure is logged, not fatal: + // the registry is read by Agent Studio and AI_engine, and neither is on a + // booking's path, so it must not stop the API from serving orders. + if err := registry.Seed(db); err != nil { + utils.Error("⚠️ AI registry seed failed", "error", err.Error()) + } + if res := db.Exec(`ALTER TABLE agent_decisions ADD COLUMN IF NOT EXISTS context_embedding vector(1536)`); res.Error != nil { utils.Error("❌ Failed to add context_embedding column", "error", res.Error) } else { diff --git a/models/ai_registry.go b/models/ai_registry.go new file mode 100644 index 0000000..d5eb0d9 --- /dev/null +++ b/models/ai_registry.go @@ -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" } diff --git a/models/ai_runs.go b/models/ai_runs.go new file mode 100644 index 0000000..25212f4 --- /dev/null +++ b/models/ai_runs.go @@ -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" } diff --git a/routes/routes.go b/routes/routes.go index 9af64fa..f123b09 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -326,6 +326,18 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { 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) @@ -437,6 +449,27 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails) adminAuth.Put("/exceptions/:id/status", controllers.ResolveException) + // AI agent registry (krow_talent_app/docs/agent-platform-plan.md, Phase 1). + // Doormile staff only — a partner-tenant login gets 403. Reads are open to + // roles 1/3/4; every write is roleid 1 only and is audited. + aiRegistry := adminAuth.Group("/ai", middlewares.DoormileStaffOnly) + aiRegistry.Get("/agents", controllers.GetAIAgents) + aiRegistry.Get("/agents/:id", controllers.GetAIAgent) + aiRegistry.Patch("/agents/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAIAgent) + aiRegistry.Get("/skills", controllers.GetAISkills) + aiRegistry.Post("/skills", middlewares.RoleCheckMiddleware(1), controllers.CreateAISkill) + aiRegistry.Patch("/skills/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAISkill) + aiRegistry.Get("/tools", controllers.GetAITools) + aiRegistry.Get("/audit", controllers.GetAIRegistryAudit) + // What the agents did (Phase 4): runs from AI_engine telemetry, decisions + // from agent_decisions, live heartbeat from Redis. Read-only. + aiRegistry.Get("/insights", controllers.GetAIInsights) + aiRegistry.Get("/decisions", controllers.GetAIDecisions) + // Test tab (Phase 6): one prompt through Claude with a skill's tools. Reads + // run redacted; writes only become proposals. Admin only — every run is a + // paid API call — and rate-limited per user in the handler. + aiRegistry.Post("/playground/run", middlewares.RoleCheckMiddleware(1), controllers.RunAIPlayground) + // -------------------- // HUB CONSOLE APIS // -------------------- @@ -532,6 +565,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { internal.Post("/agent-decisions", controllers.CreateAgentDecision) internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions) internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome) + // The agent registry, for AI_engine to poll (ETag / If-None-Match → 304). + internal.Get("/ai/registry", controllers.GetInternalAIRegistry) // Express-batch dispatch: the ExpressDispatchAgent reads a tenant's riders // and the batch's bookings, then writes back the assignments it decided. diff --git a/routes/routes_ai_registry_pg_test.go b/routes/routes_ai_registry_pg_test.go new file mode 100644 index 0000000..6cbd4be --- /dev/null +++ b/routes/routes_ai_registry_pg_test.go @@ -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) + } +} diff --git a/routes/routes_ai_registry_test.go b/routes/routes_ai_registry_test.go new file mode 100644 index 0000000..3071116 --- /dev/null +++ b/routes/routes_ai_registry_test.go @@ -0,0 +1,190 @@ +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", +} + +var aiWrites = []struct{ method, path, body string }{ + {http.MethodPatch, "/api/v1/admin/ai/skills/skill_sla_guardian", `{"enabled":false}`}, + {http.MethodPost, "/api/v1/admin/ai/skills", `{"agentid":"CONSOLE_OPS_AGENT","title":"x","tools":["scan_bookings"]}`}, + {http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", `{"autonomous":true,"confirm":"EXCEPTION_AGENT"}`}, + {http.MethodPost, "/api/v1/admin/ai/playground/run", `{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`}, +} + +func TestAIRegistryRequiresALogin(t *testing.T) { + app := newApp() + for _, p := range aiReads { + if code, _ := do(t, app, http.MethodGet, p, "", ""); code != http.StatusUnauthorized { + t.Errorf("GET %s with no token = %d, want 401", p, code) + } + } +} + +func TestAIRegistryRefusesNonConsoleRoles(t *testing.T) { + app := newApp() + for _, role := range []int{5, 6, 9} { // miler, hub staff, customer + for _, p := range aiReads { + if code, _ := do(t, app, http.MethodGet, p, token(t, 1, role), ""); code != http.StatusForbidden { + t.Errorf("role %d GET %s = %d, want 403", role, p, code) + } + } + } +} + +// A partner-tenant login is refused outright — even an admin-role one — rather +// than shown an empty registry. +func TestAIRegistryRefusesPartnerTenantLogins(t *testing.T) { + app := newApp() + tok := tenantToken(t, 50, 1, 7) + for _, p := range aiReads { + code, body := do(t, app, http.MethodGet, p, tok, "") + if code != http.StatusForbidden { + t.Errorf("tenant GET %s = %d, want 403", p, code) + } + if code == http.StatusForbidden && !strings.Contains(body, "Doormile staff only") { + t.Errorf("tenant GET %s refused with the wrong message: %s", p, body) + } + } + for _, w := range aiWrites { + if code, _ := do(t, app, w.method, w.path, tok, w.body); code != http.StatusForbidden { + t.Errorf("tenant %s %s = %d, want 403", w.method, w.path, code) + } + } +} + +// Managers (3) and executives (4) may read the registry but not change it. +func TestAIRegistryWritesAreAdminOnly(t *testing.T) { + app := newApp() + for _, role := range []int{3, 4} { + for _, w := range aiWrites { + code, body := do(t, app, w.method, w.path, token(t, 1, role), w.body) + if code != http.StatusForbidden { + t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code) + } + if code == http.StatusForbidden && !strings.Contains(body, "insufficient permissions") { + t.Errorf("role %d %s %s refused by the wrong gate: %s", role, w.method, w.path, body) + } + } + } +} + +// Doormile staff with roleid 1 get through every gate. There is no database +// in this test, so the handler's first query panics and recover answers 500 — +// which is the proof the route exists and nothing in front of it refused. +func TestAIRegistryAdminPassesEveryGate(t *testing.T) { + app := newApp() + tok := token(t, 1, 1) + for _, p := range aiReads { + if code, _ := do(t, app, http.MethodGet, p, tok, ""); code == 401 || code == 403 || code == 404 { + t.Errorf("admin GET %s = %d; a gate refused or the route is missing", p, code) + } + } + for _, w := range aiWrites { + if code, _ := do(t, app, w.method, w.path, tok, w.body); code == 401 || code == 403 || code == 404 { + t.Errorf("admin %s %s = %d; a gate refused or the route is missing", w.method, w.path, code) + } + } + // Read roles get through the read gates too. + for _, role := range []int{3, 4} { + if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/ai/agents", token(t, 1, role), ""); code == 401 || code == 403 { + t.Errorf("role %d was refused a registry read (%d)", role, code) + } + } +} + +func TestInternalRegistryNeedsTheInternalKey(t *testing.T) { + t.Setenv("INTERNAL_API_KEY", "engine-key-for-tests") + app := newApp() + for _, key := range []string{"", "wrong-key"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/internal/ai/registry", nil) + if key != "" { + req.Header.Set("X-Internal-Key", key) + } + resp, err := app.Test(req, int(10*time.Second/time.Millisecond)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("internal registry with key %q = %d, want 401", key, resp.StatusCode) + } + } + // A console JWT is not an internal key. + if code, _ := do(t, app, http.MethodGet, "/api/v1/internal/ai/registry", token(t, 1, 1), ""); code != http.StatusUnauthorized { + t.Errorf("internal registry with an admin JWT = %d, want 401", code) + } +} + +// The Test playground (Phase 6). With no model client wired the endpoint +// says so plainly (503 with a code the console branches on) — it never +// pretends to run. With one wired, bad input is refused before any database +// or model call. +func TestAIPlaygroundWithoutAClientIs503(t *testing.T) { + app := newApp() + prev := controllers.PlaygroundModel + controllers.PlaygroundModel = nil + defer func() { controllers.PlaygroundModel = prev }() + + code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1), + `{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`) + if code != http.StatusServiceUnavailable || !strings.Contains(body, "PLAYGROUND_NOT_CONFIGURED") { + t.Fatalf("no client: %d %s, want 503 PLAYGROUND_NOT_CONFIGURED", code, body) + } +} + +type noCallModel struct{ t *testing.T } + +func (m noCallModel) Next(context.Context, playground.Request) (playground.Reply, error) { + m.t.Fatal("the model was called for a request that should have been refused") + return playground.Reply{}, nil +} + +func TestAIPlaygroundRefusesBadInput(t *testing.T) { + app := newApp() + prev := controllers.PlaygroundModel + controllers.PlaygroundModel = noCallModel{t} + defer func() { controllers.PlaygroundModel = prev }() + + for _, b := range []string{ + `{"agentid":"EXCEPTION_AGENT","prompt":" "}`, + `{"prompt":"hi"}`, + `{"agentid":"EXCEPTION_AGENT","prompt":"` + strings.Repeat("x", 2001) + `"}`, + `not json`, + } { + if code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1), b); code != http.StatusBadRequest { + t.Errorf("body %.40q = %d %s, want 400", b, code, body) + } + } +} diff --git a/routes/routes_client_onboarding_test.go b/routes/routes_client_onboarding_test.go new file mode 100644 index 0000000..2573443 --- /dev/null +++ b/routes/routes_client_onboarding_test.go @@ -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) + } + } + } +}