rdev/internal/service/conversation_service.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

118 lines
3.6 KiB
Go

package service
import (
"context"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// ConversationService orchestrates conversation operations.
type ConversationService struct {
repo port.ConversationRepository
}
// NewConversationService creates a new conversation service.
func NewConversationService(repo port.ConversationRepository) *ConversationService {
return &ConversationService{repo: repo}
}
// CreateConversation creates a new conversation.
func (s *ConversationService) CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error) {
if projectID == "" {
return nil, fmt.Errorf("project_id is required")
}
if title == "" {
title = "New Conversation"
}
startTime := time.Now()
conv, err := s.repo.CreateConversation(ctx, projectID, title)
if err != nil {
return nil, err
}
log := logging.FromContext(ctx)
log.Info("conversation created",
"conversation_id", conv.ID,
logging.FieldProjectID, projectID,
logging.FieldOperation, "create_conversation",
logging.FieldDuration, time.Since(startTime).Milliseconds(),
"title", title,
)
return conv, nil
}
// GetConversation retrieves a conversation by ID.
func (s *ConversationService) GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error) {
return s.repo.GetConversation(ctx, id)
}
// ListConversations returns all conversations for a project.
func (s *ConversationService) ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error) {
return s.repo.ListConversations(ctx, projectID)
}
// UpdateTitle updates the conversation title.
func (s *ConversationService) UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error {
if title == "" {
return fmt.Errorf("title is required")
}
return s.repo.UpdateConversationTitle(ctx, id, title)
}
// DeleteConversation deletes a conversation and all its messages.
func (s *ConversationService) DeleteConversation(ctx context.Context, id domain.ConversationID) error {
startTime := time.Now()
if err := s.repo.DeleteConversation(ctx, id); err != nil {
return err
}
log := logging.FromContext(ctx)
log.Info("conversation deleted",
"conversation_id", id,
logging.FieldOperation, "delete_conversation",
logging.FieldDuration, time.Since(startTime).Milliseconds(),
)
return nil
}
// AddMessage adds a message to a conversation.
func (s *ConversationService) AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) {
if content == "" {
return nil, fmt.Errorf("message content is required")
}
startTime := time.Now()
msg, err := s.repo.AddMessage(ctx, conversationID, role, content)
if err != nil {
return nil, err
}
log := logging.FromContext(ctx)
log.Info("message added",
"conversation_id", conversationID,
"message_id", msg.ID,
"role", role,
logging.FieldOperation, "add_message",
logging.FieldDuration, time.Since(startTime).Milliseconds(),
"content_length", len(content),
)
return msg, nil
}
// GetMessages retrieves all messages for a conversation.
func (s *ConversationService) GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) {
return s.repo.GetMessages(ctx, conversationID)
}
// GetConversationWithMessages retrieves a conversation with all messages.
func (s *ConversationService) GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) {
return s.repo.GetConversationWithMessages(ctx, id)
}