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 // 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 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.", }) } return assistantRefuse(c, http.StatusBadRequest, 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}) }