package controllers import ( "errors" "net/http" "strings" "nearle/middleware" "nearle/services" "nearle/services/tools" "nearle/utils" "github.com/gofiber/fiber/v2" ) // Nearle Buddy's HTTP surface. // // POST /v1/web/assistant/ask a question → an answer, and what it ran // POST /v1/web/assistant/approve a card the person pressed → the change, made // GET /v1/web/assistant/status is this switched on here? // // ── Where the caller comes from ───────────────────────────────────────────── // // `middleware.WebAuth` parks the verified claims on the request, and this // builds the tool caller from those and from nothing else. There is no tenant // field on the request body — deliberately, so there is nothing for a model or // a caller to fill in. The console asks "what is stuck?" and the server already // knows whose shop that means. type AssistantController struct { assistant services.AssistantService } func NewAssistantController(assistant services.AssistantService) *AssistantController { return &AssistantController{assistant: assistant} } type assistantApproveRequest struct { Agent string `json:"agent"` // The card exactly as it was handed out. Opaque to the console — it is // signed, and anything the browser changed stops it verifying. Card string `json:"card"` } type assistantAskRequest struct { // Which agent to ask. The console sends the one matching the page the panel // is sitting beside; empty means orders, the only one phase 2 ships. Agent string `json:"agent"` Question string `json:"question"` } // Status lets the console decide what to render before anybody types. // // The composer is disabled when this says no, which is the honest thing: a // field that accepts text and then swallows it is worse than one that says it // is not connected. The console has shown "Not connected yet" since it was // built, and this is what finally answers that question at runtime rather than // at build time. func (ctl *AssistantController) Status(c *fiber.Ctx) error { return c.Status(http.StatusOK).JSON(fiber.Map{ "code": http.StatusOK, "status": true, "message": "Success", "details": fiber.Map{"available": ctl.assistant.Available()}, }) } func (ctl *AssistantController) Ask(c *fiber.Ctx) error { var req assistantAskRequest if err := c.BodyParser(&req); err != nil { return assistantRefuse(c, http.StatusBadRequest, "Invalid request body") } caller, ok := callerFrom(c) if !ok { // Reachable only while WEB_AUTH_REQUIRED is off, where an untokened // request still reaches handlers. Every other endpoint answers such a // request; this one must not. Reading a shop's orders through a REST // call takes knowing the endpoints and the fields; through an // assistant it takes one sentence, so this surface holds the higher // bar from its first day rather than inheriting the rollout's. return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to use Nearle Buddy.") } agent := strings.TrimSpace(req.Agent) if agent == "" { agent = "orders" } ctx, cancel := services.WithTimeout(c.Context()) defer cancel() answer, err := ctl.assistant.Ask(ctx, agent, req.Question, caller) if err != nil { // "Not switched on here" is a deployment fact, not a fault, and it gets // its own status so the console can disable the composer rather than // showing an error the person can do nothing about. if errors.Is(err, utils.ErrChatNotConfigured) { return c.Status(http.StatusOK).JSON(fiber.Map{ "code": http.StatusServiceUnavailable, "status": false, "message": "Nearle Buddy is not switched on for this deployment.", }) } // Asking too fast gets its own status, so the console and whatever // watches it can tell "you are going too quickly" apart from "that // question was malformed". The message already says how long to wait. var tooFast services.ErrTooFast if errors.As(err, &tooFast) { return assistantRefuse(c, http.StatusTooManyRequests, err.Error()) } return assistantRefuse(c, http.StatusBadRequest, err.Error()) } return c.Status(http.StatusOK).JSON(fiber.Map{ "code": http.StatusOK, "status": true, "message": "Success", "details": answer, }) } // Approve performs a change the person pressed the button on. // // Its own endpoint, not a flag on /ask, because it is a different kind of act: // no question, no model, no conversation. The card names the action and the // session names the person, and the registry re-checks both against the live // database before anything is written. func (ctl *AssistantController) Approve(c *fiber.Ctx) error { var req assistantApproveRequest if err := c.BodyParser(&req); err != nil { return assistantRefuse(c, http.StatusBadRequest, "Invalid request body") } if strings.TrimSpace(req.Card) == "" { return assistantRefuse(c, http.StatusBadRequest, "Nothing to approve.") } caller, ok := callerFrom(c) if !ok { return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to approve this.") } agent := strings.TrimSpace(req.Agent) if agent == "" { agent = "orders" } ctx, cancel := services.WithTimeout(c.Context()) defer cancel() answer, err := ctl.assistant.Approve(ctx, agent, req.Card, caller) if err != nil { // A refused approval is a business outcome, not a server fault: the card // expired, somebody else already approved it, the request was withdrawn. // The person needs the reason, and the console renders it beside the // card rather than as an error page. return assistantRefuse(c, http.StatusConflict, err.Error()) } return c.Status(http.StatusOK).JSON(fiber.Map{ "code": http.StatusOK, "status": true, "message": "Success", "details": answer, }) } // callerFrom turns a verified session into a tool caller. // // The one place the two vocabularies meet. Staff (`issuperadmin`) carry no // tenant, and the registry lets them through — but a tool that reads a shop's // data refuses them until they have picked one, because "every tenant at once" // is not an answer to "what is stuck?". func callerFrom(c *fiber.Ctx) (tools.Caller, bool) { claims, ok := middleware.WebClaimsFrom(c) if !ok { return tools.Caller{}, false } return tools.Caller{ Userid: claims.Userid, Tenantid: claims.Tenantid, Locationid: claims.Locationid, Superadmin: claims.Superadmin, }, true } func assistantRefuse(c *fiber.Ctx, code int, message string) error { return c.Status(code).JSON(fiber.Map{"code": code, "status": false, "message": message}) }