rdev/internal/handlers/blueprints.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

170 lines
5.1 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"
)
// BlueprintsHandler handles blueprint endpoints.
type BlueprintsHandler struct {
blueprintService *service.BlueprintService
}
// NewBlueprintsHandler creates a new blueprints handler.
func NewBlueprintsHandler(blueprintService *service.BlueprintService) *BlueprintsHandler {
return &BlueprintsHandler{
blueprintService: blueprintService,
}
}
// Mount registers blueprint routes.
func (h *BlueprintsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/blueprints", func(r chi.Router) {
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/", h.ListBlueprints)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/{blueprintId}", h.GetBlueprint)
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/", h.CreateBlueprint)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Put("/{blueprintId}", h.UpdateBlueprint)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/{blueprintId}", h.DeleteBlueprint)
})
}
// CreateBlueprintRequest is the request body for POST /projects/{id}/blueprints.
type CreateBlueprintRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Spec map[string]any `json:"spec"`
}
// CreateBlueprint creates a new blueprint.
// POST /projects/{id}/blueprints
func (h *BlueprintsHandler) CreateBlueprint(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
var req CreateBlueprintRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Name, "name")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
blueprint, err := h.blueprintService.CreateBlueprint(r.Context(), projectID, req.Name, req.Description, req.Spec)
if err != nil {
api.WriteInternalError(w, r, "failed to create blueprint")
return
}
api.WriteCreated(w, r, toBlueprintDTO(blueprint))
}
// ListBlueprints returns all blueprints for a project.
// GET /projects/{id}/blueprints
func (h *BlueprintsHandler) ListBlueprints(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
blueprints, err := h.blueprintService.ListBlueprints(r.Context(), projectID)
if err != nil {
api.WriteInternalError(w, r, "failed to list blueprints")
return
}
dtos := make([]*BlueprintDTO, len(blueprints))
for i, b := range blueprints {
dtos[i] = toBlueprintDTO(b)
}
api.WriteSuccess(w, r, map[string]any{
"blueprints": dtos,
"total": len(dtos),
})
}
// GetBlueprint retrieves a blueprint by ID.
// GET /projects/{id}/blueprints/{blueprintId}
func (h *BlueprintsHandler) GetBlueprint(w http.ResponseWriter, r *http.Request) {
blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId"))
blueprint, err := h.blueprintService.GetBlueprint(r.Context(), blueprintID)
if err != nil {
if errors.Is(err, domain.ErrBlueprintNotFound) {
api.WriteNotFound(w, r, "blueprint not found")
return
}
api.WriteInternalError(w, r, "failed to get blueprint")
return
}
api.WriteSuccess(w, r, toBlueprintDTO(blueprint))
}
// UpdateBlueprintRequest is the request body for PUT /projects/{id}/blueprints/{blueprintId}.
type UpdateBlueprintRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Spec map[string]any `json:"spec"`
}
// UpdateBlueprint updates a blueprint.
// PUT /projects/{id}/blueprints/{blueprintId}
func (h *BlueprintsHandler) UpdateBlueprint(w http.ResponseWriter, r *http.Request) {
blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId"))
var req UpdateBlueprintRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if err := h.blueprintService.UpdateBlueprint(r.Context(), blueprintID, req.Name, req.Description, req.Spec); err != nil {
if errors.Is(err, domain.ErrBlueprintNotFound) {
api.WriteNotFound(w, r, "blueprint not found")
return
}
api.WriteInternalError(w, r, "failed to update blueprint")
return
}
api.WriteSuccess(w, r, map[string]any{
"message": "blueprint updated",
})
}
// DeleteBlueprint deletes a blueprint.
// DELETE /projects/{id}/blueprints/{blueprintId}
func (h *BlueprintsHandler) DeleteBlueprint(w http.ResponseWriter, r *http.Request) {
blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId"))
if err := h.blueprintService.DeleteBlueprint(r.Context(), blueprintID); err != nil {
if errors.Is(err, domain.ErrBlueprintNotFound) {
api.WriteNotFound(w, r, "blueprint not found")
return
}
api.WriteInternalError(w, r, "failed to delete blueprint")
return
}
api.WriteSuccess(w, r, map[string]any{
"message": "blueprint deleted",
})
}