rdev/internal/handlers/questions.go
jordan a69eb7e587 feat(foundary): implement complete backend for conversational project design
Implements all 5 phases of Foundary Studio backend:

Phase 1: Chat Persistence (8 API endpoints)
- Conversations and messages with proper cascading deletes
- PostgreSQL schema with auto-update triggers
- Full CRUD operations with structured logging

Phase 2: Blueprint Entity (5 API endpoints)
- JSONB spec storage with GIN indexes
- Flexible structured data for project specifications
- Version-controlled blueprint management

Phase 3: Architect Service (3 API endpoints)
- Conversational AI orchestration with Claude
- Multi-turn dialogue with context building
- Blueprint spec extraction from conversations

Phase 4: Work Queue Integration
- Verified existing endpoint compatibility

Phase 5: Structured Questions (6 API endpoints)
- Four question types: text, choice, multichoice, yesno
- Answer validation with proper constraints
- Conversation-linked Q&A flow

Architecture:
- Textbook hexagonal architecture (domain → port → adapter → service → handler)
- Zero external dependencies in domain layer
- Consistent error handling with proper wrapping
- Auth scopes on all routes (projects:read, projects:execute)
- Structured logging with operation context and duration tracking
- NULL-safe DTO converters throughout

Database:
- 3 new migrations (019, 020, 021)
- UUIDs for all primary keys
- Proper foreign key constraints with ON DELETE CASCADE
- Optimized indexes including partial index for unanswered questions
- Auto-update triggers for timestamps

OpenAPI Documentation:
- Complete API documentation under 'Foundary' tag
- 22 new endpoints documented with examples
- Request/response schemas for all operations

Logging Improvements:
- Added operation field to all service logs
- Added duration_ms tracking for performance monitoring
- Log response_length instead of full response content
- Consistent use of logging field constants
- Execute-then-log pattern for delete operations

Files: 32 changed, 2800+ lines added
- 7 domain models
- 3 database migrations
- 3 port interfaces
- 3 postgres adapters
- 4 services (conversation, blueprint, question, architect)
- 4 handlers with DTOs
- OpenAPI documentation
- Integration in main.go

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-09 00:50:46 -07:00

212 lines
6.5 KiB
Go

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",
})
}