package handlers import ( "bytes" "io" "net/http" "time" "github.com/breakpilot/ai-compliance-sdk/internal/rbac" "github.com/breakpilot/ai-compliance-sdk/internal/roadmap" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // RoadmapHandlers handles roadmap-related HTTP requests type RoadmapHandlers struct { store *roadmap.Store parser *roadmap.Parser } // NewRoadmapHandlers creates new roadmap handlers func NewRoadmapHandlers(store *roadmap.Store) *RoadmapHandlers { return &RoadmapHandlers{ store: store, parser: roadmap.NewParser(), } } // ============================================================================ // Roadmap CRUD // ============================================================================ // CreateRoadmap creates a new roadmap // POST /sdk/v1/roadmaps func (h *RoadmapHandlers) CreateRoadmap(c *gin.Context) { var req roadmap.CreateRoadmapRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tenantID := rbac.GetTenantID(c) userID := rbac.GetUserID(c) r := &roadmap.Roadmap{ TenantID: tenantID, Title: req.Title, Description: req.Description, AssessmentID: req.AssessmentID, PortfolioID: req.PortfolioID, StartDate: req.StartDate, TargetDate: req.TargetDate, Status: "draft", CreatedBy: userID, } if err := h.store.CreateRoadmap(c.Request.Context(), r); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, roadmap.CreateRoadmapResponse{Roadmap: *r}) } // ListRoadmaps lists roadmaps for the tenant // GET /sdk/v1/roadmaps func (h *RoadmapHandlers) ListRoadmaps(c *gin.Context) { tenantID := rbac.GetTenantID(c) filters := &roadmap.RoadmapFilters{ Status: c.Query("status"), Limit: 50, } if assessmentID := c.Query("assessment_id"); assessmentID != "" { if id, err := uuid.Parse(assessmentID); err == nil { filters.AssessmentID = &id } } roadmaps, err := h.store.ListRoadmaps(c.Request.Context(), tenantID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "roadmaps": roadmaps, "total": len(roadmaps), }) } // GetRoadmap retrieves a roadmap by ID // GET /sdk/v1/roadmaps/:id func (h *RoadmapHandlers) GetRoadmap(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } r, err := h.store.GetRoadmap(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if r == nil { c.JSON(http.StatusNotFound, gin.H{"error": "roadmap not found"}) return } // Get items items, err := h.store.ListItems(c.Request.Context(), id, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Get stats stats, _ := h.store.GetRoadmapStats(c.Request.Context(), id) c.JSON(http.StatusOK, gin.H{ "roadmap": r, "items": items, "stats": stats, }) } // UpdateRoadmap updates a roadmap // PUT /sdk/v1/roadmaps/:id func (h *RoadmapHandlers) UpdateRoadmap(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } r, err := h.store.GetRoadmap(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if r == nil { c.JSON(http.StatusNotFound, gin.H{"error": "roadmap not found"}) return } var req roadmap.CreateRoadmapRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } r.Title = req.Title r.Description = req.Description r.AssessmentID = req.AssessmentID r.PortfolioID = req.PortfolioID r.StartDate = req.StartDate r.TargetDate = req.TargetDate if err := h.store.UpdateRoadmap(c.Request.Context(), r); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"roadmap": r}) } // DeleteRoadmap deletes a roadmap // DELETE /sdk/v1/roadmaps/:id func (h *RoadmapHandlers) DeleteRoadmap(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } if err := h.store.DeleteRoadmap(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "roadmap deleted"}) } // GetRoadmapStats returns statistics for a roadmap // GET /sdk/v1/roadmaps/:id/stats func (h *RoadmapHandlers) GetRoadmapStats(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } stats, err := h.store.GetRoadmapStats(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, stats) } // ============================================================================ // RoadmapItem CRUD // ============================================================================ // CreateItem creates a new roadmap item // POST /sdk/v1/roadmaps/:id/items func (h *RoadmapHandlers) CreateItem(c *gin.Context) { roadmapID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roadmap ID"}) return } var input roadmap.RoadmapItemInput if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } item := &roadmap.RoadmapItem{ RoadmapID: roadmapID, Title: input.Title, Description: input.Description, Category: input.Category, Priority: input.Priority, Status: input.Status, ControlID: input.ControlID, RegulationRef: input.RegulationRef, GapID: input.GapID, EffortDays: input.EffortDays, AssigneeName: input.AssigneeName, Department: input.Department, PlannedStart: input.PlannedStart, PlannedEnd: input.PlannedEnd, Notes: input.Notes, } if err := h.store.CreateItem(c.Request.Context(), item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update roadmap progress h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID) c.JSON(http.StatusCreated, gin.H{"item": item}) } // ListItems lists items for a roadmap // GET /sdk/v1/roadmaps/:id/items func (h *RoadmapHandlers) ListItems(c *gin.Context) { roadmapID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roadmap ID"}) return } filters := &roadmap.RoadmapItemFilters{ SearchQuery: c.Query("search"), Limit: 100, } if status := c.Query("status"); status != "" { filters.Status = roadmap.ItemStatus(status) } if priority := c.Query("priority"); priority != "" { filters.Priority = roadmap.ItemPriority(priority) } if category := c.Query("category"); category != "" { filters.Category = roadmap.ItemCategory(category) } if controlID := c.Query("control_id"); controlID != "" { filters.ControlID = controlID } items, err := h.store.ListItems(c.Request.Context(), roadmapID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "items": items, "total": len(items), }) } // GetItem retrieves a roadmap item // GET /sdk/v1/roadmap-items/:id func (h *RoadmapHandlers) GetItem(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } item, err := h.store.GetItem(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil { c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) return } c.JSON(http.StatusOK, gin.H{"item": item}) } // UpdateItem updates a roadmap item // PUT /sdk/v1/roadmap-items/:id func (h *RoadmapHandlers) UpdateItem(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } item, err := h.store.GetItem(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil { c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) return } var input roadmap.RoadmapItemInput if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Update fields item.Title = input.Title item.Description = input.Description if input.Category != "" { item.Category = input.Category } if input.Priority != "" { item.Priority = input.Priority } if input.Status != "" { item.Status = input.Status } item.ControlID = input.ControlID item.RegulationRef = input.RegulationRef item.GapID = input.GapID item.EffortDays = input.EffortDays item.AssigneeName = input.AssigneeName item.Department = input.Department item.PlannedStart = input.PlannedStart item.PlannedEnd = input.PlannedEnd item.Notes = input.Notes if err := h.store.UpdateItem(c.Request.Context(), item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update roadmap progress h.store.UpdateRoadmapProgress(c.Request.Context(), item.RoadmapID) c.JSON(http.StatusOK, gin.H{"item": item}) } // UpdateItemStatus updates just the status of a roadmap item // PATCH /sdk/v1/roadmap-items/:id/status func (h *RoadmapHandlers) UpdateItemStatus(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } var req struct { Status roadmap.ItemStatus `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } item, err := h.store.GetItem(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil { c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) return } item.Status = req.Status // Set actual dates now := time.Now().UTC() if req.Status == roadmap.ItemStatusInProgress && item.ActualStart == nil { item.ActualStart = &now } if req.Status == roadmap.ItemStatusCompleted && item.ActualEnd == nil { item.ActualEnd = &now } if err := h.store.UpdateItem(c.Request.Context(), item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update roadmap progress h.store.UpdateRoadmapProgress(c.Request.Context(), item.RoadmapID) c.JSON(http.StatusOK, gin.H{"item": item}) } // DeleteItem deletes a roadmap item // DELETE /sdk/v1/roadmap-items/:id func (h *RoadmapHandlers) DeleteItem(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } item, err := h.store.GetItem(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil { c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) return } roadmapID := item.RoadmapID if err := h.store.DeleteItem(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update roadmap progress h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID) c.JSON(http.StatusOK, gin.H{"message": "item deleted"}) } // ============================================================================ // Import Workflow // ============================================================================ // UploadImport handles file upload for import // POST /sdk/v1/roadmaps/import/upload func (h *RoadmapHandlers) UploadImport(c *gin.Context) { tenantID := rbac.GetTenantID(c) userID := rbac.GetUserID(c) // Get file from form file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } defer file.Close() // Read file content buf := bytes.Buffer{} if _, err := io.Copy(&buf, file); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) return } // Detect format format := roadmap.ImportFormat("") filename := header.Filename contentType := header.Header.Get("Content-Type") // Create import job job := &roadmap.ImportJob{ TenantID: tenantID, Filename: filename, FileSize: header.Size, ContentType: contentType, Status: "pending", CreatedBy: userID, } if err := h.store.CreateImportJob(c.Request.Context(), job); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Parse the file job.Status = "parsing" h.store.UpdateImportJob(c.Request.Context(), job) result, err := h.parser.ParseFile(buf.Bytes(), filename, contentType) if err != nil { job.Status = "failed" job.ErrorMessage = err.Error() h.store.UpdateImportJob(c.Request.Context(), job) c.JSON(http.StatusBadRequest, gin.H{ "error": "failed to parse file", "detail": err.Error(), }) return } // Update job with parsed data job.Status = "parsed" job.Format = format job.TotalRows = result.TotalRows job.ValidRows = result.ValidRows job.InvalidRows = result.InvalidRows job.ParsedItems = result.Items h.store.UpdateImportJob(c.Request.Context(), job) c.JSON(http.StatusOK, roadmap.ImportParseResponse{ JobID: job.ID, Status: job.Status, TotalRows: result.TotalRows, ValidRows: result.ValidRows, InvalidRows: result.InvalidRows, Items: result.Items, ColumnMap: buildColumnMap(result.Columns), }) } // GetImportJob returns the status of an import job // GET /sdk/v1/roadmaps/import/:jobId func (h *RoadmapHandlers) GetImportJob(c *gin.Context) { jobID, err := uuid.Parse(c.Param("jobId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job ID"}) return } job, err := h.store.GetImportJob(c.Request.Context(), jobID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if job == nil { c.JSON(http.StatusNotFound, gin.H{"error": "import job not found"}) return } c.JSON(http.StatusOK, gin.H{ "job": job, "items": job.ParsedItems, }) } // ConfirmImport confirms and executes the import // POST /sdk/v1/roadmaps/import/:jobId/confirm func (h *RoadmapHandlers) ConfirmImport(c *gin.Context) { jobID, err := uuid.Parse(c.Param("jobId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job ID"}) return } var req roadmap.ImportConfirmRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } job, err := h.store.GetImportJob(c.Request.Context(), jobID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if job == nil { c.JSON(http.StatusNotFound, gin.H{"error": "import job not found"}) return } if job.Status != "parsed" { c.JSON(http.StatusBadRequest, gin.H{ "error": "job is not ready for confirmation", "status": job.Status, }) return } tenantID := rbac.GetTenantID(c) userID := rbac.GetUserID(c) // Create or use existing roadmap var roadmapID uuid.UUID if req.RoadmapID != nil { roadmapID = *req.RoadmapID // Verify roadmap exists r, err := h.store.GetRoadmap(c.Request.Context(), roadmapID) if err != nil || r == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "roadmap not found"}) return } } else { // Create new roadmap title := req.RoadmapTitle if title == "" { title = "Imported Roadmap - " + job.Filename } r := &roadmap.Roadmap{ TenantID: tenantID, Title: title, Status: "active", CreatedBy: userID, } if err := h.store.CreateRoadmap(c.Request.Context(), r); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } roadmapID = r.ID } // Determine which rows to import selectedRows := make(map[int]bool) if len(req.SelectedRows) > 0 { for _, row := range req.SelectedRows { selectedRows[row] = true } } // Convert parsed items to roadmap items var items []roadmap.RoadmapItem var importedCount, skippedCount int for i, parsed := range job.ParsedItems { // Skip invalid items if !parsed.IsValid { skippedCount++ continue } // Skip unselected rows if selection was specified if len(selectedRows) > 0 && !selectedRows[parsed.RowNumber] { skippedCount++ continue } item := roadmap.RoadmapItem{ RoadmapID: roadmapID, Title: parsed.Data.Title, Description: parsed.Data.Description, Category: parsed.Data.Category, Priority: parsed.Data.Priority, Status: parsed.Data.Status, ControlID: parsed.Data.ControlID, RegulationRef: parsed.Data.RegulationRef, GapID: parsed.Data.GapID, EffortDays: parsed.Data.EffortDays, AssigneeName: parsed.Data.AssigneeName, Department: parsed.Data.Department, PlannedStart: parsed.Data.PlannedStart, PlannedEnd: parsed.Data.PlannedEnd, Notes: parsed.Data.Notes, SourceRow: parsed.RowNumber, SourceFile: job.Filename, SortOrder: i, } // Apply auto-mappings if requested if req.ApplyMappings { if parsed.MatchedControl != "" { item.ControlID = parsed.MatchedControl } if parsed.MatchedRegulation != "" { item.RegulationRef = parsed.MatchedRegulation } if parsed.MatchedGap != "" { item.GapID = parsed.MatchedGap } } // Set defaults if item.Status == "" { item.Status = roadmap.ItemStatusPlanned } if item.Priority == "" { item.Priority = roadmap.ItemPriorityMedium } if item.Category == "" { item.Category = roadmap.ItemCategoryTechnical } items = append(items, item) importedCount++ } // Bulk create items if len(items) > 0 { if err := h.store.BulkCreateItems(c.Request.Context(), items); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } // Update roadmap progress h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID) // Update job status now := time.Now().UTC() job.Status = "completed" job.RoadmapID = &roadmapID job.ImportedItems = importedCount job.CompletedAt = &now h.store.UpdateImportJob(c.Request.Context(), job) c.JSON(http.StatusOK, roadmap.ImportConfirmResponse{ RoadmapID: roadmapID, ImportedItems: importedCount, SkippedItems: skippedCount, Message: "Import completed successfully", }) } // ============================================================================ // Helper Functions // ============================================================================ func buildColumnMap(columns []roadmap.DetectedColumn) map[string]string { result := make(map[string]string) for _, col := range columns { if col.MappedTo != "" { result[col.Header] = col.MappedTo } } return result }