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>
304 lines
7.6 KiB
Go
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
|
|
}
|