Files
Jay Brown 72de690894 Merged in feature/chatbot1 (pull request #223)
Chatbot functionality

* baseline working

* missing test file

* more tests
2026-05-07 20:56:18 +00:00

275 lines
9.5 KiB
Go

// Package queryapi: handler implementations for the BotService user
// routes. M2 covers session create/get/list and scope patch. Plan §3
// mandates singular `/client/{clientId}/...` routes; the OpenAPI spec
// declares them that way and the generated ServerInterface wires the
// path params into these handlers.
//
// Every handler:
// 1. Extracts the authenticated Cognito subject via cognitoauth.GetUserSubject.
// Returns 401 when the subject is missing.
// 2. Calls the bot.Service method with the (clientID, actor) tuple.
// 3. Maps the bot.Err* sentinels onto HTTP status codes via mapBotError.
// 4. Translates the service-layer Session into the generated
// BotSessionResponse / BotSessionListResponse.
package queryapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"queryorchestration/internal/bot"
"queryorchestration/internal/cognitoauth"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
openapi_types "github.com/oapi-codegen/runtime/types"
)
const (
defaultBotListLimit = 20
defaultBotListOffset = 0
)
// CreateBotSession is POST /client/{clientId}/bot/sessions.
//
// Body is an empty CreateBotSessionRequest object (placeholder for
// future fields). The service mints the session id and timestamps;
// title remains empty until M4's first turn arrives.
func (s *Controllers) CreateBotSession(ctx echo.Context, clientId BotClientID) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
session, err := s.svc.Bot.CreateSession(ctx.Request().Context(), clientId, actor)
if err != nil {
return mapBotError(err)
}
resp, err := sessionToResponse(session)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode session response")
}
return ctx.JSON(http.StatusCreated, resp)
}
// GetBotSession is GET /client/{clientId}/bot/sessions/{sessionId}.
// Owner-scoped read: returns 404 when the session does not match
// (clientId, actor, is_deleted=false).
func (s *Controllers) GetBotSession(ctx echo.Context, clientId BotClientID, sessionId BotSessionID) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
session, err := s.svc.Bot.GetSessionForOwner(ctx.Request().Context(), uuid.UUID(sessionId), clientId, actor)
if err != nil {
return mapBotError(err)
}
resp, err := sessionToResponse(session)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode session response")
}
return ctx.JSON(http.StatusOK, resp)
}
// ListBotSessions is GET /client/{clientId}/bot/sessions.
//
// Owner-scoped list, paged by limit/offset. Each item carries up to
// MinTurnLimit(limit, cfg.RecentTurns) preview turns.
func (s *Controllers) ListBotSessions(ctx echo.Context, clientId BotClientID, params ListBotSessionsParams) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
limit, offset, err := resolveListBotSessionParams(params.Limit, params.Offset)
if err != nil {
return err
}
sessions, err := s.svc.Bot.ListSessionsForOwner(ctx.Request().Context(), clientId, actor, bot.ListSessionsInput{
Limit: limit,
Offset: offset,
})
if err != nil {
return mapBotError(err)
}
return ctx.JSON(http.StatusOK, sessionsToListResponse(sessions))
}
// PatchBotSessionScope is PATCH /client/{clientId}/bot/sessions/{sessionId}/scope.
func (s *Controllers) PatchBotSessionScope(ctx echo.Context, clientId BotClientID, sessionId BotSessionID) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
var req BotSessionScopePatchRequest
if err := ctx.Bind(&req); err != nil {
slog.Error("failed to parse scope patch request", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
input := bot.PatchScopeInput{
AddDocuments: derefUUIDList(req.AddDocuments),
RemoveDocuments: derefUUIDList(req.RemoveDocuments),
AddFolders: derefUUIDList(req.AddFolders),
RemoveFolders: derefUUIDList(req.RemoveFolders),
}
session, err := s.svc.Bot.PatchScope(ctx.Request().Context(), uuid.UUID(sessionId), clientId, actor, input)
if err != nil {
return mapBotError(err)
}
resp, err := sessionToResponse(session)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode session response")
}
return ctx.JSON(http.StatusOK, resp)
}
// resolveListBotSessionParams normalises and validates the optional
// limit/offset query params. Plan §3 mandates limit in [1, 200] and
// offset >= 0; this is the gate before the service layer.
func resolveListBotSessionParams(limitPtr, offsetPtr *int32) (int, int, error) {
limit := defaultBotListLimit
offset := defaultBotListOffset
if limitPtr != nil {
limit = int(*limitPtr)
}
if offsetPtr != nil {
offset = int(*offsetPtr)
}
if limit < 1 || limit > 200 {
return 0, 0, echo.NewHTTPError(http.StatusBadRequest, "limit must be in [1, 200]")
}
if offset < 0 {
return 0, 0, echo.NewHTTPError(http.StatusBadRequest, "offset must be >= 0")
}
return limit, offset, nil
}
// derefUUIDList unpacks the optional pointer-to-slice that oapi-codegen
// emits for nullable arrays into a plain slice. nil input returns a
// non-nil empty slice so service-layer dedup helpers see a consistent
// shape.
func derefUUIDList(in *[]openapi_types.UUID) []uuid.UUID {
if in == nil {
return []uuid.UUID{}
}
out := make([]uuid.UUID, 0, len(*in))
for _, id := range *in {
out = append(out, uuid.UUID(id))
}
return out
}
// mapBotError translates internal/bot sentinel errors into HTTP errors.
// M4 sentinels live in mapBotTurnError so the M2/M3 cases below stay
// short. Any unrecognized error is logged and surfaced as 500.
func mapBotError(err error) error {
if turnMapped := mapBotTurnError(err); turnMapped != nil {
return turnMapped
}
switch {
case errors.Is(err, bot.ErrSessionNotFound):
return echo.NewHTTPError(http.StatusNotFound, "session not found")
case errors.Is(err, bot.ErrAddRemoveConflict):
return echo.NewHTTPError(http.StatusBadRequest, "add_remove_conflict")
case errors.Is(err, bot.ErrDocumentCrossClient):
return echo.NewHTTPError(http.StatusBadRequest, "document_cross_client")
case errors.Is(err, bot.ErrFolderCrossClient):
return echo.NewHTTPError(http.StatusBadRequest, "folder_cross_client")
case errors.Is(err, bot.ErrInvalidLimit):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, bot.ErrInvalidOffset):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, bot.ErrMissingClientID):
return echo.NewHTTPError(http.StatusBadRequest, "clientId is required")
}
slog.Error("bot handler internal error", "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
// sessionToResponse converts the service-layer Session into the
// generated BotSessionResponse type. State is unmarshalled into a
// map[string]interface{} so the wire shape is a JSON object, not a
// base64 string. recentTurns is omitted from single-session reads
// (where the service did not populate them).
func sessionToResponse(s *bot.Session) (BotSessionResponse, error) {
stateMap := map[string]interface{}{}
if len(s.State) > 0 {
if err := json.Unmarshal(s.State, &stateMap); err != nil {
return BotSessionResponse{}, err
}
}
docs := make([]openapi_types.UUID, 0, len(s.Scope.DocumentIDs))
for _, id := range s.Scope.DocumentIDs {
docs = append(docs, openapi_types.UUID(id))
}
folders := make([]openapi_types.UUID, 0, len(s.Scope.FolderIDs))
for _, id := range s.Scope.FolderIDs {
folders = append(folders, openapi_types.UUID(id))
}
previews := previewsToResponse(s.RecentTurns)
if previews == nil {
previews = []BotTurnPreview{}
}
resp := BotSessionResponse{
Id: openapi_types.UUID(s.ID),
ClientId: s.ClientID,
CreatedBy: s.CreatedBy,
Title: s.Title,
State: stateMap,
LastTurn: s.LastTurn,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
Scope: BotSessionScope{
DocumentIds: docs,
FolderIds: folders,
},
RecentTurns: previews,
}
return resp, nil
}
// previewsToResponse converts service-layer turn previews into the
// generated BotTurnPreview slice. Returns nil for empty input so
// callers can decide whether to substitute an empty slice.
func previewsToResponse(in []bot.TurnPreview) []BotTurnPreview {
if len(in) == 0 {
return nil
}
out := make([]BotTurnPreview, 0, len(in))
for _, p := range in {
row := BotTurnPreview{
Ordinal: p.Ordinal,
Prompt: p.Prompt,
Status: BotTurnPreviewStatus(p.Status),
CreatedAt: p.CreatedAt,
}
if p.Completion != nil {
row.Completion = nullable.NewNullableWithValue(*p.Completion)
} else {
row.Completion = nullable.NewNullNullable[string]()
}
out = append(out, row)
}
return out
}
// sessionsToListResponse builds the wire shape for list endpoints. The
// list always emits a non-nil array even when empty.
func sessionsToListResponse(in []*bot.Session) BotSessionListResponse {
out := BotSessionListResponse{
Sessions: make([]BotSessionResponse, 0, len(in)),
}
for _, s := range in {
resp, err := sessionToResponse(s)
if err != nil {
// json.Marshal of an in-memory map[string]interface{}
// effectively cannot fail here; log and keep going.
slog.Error("encode session in list", "error", err, "session_id", s.ID)
continue
}
out.Sessions = append(out.Sessions, resp)
}
return out
}