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>
236 lines
7.4 KiB
Go
236 lines
7.4 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"
|
|
)
|
|
|
|
// 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),
|
|
})
|
|
}
|