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" ) // QuestionsHandler handles question endpoints. type QuestionsHandler struct { questionService *service.QuestionService } // NewQuestionsHandler creates a new questions handler. func NewQuestionsHandler(questionService *service.QuestionService) *QuestionsHandler { return &QuestionsHandler{ questionService: questionService, } } // Mount registers question routes. func (h *QuestionsHandler) Mount(r api.Router) { r.Route("/projects/{id}/questions", func(r chi.Router) { // Read operations r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/", h.ListUnansweredQuestions) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/{questionId}", h.GetQuestion) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/conversation/{conversationId}", h.ListQuestionsByConversation) // Write operations r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/", h.CreateQuestion) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/{questionId}/answer", h.AnswerQuestion) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Delete("/{questionId}", h.DeleteQuestion) }) } // CreateQuestionRequest is the request body for POST /projects/{id}/questions. type CreateQuestionRequest struct { ConversationID string `json:"conversation_id"` Type string `json:"type"` Text string `json:"text"` Choices []string `json:"choices,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } // CreateQuestion creates a new question. // POST /projects/{id}/questions func (h *QuestionsHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") var req CreateQuestionRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } v := validate.New() v.Required(req.ConversationID, "conversation_id") v.Required(req.Type, "type") v.Required(req.Text, "text") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } questionType := domain.QuestionType(req.Type) if questionType != domain.QuestionTypeText && questionType != domain.QuestionTypeChoice && questionType != domain.QuestionTypeMultiChoice && questionType != domain.QuestionTypeYesNo { api.WriteBadRequest(w, r, "type must be 'text', 'choice', 'multichoice', or 'yesno'") return } question, err := h.questionService.CreateQuestion( r.Context(), domain.ConversationID(req.ConversationID), projectID, questionType, req.Text, req.Choices, req.Metadata, ) if err != nil { api.WriteInternalError(w, r, "failed to create question") return } api.WriteCreated(w, r, toQuestionDTO(question)) } // ListUnansweredQuestions returns all unanswered questions for a project. // GET /projects/{id}/questions func (h *QuestionsHandler) ListUnansweredQuestions(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") questions, err := h.questionService.ListUnansweredQuestions(r.Context(), projectID) if err != nil { api.WriteInternalError(w, r, "failed to list questions") return } dtos := make([]*QuestionDTO, len(questions)) for i, q := range questions { dtos[i] = toQuestionDTO(q) } api.WriteSuccess(w, r, map[string]any{ "questions": dtos, "total": len(dtos), }) } // GetQuestion retrieves a question by ID. // GET /projects/{id}/questions/{questionId} func (h *QuestionsHandler) GetQuestion(w http.ResponseWriter, r *http.Request) { questionID := domain.QuestionID(chi.URLParam(r, "questionId")) question, err := h.questionService.GetQuestion(r.Context(), questionID) if err != nil { if errors.Is(err, domain.ErrQuestionNotFound) { api.WriteNotFound(w, r, "question not found") return } api.WriteInternalError(w, r, "failed to get question") return } api.WriteSuccess(w, r, toQuestionDTO(question)) } // ListQuestionsByConversation returns all questions for a conversation. // GET /projects/{id}/questions/conversation/{conversationId} func (h *QuestionsHandler) ListQuestionsByConversation(w http.ResponseWriter, r *http.Request) { conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) questions, err := h.questionService.ListQuestionsByConversation(r.Context(), conversationID) if err != nil { api.WriteInternalError(w, r, "failed to list questions") return } dtos := make([]*QuestionDTO, len(questions)) for i, q := range questions { dtos[i] = toQuestionDTO(q) } api.WriteSuccess(w, r, map[string]any{ "questions": dtos, "total": len(dtos), }) } // AnswerQuestionRequest is the request body for POST /projects/{id}/questions/{questionId}/answer. type AnswerQuestionRequest struct { Answer *string `json:"answer,omitempty"` AnswerChoices []string `json:"answer_choices,omitempty"` } // AnswerQuestion records an answer to a question. // POST /projects/{id}/questions/{questionId}/answer func (h *QuestionsHandler) AnswerQuestion(w http.ResponseWriter, r *http.Request) { questionID := domain.QuestionID(chi.URLParam(r, "questionId")) var req AnswerQuestionRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if err := h.questionService.AnswerQuestion(r.Context(), questionID, req.Answer, req.AnswerChoices); err != nil { if errors.Is(err, domain.ErrQuestionNotFound) { api.WriteNotFound(w, r, "question not found") return } api.WriteBadRequest(w, r, err.Error()) return } api.WriteSuccess(w, r, map[string]any{ "message": "question answered", }) } // DeleteQuestion deletes a question. // DELETE /projects/{id}/questions/{questionId} func (h *QuestionsHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) { questionID := domain.QuestionID(chi.URLParam(r, "questionId")) if err := h.questionService.DeleteQuestion(r.Context(), questionID); err != nil { if errors.Is(err, domain.ErrQuestionNotFound) { api.WriteNotFound(w, r, "question not found") return } api.WriteInternalError(w, r, "failed to delete question") return } api.WriteSuccess(w, r, map[string]any{ "message": "question deleted", }) }