Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/roadmap_import_handlers.go
Sharang Parnerkar 3f2aff2389 refactor(go): split roadmap_handlers, academy/store, extract cmd/server/main to internal/app
roadmap_handlers.go (740 LOC) → roadmap_handlers.go, roadmap_item_handlers.go, roadmap_import_handlers.go
academy/store.go (683 LOC) → store_courses.go, store_enrollments.go
cmd/server/main.go (681 LOC) → internal/app/app.go (Run+buildRouter) + internal/app/routes.go (registerXxx helpers)
main.go reduced to 7 LOC thin entrypoint calling app.Run()

All files under 410 LOC. Zero behavior changes, same package declarations.
go vet passes on all directly-split packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 09:51:11 +02:00

304 lines
7.6 KiB
Go

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"
)
// ============================================================================
// 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
}