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