// handler.go exposes the chat module's HTTP endpoints. // Routes follow the workspace/handler.go pattern: a single Handler holds all // service dependencies, and Mount registers sub-routes with Auth middleware. // // Auth strategy: // - All routes require a valid session (Auth middleware, 401 on failure). // - Admin-only routes are wrapped by adminGuard, which checks IsAdmin and // returns 403 for non-admin callers. // // Error format: httpx.WriteError (Google API style {code, message, request_id}). package chat import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // AdminLookup resolves whether a user is an admin. // Using a narrow interface (rather than importing the user package directly) // mirrors the workspace handler pattern and makes the dependency testable. type AdminLookup interface { IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) } // Handler holds all chat service dependencies and implements chi.Router mounting. type Handler struct { convSvc ConversationService msgSvc MessageService tplSvc TemplateService epSvc EndpointService usageSvc UsageService upload *Uploader resolver middleware.SessionResolver users AdminLookup } // NewHandler constructs a Handler. func NewHandler( conv ConversationService, msg MessageService, tpl TemplateService, ep EndpointService, usage UsageService, upload *Uploader, resolver middleware.SessionResolver, users AdminLookup, ) *Handler { return &Handler{ convSvc: conv, msgSvc: msg, tplSvc: tpl, epSvc: ep, usageSvc: usage, upload: upload, resolver: resolver, users: users, } } // Mount registers all chat routes under /api/v1 on r. func (h *Handler) Mount(r chi.Router) { r.Route("/api/v1", func(r chi.Router) { r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) // User-scoped conversation routes. r.Get("/conversations", h.listConversations) r.Post("/conversations", h.createConversation) r.Get("/conversations/{id}", h.getConversation) r.Patch("/conversations/{id}", h.updateConversationTitle) r.Delete("/conversations/{id}", h.deleteConversation) r.Post("/conversations/{id}/messages", h.sendMessage) r.Get("/conversations/{id}/stream", h.streamMessages) // T18 r.Get("/conversations/{id}/messages", h.listMessages) // User-scoped message routes. r.Post("/messages/{id}/cancel", h.cancelMessage) r.Post("/messages/{id}/retry", h.retryMessage) // Upload endpoint (T18). r.Post("/uploads", h.upload.Handle) // User-scoped template routes. r.Get("/prompt-templates", h.listTemplates) r.Post("/prompt-templates", h.createTemplate) r.Patch("/prompt-templates/{id}", h.updateTemplate) r.Delete("/prompt-templates/{id}", h.deleteTemplate) // User usage. r.Get("/users/me/usage", h.userDailyUsage) // Public (non-admin) models list for chat composers. r.Get("/models", h.listModelsPublic) // Admin-only routes. r.Group(func(r chi.Router) { r.Use(h.adminGuard) r.Get("/admin/llm-endpoints", h.listEndpoints) r.Post("/admin/llm-endpoints", h.createEndpoint) r.Patch("/admin/llm-endpoints/{id}", h.updateEndpoint) r.Delete("/admin/llm-endpoints/{id}", h.deleteEndpoint) r.Post("/admin/llm-endpoints/{id}/test", h.testEndpoint) r.Get("/admin/llm-models", h.listModels) r.Post("/admin/llm-models", h.createModel) r.Patch("/admin/llm-models/{id}", h.updateModel) r.Delete("/admin/llm-models/{id}", h.deleteModel) r.Get("/admin/usage", h.adminUsage) }) }) } // adminGuard is middleware that enforces admin access for the wrapped routes. func (h *Handler) adminGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized")) return } isAdmin, err := h.users.IsAdmin(r.Context(), uid) if err != nil { writeErr(w, r, err) return } if !isAdmin { writeErr(w, r, errs.New(errs.CodeForbidden, "admin only")) return } next.ServeHTTP(w, r) }) } // userID returns the authenticated user's ID from the request context. // Returns CodeUnauthorized if the Auth middleware did not run or yielded no ID. func (h *Handler) userID(r *http.Request) (uuid.UUID, error) { uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { return uuid.Nil, errs.New(errs.CodeUnauthorized, "unauthorized") } return uid, nil } // ===== Transport helpers ===== func writeErr(w http.ResponseWriter, r *http.Request, err error) { httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) } func writeJSON(w http.ResponseWriter, status int, body any) { httpx.WriteJSON(w, status, body) } func parseUUID(s string) (uuid.UUID, error) { id, err := uuid.Parse(s) if err != nil { return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid uuid") } return id, nil } // ===== Conversation handlers ===== func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } q := r.URL.Query() f := ConversationFilter{ TitleQuery: q.Get("q"), } if v := q.Get("limit"); v != "" { if n, e := strconv.Atoi(v); e == nil { f.Limit = n } } if v := q.Get("project_id"); v != "" { if id, e := parseUUID(v); e == nil { f.ProjectID = &id } } if v := q.Get("workspace_id"); v != "" { if id, e := parseUUID(v); e == nil { f.WorkspaceID = &id } } if v := q.Get("issue_id"); v != "" { if id, e := parseUUID(v); e == nil { f.IssueID = &id } } if v := q.Get("requirement_id"); v != "" { if id, e := parseUUID(v); e == nil { f.RequirementID = &id } } convs, err := h.convSvc.List(r.Context(), uid, f) if err != nil { writeErr(w, r, err) return } out := make([]ConversationDTO, 0, len(convs)) for i := range convs { out = append(out, toConversationDTO(&convs[i])) } writeJSON(w, http.StatusOK, out) } func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } var req CreateConversationReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } conv, err := h.convSvc.Create(r.Context(), uid, CreateConversationInput{ ModelID: req.ModelID, TemplateID: req.TemplateID, SystemPrompt: req.SystemPrompt, ProjectID: req.ProjectID, WorkspaceID: req.WorkspaceID, IssueID: req.IssueID, RequirementID: req.RequirementID, }) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusCreated, toConversationDTO(conv)) } func (h *Handler) getConversation(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } limit := 50 if v := r.URL.Query().Get("limit"); v != "" { if n, e := strconv.Atoi(v); e == nil && n > 0 { limit = n } } conv, msgs, err := h.convSvc.Get(r.Context(), uid, id, limit) if err != nil { writeErr(w, r, err) return } msgDTOs := make([]MessageDTO, 0, len(msgs)) for i := range msgs { msgDTOs = append(msgDTOs, toMessageDTO(&msgs[i])) } writeJSON(w, http.StatusOK, map[string]any{ "conversation": toConversationDTO(conv), "messages": msgDTOs, }) } func (h *Handler) updateConversationTitle(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } var req UpdateConversationTitleReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } if err := h.convSvc.UpdateTitle(r.Context(), uid, id, req.Title); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) deleteConversation(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } if err := h.convSvc.SoftDelete(r.Context(), uid, id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // ===== Message handlers ===== func (h *Handler) sendMessage(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } convID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } var req SendMessageReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } userMsgID, assistantMsgID, err := h.msgSvc.Send(r.Context(), uid, convID, req.Content, req.AttachmentIDs) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusOK, SendMessageResp{ UserMessageID: userMsgID, AssistantMessageID: assistantMsgID, StreamURL: fmt.Sprintf("/api/v1/conversations/%s/stream", convID), }) } func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } convID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } q := r.URL.Query() var beforeID *int64 if v := q.Get("before_id"); v != "" { n, e := strconv.ParseInt(v, 10, 64) if e == nil { beforeID = &n } } limit := 50 if v := q.Get("limit"); v != "" { if n, e := strconv.Atoi(v); e == nil && n > 0 { limit = n } } msgs, err := h.msgSvc.ListMessages(r.Context(), uid, convID, beforeID, limit) if err != nil { writeErr(w, r, err) return } out := make([]MessageDTO, 0, len(msgs)) for i := range msgs { out = append(out, toMessageDTO(&msgs[i])) } writeJSON(w, http.StatusOK, out) } func (h *Handler) cancelMessage(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } msgID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id")) return } if err := h.msgSvc.Cancel(r.Context(), uid, msgID); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) retryMessage(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } msgID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id")) return } newID, convID, err := h.msgSvc.Retry(r.Context(), uid, msgID) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{ "assistant_message_id": newID, "stream_url": fmt.Sprintf("/api/v1/conversations/%s/stream", convID), }) } // ===== Template handlers ===== func (h *Handler) listTemplates(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } tpls, err := h.tplSvc.List(r.Context(), uid) if err != nil { writeErr(w, r, err) return } out := make([]TemplateDTO, 0, len(tpls)) for i := range tpls { out = append(out, toTemplateDTO(&tpls[i])) } writeJSON(w, http.StatusOK, out) } func (h *Handler) createTemplate(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } var req CreateTemplateReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } scope := TemplateScope(req.Scope) // Creating a system-scoped template requires admin privileges. if scope == TemplateScopeSystem { isAdmin, e := h.users.IsAdmin(r.Context(), uid) if e != nil { writeErr(w, r, e) return } if !isAdmin { writeErr(w, r, errs.New(errs.CodeForbidden, "system scope requires admin")) return } } tpl, err := h.tplSvc.Create(r.Context(), uid, req.Name, req.Content, scope) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusCreated, toTemplateDTO(tpl)) } func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } var req UpdateTemplateReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } if err := h.tplSvc.Update(r.Context(), uid, id, req.Name, req.Content); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } if err := h.tplSvc.Delete(r.Context(), uid, id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // ===== Usage handlers ===== func (h *Handler) userDailyUsage(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } q := r.URL.Query() to := time.Now().UTC() from := to.AddDate(0, 0, -30) if v := q.Get("from"); v != "" { if t, e := time.Parse(time.RFC3339, v); e == nil { from = t } } if v := q.Get("to"); v != "" { if t, e := time.Parse(time.RFC3339, v); e == nil { to = t } } rows, err := h.usageSvc.UserDaily(r.Context(), uid, from, to) if err != nil { writeErr(w, r, err) return } out := make([]DayUsageDTO, 0, len(rows)) for _, row := range rows { out = append(out, DayUsageDTO{ Day: row.Day, PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, }) } writeJSON(w, http.StatusOK, out) } // ===== Admin: Endpoint handlers ===== func (h *Handler) listEndpoints(w http.ResponseWriter, r *http.Request) { eps, err := h.epSvc.ListEndpoints(r.Context()) if err != nil { writeErr(w, r, err) return } out := make([]EndpointDTO, 0, len(eps)) for i := range eps { out = append(out, toEndpointDTO(&eps[i])) } writeJSON(w, http.StatusOK, out) } func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { uid, err := h.userID(r) if err != nil { writeErr(w, r, err) return } var req CreateEndpointReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } ep, err := h.epSvc.CreateEndpoint(r.Context(), uid, CreateEndpointInput{ Provider: LLMProvider(req.Provider), DisplayName: req.DisplayName, BaseURL: req.BaseURL, APIKey: req.APIKey, }) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusCreated, toEndpointDTO(ep)) } func (h *Handler) updateEndpoint(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } var req UpdateEndpointReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } if err := h.epSvc.UpdateEndpoint(r.Context(), id, UpdateEndpointInput{ DisplayName: req.DisplayName, BaseURL: req.BaseURL, APIKey: req.APIKey, }); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) deleteEndpoint(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } if err := h.epSvc.DeleteEndpoint(r.Context(), id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) testEndpoint(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } if err := h.epSvc.TestEndpoint(r.Context(), id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // ===== Admin: Model handlers ===== func (h *Handler) listModels(w http.ResponseWriter, r *http.Request) { onlyEnabled := r.URL.Query().Get("only_enabled") == "true" models, err := h.epSvc.ListModels(r.Context(), onlyEnabled) if err != nil { writeErr(w, r, err) return } out := make([]ModelDTO, 0, len(models)) for i := range models { out = append(out, toModelDTO(&models[i])) } writeJSON(w, http.StatusOK, out) } // listModelsPublic mirrors listModels but is reachable by any authenticated // user (non-admin), so chat composers can list available models. func (h *Handler) listModelsPublic(w http.ResponseWriter, r *http.Request) { onlyEnabled := r.URL.Query().Get("only_enabled") == "true" models, err := h.epSvc.ListModels(r.Context(), onlyEnabled) if err != nil { writeErr(w, r, err) return } out := make([]ModelDTO, 0, len(models)) for i := range models { out = append(out, toModelDTO(&models[i])) } writeJSON(w, http.StatusOK, out) } func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) { var req CreateModelReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{ EndpointID: req.EndpointID, ModelID: req.ModelID, DisplayName: req.DisplayName, Capabilities: req.Capabilities, ContextWindow: req.ContextWindow, MaxOutputTokens: req.MaxOutputTokens, PromptPrice: req.PromptPricePerMillionUSD, CompletionPrice: req.CompletionPricePerMillionUSD, ThinkingPrice: req.ThinkingPricePerMillionUSD, IsTitleGenerator: req.IsTitleGenerator, Enabled: req.Enabled, SortOrder: req.SortOrder, }) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusCreated, toModelDTO(m)) } func (h *Handler) updateModel(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } var req UpdateModelReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } if err := h.epSvc.UpdateModel(r.Context(), id, UpdateModelInput{ DisplayName: req.DisplayName, Capabilities: req.Capabilities, ContextWindow: req.ContextWindow, MaxOutputTokens: req.MaxOutputTokens, PromptPrice: req.PromptPricePerMillionUSD, CompletionPrice: req.CompletionPricePerMillionUSD, ThinkingPrice: req.ThinkingPricePerMillionUSD, IsTitleGenerator: req.IsTitleGenerator, Enabled: req.Enabled, SortOrder: req.SortOrder, }); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) deleteModel(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeErr(w, r, err) return } if err := h.epSvc.DeleteModel(r.Context(), id); err != nil { writeErr(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // ===== Admin: Usage handler ===== func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() to := time.Now().UTC() from := to.AddDate(0, 0, -30) if v := q.Get("from"); v != "" { if t, e := time.Parse(time.RFC3339, v); e == nil { from = t } } if v := q.Get("to"); v != "" { if t, e := time.Parse(time.RFC3339, v); e == nil { to = t } } groupBy := q.Get("group_by") if groupBy == "" { groupBy = "day" } items := make([]AdminUsageItem, 0) switch groupBy { case "user": rows, err := h.usageSvc.SummarizeByUser(r.Context(), from, to) if err != nil { writeErr(w, r, err) return } for _, row := range rows { items = append(items, AdminUsageItem{ Key: row.UserID.String(), PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, }) } case "model": rows, err := h.usageSvc.SummarizeByModel(r.Context(), from, to) if err != nil { writeErr(w, r, err) return } for _, row := range rows { items = append(items, AdminUsageItem{ Key: row.ModelID.String(), PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, }) } case "day": rows, err := h.usageSvc.SummarizeByDay(r.Context(), from, to) if err != nil { writeErr(w, r, err) return } for _, row := range rows { items = append(items, AdminUsageItem{ Key: row.Day.Format(time.RFC3339), PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, }) } default: writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group_by")) return } writeJSON(w, http.StatusOK, AdminUsageResp{Items: items}) }