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:
Sharang Parnerkar
2026-04-19 09:51:11 +02:00
parent 3fb5b94905
commit 3f2aff2389
8 changed files with 1482 additions and 1557 deletions

View File

@@ -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
}

View File

@@ -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
}

View 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"})
}