package handlers import ( "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // ConversationsHandler handles conversation endpoints. type ConversationsHandler struct { conversationService *service.ConversationService } // NewConversationsHandler creates a new conversations handler. func NewConversationsHandler(conversationService *service.ConversationService) *ConversationsHandler { return &ConversationsHandler{ conversationService: conversationService, } } // Mount registers conversation routes. func (h *ConversationsHandler) Mount(r api.Router) { r.Route("/projects/{id}/conversations", func(r chi.Router) { // Read operations r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/", h.ListConversations) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/{conversationId}", h.GetConversation) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/{conversationId}/messages", h.GetMessages) // Write operations r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/", h.CreateConversation) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Patch("/{conversationId}", h.UpdateConversation) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Delete("/{conversationId}", h.DeleteConversation) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/{conversationId}/messages", h.AddMessage) }) } // CreateConversationRequest is the request body for POST /projects/{id}/conversations. type CreateConversationRequest struct { Title string `json:"title"` } // CreateConversation creates a new conversation. // POST /projects/{id}/conversations func (h *ConversationsHandler) CreateConversation(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") var req CreateConversationRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } conv, err := h.conversationService.CreateConversation(r.Context(), projectID, req.Title) if err != nil { api.WriteInternalError(w, r, "failed to create conversation") return } api.WriteCreated(w, r, toConversationDTO(conv)) } // ListConversations returns all conversations for a project. // GET /projects/{id}/conversations func (h *ConversationsHandler) ListConversations(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") convs, err := h.conversationService.ListConversations(r.Context(), projectID) if err != nil { api.WriteInternalError(w, r, "failed to list conversations") return } dtos := make([]*ConversationDTO, len(convs)) for i, c := range convs { dtos[i] = toConversationDTO(c) } api.WriteSuccess(w, r, map[string]any{ "conversations": dtos, "total": len(dtos), }) } // GetConversation retrieves a conversation with all messages. // GET /projects/{id}/conversations/{conversationId} func (h *ConversationsHandler) GetConversation(w http.ResponseWriter, r *http.Request) { conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) conv, err := h.conversationService.GetConversationWithMessages(r.Context(), conversationID) if err != nil { if errors.Is(err, domain.ErrConversationNotFound) { api.WriteNotFound(w, r, "conversation not found") return } api.WriteInternalError(w, r, "failed to get conversation") return } api.WriteSuccess(w, r, toConversationWithMessagesDTO(conv)) } // UpdateConversationRequest is the request body for PATCH /projects/{id}/conversations/{conversationId}. type UpdateConversationRequest struct { Title string `json:"title"` } // UpdateConversation updates conversation metadata. // PATCH /projects/{id}/conversations/{conversationId} func (h *ConversationsHandler) UpdateConversation(w http.ResponseWriter, r *http.Request) { conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) var req UpdateConversationRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } v := validate.New() v.Required(req.Title, "title") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if err := h.conversationService.UpdateTitle(r.Context(), conversationID, req.Title); err != nil { if errors.Is(err, domain.ErrConversationNotFound) { api.WriteNotFound(w, r, "conversation not found") return } api.WriteInternalError(w, r, "failed to update conversation") return } api.WriteSuccess(w, r, map[string]any{ "message": "conversation updated", }) } // DeleteConversation deletes a conversation. // DELETE /projects/{id}/conversations/{conversationId} func (h *ConversationsHandler) DeleteConversation(w http.ResponseWriter, r *http.Request) { conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) if err := h.conversationService.DeleteConversation(r.Context(), conversationID); err != nil { if errors.Is(err, domain.ErrConversationNotFound) { api.WriteNotFound(w, r, "conversation not found") return } api.WriteInternalError(w, r, "failed to delete conversation") return } api.WriteSuccess(w, r, map[string]any{ "message": "conversation deleted", }) } // AddMessageRequest is the request body for POST /projects/{id}/conversations/{conversationId}/messages. type AddMessageRequest struct { Role string `json:"role"` Content string `json:"content"` } // AddMessage adds a message to a conversation. // POST /projects/{id}/conversations/{conversationId}/messages func (h *ConversationsHandler) AddMessage(w http.ResponseWriter, r *http.Request) { conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) var req AddMessageRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } v := validate.New() v.Required(req.Role, "role") v.Required(req.Content, "content") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } role := domain.MessageRole(req.Role) if role != domain.MessageRoleUser && role != domain.MessageRoleAssistant && role != domain.MessageRoleSystem { api.WriteBadRequest(w, r, "role must be 'user', 'assistant', or 'system'") return } msg, err := h.conversationService.AddMessage(r.Context(), conversationID, role, req.Content) if err != nil { if errors.Is(err, domain.ErrConversationNotFound) { api.WriteNotFound(w, r, "conversation not found") return } api.WriteInternalError(w, r, "failed to add message") return } api.WriteCreated(w, r, toMessageDTO(msg)) } // GetMessages retrieves all messages for a conversation. // GET /projects/{id}/conversations/{conversationId}/messages func (h *ConversationsHandler) GetMessages(w http.ResponseWriter, r *http.Request) { conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) messages, err := h.conversationService.GetMessages(r.Context(), conversationID) if err != nil { api.WriteInternalError(w, r, "failed to get messages") return } dtos := make([]*MessageDTO, len(messages)) for i, m := range messages { dtos[i] = toMessageDTO(m) } api.WriteSuccess(w, r, map[string]any{ "messages": dtos, "total": len(dtos), }) }