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>
171 lines
5.0 KiB
Go
171 lines
5.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// QuestionService orchestrates question operations.
|
|
type QuestionService struct {
|
|
repo port.QuestionRepository
|
|
}
|
|
|
|
// NewQuestionService creates a new question service.
|
|
func NewQuestionService(repo port.QuestionRepository) *QuestionService {
|
|
return &QuestionService{repo: repo}
|
|
}
|
|
|
|
// CreateQuestion creates a new question.
|
|
func (s *QuestionService) CreateQuestion(ctx context.Context, conversationID domain.ConversationID, projectID string, questionType domain.QuestionType, text string, choices []string, metadata map[string]string) (*domain.Question, error) {
|
|
if projectID == "" {
|
|
return nil, fmt.Errorf("project_id is required")
|
|
}
|
|
if text == "" {
|
|
return nil, fmt.Errorf("text is required")
|
|
}
|
|
|
|
// Validate choices for choice-based questions
|
|
if (questionType == domain.QuestionTypeChoice || questionType == domain.QuestionTypeMultiChoice) && len(choices) == 0 {
|
|
return nil, fmt.Errorf("choices are required for choice-based questions")
|
|
}
|
|
|
|
if metadata == nil {
|
|
metadata = make(map[string]string)
|
|
}
|
|
|
|
question := &domain.Question{
|
|
ID: domain.QuestionID(uuid.New().String()),
|
|
ConversationID: conversationID,
|
|
ProjectID: projectID,
|
|
Type: questionType,
|
|
Text: text,
|
|
Choices: choices,
|
|
Metadata: metadata,
|
|
}
|
|
|
|
startTime := time.Now()
|
|
if err := s.repo.CreateQuestion(ctx, question); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log := logging.FromContext(ctx)
|
|
log.Info("question created",
|
|
"question_id", question.ID,
|
|
logging.FieldProjectID, projectID,
|
|
logging.FieldOperation, "create_question",
|
|
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
"question_type", questionType,
|
|
"conversation_id", conversationID,
|
|
"choice_count", len(choices),
|
|
)
|
|
|
|
return question, nil
|
|
}
|
|
|
|
// GetQuestion retrieves a question by ID.
|
|
func (s *QuestionService) GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error) {
|
|
return s.repo.GetQuestion(ctx, id)
|
|
}
|
|
|
|
// ListUnansweredQuestions returns all unanswered questions for a project.
|
|
func (s *QuestionService) ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error) {
|
|
return s.repo.ListUnansweredQuestions(ctx, projectID)
|
|
}
|
|
|
|
// ListQuestionsByConversation returns all questions for a conversation.
|
|
func (s *QuestionService) ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error) {
|
|
return s.repo.ListQuestionsByConversation(ctx, conversationID)
|
|
}
|
|
|
|
// AnswerQuestion records an answer to a question.
|
|
func (s *QuestionService) AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error {
|
|
// Get question to validate answer type
|
|
question, err := s.repo.GetQuestion(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate answer based on question type
|
|
switch question.Type {
|
|
case domain.QuestionTypeText:
|
|
if answer == nil || *answer == "" {
|
|
return fmt.Errorf("text answer is required")
|
|
}
|
|
case domain.QuestionTypeYesNo:
|
|
if answer == nil || (*answer != "yes" && *answer != "no") {
|
|
return fmt.Errorf("answer must be 'yes' or 'no'")
|
|
}
|
|
case domain.QuestionTypeChoice:
|
|
if answer == nil || *answer == "" {
|
|
return fmt.Errorf("choice answer is required")
|
|
}
|
|
// Validate choice is in the available choices
|
|
valid := false
|
|
for _, choice := range question.Choices {
|
|
if *answer == choice {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("answer must be one of the available choices")
|
|
}
|
|
case domain.QuestionTypeMultiChoice:
|
|
if len(answerChoices) == 0 {
|
|
return fmt.Errorf("at least one choice must be selected")
|
|
}
|
|
// Validate all choices are in the available choices
|
|
for _, selected := range answerChoices {
|
|
valid := false
|
|
for _, choice := range question.Choices {
|
|
if selected == choice {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("answer '%s' is not one of the available choices", selected)
|
|
}
|
|
}
|
|
}
|
|
|
|
startTime := time.Now()
|
|
if err := s.repo.AnswerQuestion(ctx, id, answer, answerChoices); err != nil {
|
|
return err
|
|
}
|
|
|
|
log := logging.FromContext(ctx)
|
|
log.Info("question answered",
|
|
"question_id", id,
|
|
logging.FieldProjectID, question.ProjectID,
|
|
logging.FieldOperation, "answer_question",
|
|
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
"question_type", question.Type,
|
|
"answer_choice_count", len(answerChoices),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteQuestion deletes a question.
|
|
func (s *QuestionService) DeleteQuestion(ctx context.Context, id domain.QuestionID) error {
|
|
startTime := time.Now()
|
|
if err := s.repo.DeleteQuestion(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
log := logging.FromContext(ctx)
|
|
log.Info("question deleted",
|
|
"question_id", id,
|
|
logging.FieldOperation, "delete_question",
|
|
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
)
|
|
return nil
|
|
}
|