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>
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/roadmap"
|
||||
@@ -200,541 +197,3 @@ func (h *RoadmapHandlers) GetRoadmapStats(c *gin.Context) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
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
|
||||
}
|
||||
258
ai-compliance-sdk/internal/api/handlers/roadmap_item_handlers.go
Normal file
258
ai-compliance-sdk/internal/api/handlers/roadmap_item_handlers.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/roadmap"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 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"})
|
||||
}
|
||||
Reference in New Issue
Block a user