Files
backend_fiesta/controllers/assistantController.go
2026-09-24 11:01:16 +05:30

180 lines
6.5 KiB
Go

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