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>
212 lines
6.5 KiB
Go
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",
|
|
})
|
|
}
|