// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "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/logging" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) // ProjectManagementHandler handles project lifecycle operations. type ProjectManagementHandler struct { infraService *service.ProjectInfraService operationService *service.OperationService } // NewProjectManagementHandler creates a new project management handler. func NewProjectManagementHandler(infraService *service.ProjectInfraService) *ProjectManagementHandler { return &ProjectManagementHandler{ infraService: infraService, } } // SetOperationService sets the operation tracking service. func (h *ProjectManagementHandler) SetOperationService(svc *service.OperationService) *ProjectManagementHandler { if svc != nil { h.operationService = svc } return h } // Mount registers the project management routes. func (h *ProjectManagementHandler) Mount(r api.Router) { r.Route("/project", func(r chi.Router) { // Write operations r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/", h.Create) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Delete("/{name}", h.Delete) // Read operations r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/", h.List) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/{name}", h.Status) }) // Bulk operations - cleanup test projects (admin only) // Note: Uses direct route to avoid conflict with /projects in projects.go r.With(auth.RequireScope(auth.ScopeAdmin)). Delete("/projects/cleanup", h.CleanupTestProjects) // Template endpoints (read-only) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/templates", h.ListTemplates) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/templates/components", h.ListComponentTemplates) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/templates/{name}", h.GetTemplate) } // CreateRequest is the request body for POST /project. type CreateRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` Private bool `json:"private,omitempty"` Template string `json:"template,omitempty"` // Template to seed repo (default: "default") } // Create creates a new project with git repo and DNS. // POST /project func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } var req CreateRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Name == "" { api.WriteBadRequest(w, r, "name is required") return } // Start operation tracking var operationID string if h.operationService != nil { operationID, _ = h.operationService.StartOperation(ctx, req.Name, domain.OperationTypeProjectCreate, map[string]any{"name": req.Name, "description": req.Description, "template": req.Template}, r.Header.Get("X-Request-ID")) } result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{ Name: req.Name, Description: req.Description, Private: req.Private, Template: req.Template, }) if err != nil { log := logging.FromContext(ctx).WithHandler("Create") if h.operationService != nil && operationID != "" { if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil { log.Error("failed to record operation failure", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } // Check for validation errors (user input) vs internal errors if errors.Is(err, domain.ErrInvalidProjectName) { api.WriteBadRequest(w, r, err.Error()) return } // Log internal errors but return generic message to client log.Error("project creation failed", logging.FieldError, err.Error(), logging.FieldProjectName, req.Name) api.WriteInternalError(w, r, "failed to create project") return } if h.operationService != nil && operationID != "" { if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{ "project_id": result.ProjectID, "git_url": result.CloneHTTP, }); opErr != nil { log := logging.FromContext(ctx).WithHandler("Create") log.Error("failed to record operation completion", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } resp := map[string]any{ "project_id": result.ProjectID, "name": result.Name, "description": result.Description, "git": map[string]string{ "owner": result.GitRepoOwner, "name": result.GitRepoName, "clone_ssh": result.CloneSSH, "clone_http": result.CloneHTTP, "html_url": result.HTMLURL, }, "domain": result.Domain, "url": result.URL, "next_steps": result.NextSteps, } if operationID != "" { resp["operation_id"] = operationID } api.WriteCreated(w, r, resp) } // List returns all projects. // GET /project func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } projects, err := h.infraService.ListProjects(ctx) if err != nil { log := logging.FromContext(ctx).WithHandler("List") log.Error("failed to list projects", logging.FieldError, err.Error()) api.WriteInternalError(w, r, "failed to list projects") return } // Convert to response format response := make([]map[string]any, len(projects)) for i, p := range projects { response[i] = map[string]any{ "project_id": p.ProjectID, "name": p.Name, "description": p.Description, "git": map[string]string{ "clone_ssh": p.CloneSSH, "clone_http": p.CloneHTTP, "html_url": p.HTMLURL, }, "domain": p.Domain, "url": p.URL, "deployment": map[string]any{ "status": p.DeploymentStatus, "image": p.DeploymentImage, "replicas": p.DeploymentReplicas, "ready_replicas": p.ReadyReplicas, }, } } api.WriteSuccess(w, r, response) } // Status returns the status of a specific project. // GET /project/{name} func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } status, err := h.infraService.GetStatus(ctx, name) if err != nil { // Check if it's a "not found" error if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, "project not found") return } log := logging.FromContext(ctx).WithHandler("Status") log.Error("failed to get project status", logging.FieldError, err.Error(), logging.FieldProjectName, name) api.WriteInternalError(w, r, "failed to get project status") return } api.WriteSuccess(w, r, map[string]any{ "project_id": status.ProjectID, "name": status.Name, "description": status.Description, "git": map[string]string{ "owner": status.GitRepoOwner, "name": status.GitRepoName, "clone_ssh": status.CloneSSH, "clone_http": status.CloneHTTP, "html_url": status.HTMLURL, }, "domain": status.Domain, "custom_domain": status.CustomDomain, "url": status.URL, "deployment": map[string]any{ "status": status.DeploymentStatus, "image": status.DeploymentImage, "replicas": status.DeploymentReplicas, "ready_replicas": status.ReadyReplicas, }, }) } // Delete removes a project and its associated resources. // DELETE /project/{name} func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } err := h.infraService.DeleteProject(ctx, name) if err != nil { // Check if it's a "not found" error if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, "project not found") return } log := logging.FromContext(ctx).WithHandler("Delete") log.Error("failed to delete project", logging.FieldError, err.Error(), logging.FieldProjectName, name) api.WriteInternalError(w, r, "failed to delete project") return } api.WriteSuccess(w, r, map[string]string{ "status": "deleted", "project": name, }) } // ListTemplates returns available project templates. // GET /templates func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } templates, err := h.infraService.ListTemplates(ctx) if err != nil { log := logging.FromContext(ctx).WithHandler("ListTemplates") log.Error("failed to list templates", logging.FieldError, err.Error()) api.WriteInternalError(w, r, "failed to list templates") return } // Convert to response format response := make([]map[string]any, len(templates)) for i, t := range templates { response[i] = map[string]any{ "name": t.Name, "description": t.Description, "stack": t.Stack, "files": t.Files, } } api.WriteSuccess(w, r, response) } // GetTemplate returns details about a specific template. // GET /templates/{name} func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } template, err := h.infraService.GetTemplate(ctx, name) if err != nil { if errors.Is(err, domain.ErrTemplateNotFound) { api.WriteNotFound(w, r, "template not found") return } log := logging.FromContext(ctx).WithHandler("GetTemplate") log.Error("failed to get template", logging.FieldError, err.Error(), "name", name) api.WriteInternalError(w, r, "failed to get template") return } api.WriteSuccess(w, r, map[string]any{ "name": template.Name, "description": template.Description, "stack": template.Stack, "files": template.Files, }) } // CleanupTestProjectsRequest is the request body for DELETE /projects/cleanup. type CleanupTestProjectsRequest struct { Patterns []string `json:"patterns"` // Name patterns to match (e.g., "tree-test-*", "landing-test-*") OlderThanHrs int `json:"older_than_hours"` // Only delete projects older than this many hours DryRun bool `json:"dry_run"` // If true, don't actually delete, just return what would be deleted } // CleanupTestProjects deletes test projects matching patterns that are older than a specified age. // DELETE /projects/cleanup // Admin scope required for safety. func (h *ProjectManagementHandler) CleanupTestProjects(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } var req CleanupTestProjectsRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if len(req.Patterns) == 0 { api.WriteBadRequest(w, r, "at least one pattern is required") return } if req.OlderThanHrs < 0 { api.WriteBadRequest(w, r, "older_than_hours must be non-negative") return } result, err := h.infraService.CleanupTestProjects(ctx, service.CleanupTestProjectsRequest{ Patterns: req.Patterns, OlderThanHrs: req.OlderThanHrs, DryRun: req.DryRun, }) if err != nil { log := logging.FromContext(ctx).WithHandler("CleanupTestProjects") log.Error("failed to cleanup test projects", logging.FieldError, err.Error()) api.WriteInternalError(w, r, "failed to cleanup test projects") return } api.WriteSuccess(w, r, map[string]any{ "deleted": result.Deleted, "count": result.Count, "dry_run": result.DryRun, }) } // ListComponentTemplates returns available component templates. // GET /templates/components func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } componentType := r.URL.Query().Get("type") components, err := h.infraService.ListComponentTemplates(ctx, componentType) if err != nil { log := logging.FromContext(ctx).WithHandler("ListComponentTemplates") log.Error("failed to list component templates", logging.FieldError, err.Error()) api.WriteInternalError(w, r, "failed to list component templates") return } response := make([]map[string]any, len(components)) for i, c := range components { response[i] = map[string]any{ "type": c.Type, "description": c.Description, "stack": c.Stack, "default_port": c.DefaultPort, "dest_dir": c.DestDir, "files": c.Files, } } api.WriteSuccess(w, r, map[string]any{ "components": response, }) }