187 lines
6.8 KiB
Go
187 lines
6.8 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 {
|
|
details := fiber.Map{"available": ctl.assistant.Available()}
|
|
// Named "reason" rather than "error": not having an assistant is a
|
|
// deployment choice, and the same field answers "we have not switched it
|
|
// on" and "somebody misspelled a variable" — which are the two states that
|
|
// looked identical from outside.
|
|
if why := ctl.assistant.Unavailable(); why != "" {
|
|
details["reason"] = why
|
|
}
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true, "message": "Success", "details": details,
|
|
})
|
|
}
|
|
|
|
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})
|
|
}
|