refactor(go/handlers): split iace_handler and training_handlers into focused files
iace_handler.go (2706 LOC) split into 9 files: - iace_handler.go: struct, constructor, shared helpers (~156 LOC) - iace_handler_projects.go: project CRUD + InitFromProfile (~310 LOC) - iace_handler_components.go: components + classification (~387 LOC) - iace_handler_hazards.go: hazard library, CRUD, risk assessment (~469 LOC) - iace_handler_mitigations.go: mitigations, evidence, verification plans (~293 LOC) - iace_handler_techfile.go: CE tech file generation/export (~452 LOC) - iace_handler_monitoring.go: monitoring events + audit trail (~134 LOC) - iace_handler_refdata.go: ISO 12100 ref data, patterns, suggestions (~465 LOC) - iace_handler_rag.go: RAG library search + section enrichment (~142 LOC) training_handlers.go (1864 LOC) split into 9 files: - training_handlers.go: struct + constructor (~23 LOC) - training_handlers_modules.go: module CRUD (~226 LOC) - training_handlers_matrix.go: CTM matrix endpoints (~95 LOC) - training_handlers_assignments.go: assignment lifecycle (~243 LOC) - training_handlers_quiz.go: quiz submit/grade/attempts (~185 LOC) - training_handlers_content.go: LLM content/audio/video generation (~274 LOC) - training_handlers_media.go: media, streaming, interactive video (~325 LOC) - training_handlers_blocks.go: block configs + canonical controls (~280 LOC) - training_handlers_stats.go: deadlines, escalation, audit, certificates (~290 LOC) All files remain in package handlers. Zero behavior changes. All exported function names preserved. All files under 500 LOC hard cap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateComponent handles POST /projects/:id/components
|
||||||
|
// Adds a new component to a project.
|
||||||
|
func (h *IACEHandler) CreateComponent(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.CreateComponentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override project ID from URL path
|
||||||
|
req.ProjectID = projectID
|
||||||
|
|
||||||
|
component, err := h.store.CreateComponent(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
newVals, _ := json.Marshal(component)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "component", component.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"component": component})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComponents handles GET /projects/:id/components
|
||||||
|
// Lists all components for a project.
|
||||||
|
func (h *IACEHandler) ListComponents(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
components, err := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if components == nil {
|
||||||
|
components = []iace.Component{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"components": components,
|
||||||
|
"total": len(components),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateComponent handles PUT /projects/:id/components/:cid
|
||||||
|
// Updates a component with the provided fields.
|
||||||
|
func (h *IACEHandler) UpdateComponent(c *gin.Context) {
|
||||||
|
_, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
componentID, err := uuid.Parse(c.Param("cid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
component, err := h.store.UpdateComponent(c.Request.Context(), componentID, updates)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if component == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"component": component})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComponent handles DELETE /projects/:id/components/:cid
|
||||||
|
// Deletes a component from a project.
|
||||||
|
func (h *IACEHandler) DeleteComponent(c *gin.Context) {
|
||||||
|
_, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
componentID, err := uuid.Parse(c.Param("cid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.DeleteComponent(c.Request.Context(), componentID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "component deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCompleteness handles POST /projects/:id/completeness-check
|
||||||
|
// Loads all project data, evaluates all 25 CE completeness gates, updates the
|
||||||
|
// project's completeness score, and returns the result.
|
||||||
|
func (h *IACEHandler) CheckCompleteness(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all related entities
|
||||||
|
components, _ := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
classifications, _ := h.store.GetClassifications(c.Request.Context(), projectID)
|
||||||
|
hazards, _ := h.store.ListHazards(c.Request.Context(), projectID)
|
||||||
|
|
||||||
|
// Collect all assessments and mitigations across all hazards
|
||||||
|
var allAssessments []iace.RiskAssessment
|
||||||
|
var allMitigations []iace.Mitigation
|
||||||
|
for _, hazard := range hazards {
|
||||||
|
assessments, _ := h.store.ListAssessments(c.Request.Context(), hazard.ID)
|
||||||
|
allAssessments = append(allAssessments, assessments...)
|
||||||
|
|
||||||
|
mitigations, _ := h.store.ListMitigations(c.Request.Context(), hazard.ID)
|
||||||
|
allMitigations = append(allMitigations, mitigations...)
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence, _ := h.store.ListEvidence(c.Request.Context(), projectID)
|
||||||
|
techFileSections, _ := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
|
||||||
|
// Determine if the project has AI components
|
||||||
|
hasAI := false
|
||||||
|
for _, comp := range components {
|
||||||
|
if comp.ComponentType == iace.ComponentTypeAIModel {
|
||||||
|
hasAI = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check audit trail for pattern matching
|
||||||
|
patternMatchingPerformed, _ := h.store.HasAuditEntryForType(c.Request.Context(), projectID, "pattern_matching")
|
||||||
|
|
||||||
|
// Build completeness context
|
||||||
|
completenessCtx := &iace.CompletenessContext{
|
||||||
|
Project: project,
|
||||||
|
Components: components,
|
||||||
|
Classifications: classifications,
|
||||||
|
Hazards: hazards,
|
||||||
|
Assessments: allAssessments,
|
||||||
|
Mitigations: allMitigations,
|
||||||
|
Evidence: evidence,
|
||||||
|
TechFileSections: techFileSections,
|
||||||
|
HasAI: hasAI,
|
||||||
|
PatternMatchingPerformed: patternMatchingPerformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the checker
|
||||||
|
result := h.checker.Check(completenessCtx)
|
||||||
|
|
||||||
|
// Build risk summary for the project update
|
||||||
|
riskSummary := map[string]int{
|
||||||
|
"total_hazards": len(hazards),
|
||||||
|
}
|
||||||
|
for _, a := range allAssessments {
|
||||||
|
riskSummary[string(a.RiskLevel)]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project completeness score and risk summary
|
||||||
|
if err := h.store.UpdateProjectCompleteness(
|
||||||
|
c.Request.Context(), projectID, result.Score, riskSummary,
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"completeness": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Classification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Classify handles POST /projects/:id/classify
|
||||||
|
// Runs all regulatory classifiers (AI Act, Machinery Regulation, CRA, NIS2),
|
||||||
|
// upserts each result into the store, and returns classifications.
|
||||||
|
func (h *IACEHandler) Classify(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
components, err := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all classifiers
|
||||||
|
results := h.classifier.ClassifyAll(project, components)
|
||||||
|
|
||||||
|
// Upsert each classification result into the store
|
||||||
|
var classifications []iace.RegulatoryClassification
|
||||||
|
for _, r := range results {
|
||||||
|
reqsJSON, _ := json.Marshal(r.Requirements)
|
||||||
|
|
||||||
|
classification, err := h.store.UpsertClassification(
|
||||||
|
c.Request.Context(),
|
||||||
|
projectID,
|
||||||
|
r.Regulation,
|
||||||
|
r.ClassificationResult,
|
||||||
|
r.RiskLevel,
|
||||||
|
r.Confidence,
|
||||||
|
r.Reasoning,
|
||||||
|
nil, // ragSources - not available from rule-based classifier
|
||||||
|
reqsJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if classification != nil {
|
||||||
|
classifications = append(classifications, *classification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance project status to classification
|
||||||
|
h.store.UpdateProjectStatus(c.Request.Context(), projectID, iace.ProjectStatusClassification)
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
newVals, _ := json.Marshal(classifications)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "classification", projectID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"classifications": classifications,
|
||||||
|
"total": len(classifications),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassifications handles GET /projects/:id/classifications
|
||||||
|
// Returns all regulatory classifications for a project.
|
||||||
|
func (h *IACEHandler) GetClassifications(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classifications, err := h.store.GetClassifications(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if classifications == nil {
|
||||||
|
classifications = []iace.RegulatoryClassification{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"classifications": classifications,
|
||||||
|
"total": len(classifications),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifySingle handles POST /projects/:id/classify/:regulation
|
||||||
|
// Runs a single regulatory classifier for the specified regulation type.
|
||||||
|
func (h *IACEHandler) ClassifySingle(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
regulation := iace.RegulationType(c.Param("regulation"))
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
components, err := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the appropriate classifier
|
||||||
|
var result iace.ClassificationResult
|
||||||
|
switch regulation {
|
||||||
|
case iace.RegulationAIAct:
|
||||||
|
result = h.classifier.ClassifyAIAct(project, components)
|
||||||
|
case iace.RegulationMachineryRegulation:
|
||||||
|
result = h.classifier.ClassifyMachineryRegulation(project, components)
|
||||||
|
case iace.RegulationCRA:
|
||||||
|
result = h.classifier.ClassifyCRA(project, components)
|
||||||
|
case iace.RegulationNIS2:
|
||||||
|
result = h.classifier.ClassifyNIS2(project, components)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown regulation type: %s", regulation)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the classification result
|
||||||
|
reqsJSON, _ := json.Marshal(result.Requirements)
|
||||||
|
|
||||||
|
classification, err := h.store.UpsertClassification(
|
||||||
|
c.Request.Context(),
|
||||||
|
projectID,
|
||||||
|
result.Regulation,
|
||||||
|
result.ClassificationResult,
|
||||||
|
result.RiskLevel,
|
||||||
|
result.Confidence,
|
||||||
|
result.Reasoning,
|
||||||
|
nil,
|
||||||
|
reqsJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"classification": classification})
|
||||||
|
}
|
||||||
469
ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go
Normal file
469
ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hazard Library & Controls Library
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListHazardLibrary handles GET /hazard-library
|
||||||
|
// Returns built-in hazard library entries merged with any custom DB entries,
|
||||||
|
// optionally filtered by ?category and ?componentType.
|
||||||
|
func (h *IACEHandler) ListHazardLibrary(c *gin.Context) {
|
||||||
|
category := c.Query("category")
|
||||||
|
componentType := c.Query("componentType")
|
||||||
|
|
||||||
|
// Start with built-in templates from Go code
|
||||||
|
builtinEntries := iace.GetBuiltinHazardLibrary()
|
||||||
|
|
||||||
|
// Apply filters to built-in entries
|
||||||
|
var entries []iace.HazardLibraryEntry
|
||||||
|
for _, entry := range builtinEntries {
|
||||||
|
if category != "" && entry.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if componentType != "" && !containsString(entry.ApplicableComponentTypes, componentType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with custom DB entries (tenant-specific)
|
||||||
|
dbEntries, err := h.store.ListHazardLibrary(c.Request.Context(), category, componentType)
|
||||||
|
if err == nil && len(dbEntries) > 0 {
|
||||||
|
// Add DB entries that are not built-in (avoid duplicates)
|
||||||
|
builtinIDs := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
builtinIDs[e.ID.String()] = true
|
||||||
|
}
|
||||||
|
for _, dbEntry := range dbEntries {
|
||||||
|
if !builtinIDs[dbEntry.ID.String()] {
|
||||||
|
entries = append(entries, dbEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries == nil {
|
||||||
|
entries = []iace.HazardLibraryEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"hazard_library": entries,
|
||||||
|
"total": len(entries),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListControlsLibrary handles GET /controls-library
|
||||||
|
// Returns the built-in controls library, optionally filtered by ?domain and ?category.
|
||||||
|
func (h *IACEHandler) ListControlsLibrary(c *gin.Context) {
|
||||||
|
domain := c.Query("domain")
|
||||||
|
category := c.Query("category")
|
||||||
|
|
||||||
|
all := iace.GetControlsLibrary()
|
||||||
|
|
||||||
|
var filtered []iace.ControlLibraryEntry
|
||||||
|
for _, entry := range all {
|
||||||
|
if domain != "" && entry.Domain != domain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if category != "" && !containsString(entry.MapsToHazardCategories, category) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered == nil {
|
||||||
|
filtered = []iace.ControlLibraryEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"controls": filtered,
|
||||||
|
"total": len(filtered),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hazard CRUD
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateHazard handles POST /projects/:id/hazards
|
||||||
|
// Creates a new hazard within a project.
|
||||||
|
func (h *IACEHandler) CreateHazard(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.CreateHazardRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override project ID from URL path
|
||||||
|
req.ProjectID = projectID
|
||||||
|
|
||||||
|
hazard, err := h.store.CreateHazard(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
newVals, _ := json.Marshal(hazard)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "hazard", hazard.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"hazard": hazard})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHazards handles GET /projects/:id/hazards
|
||||||
|
// Lists all hazards for a project.
|
||||||
|
func (h *IACEHandler) ListHazards(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazards, err := h.store.ListHazards(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hazards == nil {
|
||||||
|
hazards = []iace.Hazard{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"hazards": hazards,
|
||||||
|
"total": len(hazards),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateHazard handles PUT /projects/:id/hazards/:hid
|
||||||
|
// Updates a hazard with the provided fields.
|
||||||
|
func (h *IACEHandler) UpdateHazard(c *gin.Context) {
|
||||||
|
_, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazardID, err := uuid.Parse(c.Param("hid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazard, err := h.store.UpdateHazard(c.Request.Context(), hazardID, updates)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hazard == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"hazard": hazard})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestHazards handles POST /projects/:id/hazards/suggest
|
||||||
|
// Returns hazard library matches based on the project's components.
|
||||||
|
// TODO: Enhance with LLM-based suggestions for more intelligent matching.
|
||||||
|
func (h *IACEHandler) SuggestHazards(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
components, err := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique component types from the project
|
||||||
|
componentTypes := make(map[string]bool)
|
||||||
|
for _, comp := range components {
|
||||||
|
componentTypes[string(comp.ComponentType)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match built-in hazard templates against project component types
|
||||||
|
var suggestions []iace.HazardLibraryEntry
|
||||||
|
seen := make(map[uuid.UUID]bool)
|
||||||
|
|
||||||
|
builtinEntries := iace.GetBuiltinHazardLibrary()
|
||||||
|
for _, entry := range builtinEntries {
|
||||||
|
for _, applicableType := range entry.ApplicableComponentTypes {
|
||||||
|
if componentTypes[applicableType] && !seen[entry.ID] {
|
||||||
|
seen[entry.ID] = true
|
||||||
|
suggestions = append(suggestions, entry)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check DB for custom tenant-specific hazard templates
|
||||||
|
for compType := range componentTypes {
|
||||||
|
dbEntries, err := h.store.ListHazardLibrary(c.Request.Context(), "", compType)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range dbEntries {
|
||||||
|
if !seen[entry.ID] {
|
||||||
|
seen[entry.ID] = true
|
||||||
|
suggestions = append(suggestions, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if suggestions == nil {
|
||||||
|
suggestions = []iace.HazardLibraryEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"total": len(suggestions),
|
||||||
|
"component_types": componentTypeKeys(componentTypes),
|
||||||
|
"_note": "TODO: LLM-based suggestion ranking not yet implemented",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Risk Assessment
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// AssessRisk handles POST /projects/:id/hazards/:hid/assess
|
||||||
|
// Performs a quantitative risk assessment for a hazard using the IACE risk engine.
|
||||||
|
func (h *IACEHandler) AssessRisk(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazardID, err := uuid.Parse(c.Param("hid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hazard exists
|
||||||
|
hazard, err := h.store.GetHazard(c.Request.Context(), hazardID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hazard == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.AssessRiskRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override hazard ID from URL path
|
||||||
|
req.HazardID = hazardID
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
// Calculate risk using the engine
|
||||||
|
inherentRisk := h.engine.CalculateInherentRisk(req.Severity, req.Exposure, req.Probability, req.Avoidance)
|
||||||
|
controlEff := h.engine.CalculateControlEffectiveness(req.ControlMaturity, req.ControlCoverage, req.TestEvidenceStrength)
|
||||||
|
residualRisk := h.engine.CalculateResidualRisk(req.Severity, req.Exposure, req.Probability, controlEff)
|
||||||
|
|
||||||
|
// ISO 12100 mode: use ISO thresholds when avoidance is set
|
||||||
|
var riskLevel iace.RiskLevel
|
||||||
|
if req.Avoidance >= 1 {
|
||||||
|
riskLevel = h.engine.DetermineRiskLevelISO(inherentRisk)
|
||||||
|
} else {
|
||||||
|
riskLevel = h.engine.DetermineRiskLevel(residualRisk)
|
||||||
|
}
|
||||||
|
acceptable, acceptanceReason := h.engine.IsAcceptable(residualRisk, false, req.AcceptanceJustification != "")
|
||||||
|
|
||||||
|
// Determine version by checking existing assessments
|
||||||
|
existingAssessments, _ := h.store.ListAssessments(c.Request.Context(), hazardID)
|
||||||
|
version := len(existingAssessments) + 1
|
||||||
|
|
||||||
|
assessment := &iace.RiskAssessment{
|
||||||
|
HazardID: hazardID,
|
||||||
|
Version: version,
|
||||||
|
AssessmentType: iace.AssessmentTypeInitial,
|
||||||
|
Severity: req.Severity,
|
||||||
|
Exposure: req.Exposure,
|
||||||
|
Probability: req.Probability,
|
||||||
|
Avoidance: req.Avoidance,
|
||||||
|
InherentRisk: inherentRisk,
|
||||||
|
ControlMaturity: req.ControlMaturity,
|
||||||
|
ControlCoverage: req.ControlCoverage,
|
||||||
|
TestEvidenceStrength: req.TestEvidenceStrength,
|
||||||
|
CEff: controlEff,
|
||||||
|
ResidualRisk: residualRisk,
|
||||||
|
RiskLevel: riskLevel,
|
||||||
|
IsAcceptable: acceptable,
|
||||||
|
AcceptanceJustification: req.AcceptanceJustification,
|
||||||
|
AssessedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateRiskAssessment(c.Request.Context(), assessment); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hazard status
|
||||||
|
h.store.UpdateHazard(c.Request.Context(), hazardID, map[string]interface{}{
|
||||||
|
"status": string(iace.HazardStatusAssessed),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
newVals, _ := json.Marshal(assessment)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "risk_assessment", assessment.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"assessment": assessment,
|
||||||
|
"acceptable": acceptable,
|
||||||
|
"acceptance_reason": acceptanceReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRiskSummary handles GET /projects/:id/risk-summary
|
||||||
|
// Returns an aggregated risk overview for a project.
|
||||||
|
func (h *IACEHandler) GetRiskSummary(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.store.GetRiskSummary(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"risk_summary": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReassessRisk handles POST /projects/:id/hazards/:hid/reassess
|
||||||
|
// Creates a post-mitigation risk reassessment for a hazard.
|
||||||
|
func (h *IACEHandler) ReassessRisk(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazardID, err := uuid.Parse(c.Param("hid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hazard exists
|
||||||
|
hazard, err := h.store.GetHazard(c.Request.Context(), hazardID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hazard == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.AssessRiskRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
// Calculate risk using the engine
|
||||||
|
inherentRisk := h.engine.CalculateInherentRisk(req.Severity, req.Exposure, req.Probability, req.Avoidance)
|
||||||
|
controlEff := h.engine.CalculateControlEffectiveness(req.ControlMaturity, req.ControlCoverage, req.TestEvidenceStrength)
|
||||||
|
residualRisk := h.engine.CalculateResidualRisk(req.Severity, req.Exposure, req.Probability, controlEff)
|
||||||
|
riskLevel := h.engine.DetermineRiskLevel(residualRisk)
|
||||||
|
|
||||||
|
// For reassessment, check if all reduction steps have been applied
|
||||||
|
mitigations, _ := h.store.ListMitigations(c.Request.Context(), hazardID)
|
||||||
|
allReductionStepsApplied := len(mitigations) > 0
|
||||||
|
for _, m := range mitigations {
|
||||||
|
if m.Status != iace.MitigationStatusVerified {
|
||||||
|
allReductionStepsApplied = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptable, acceptanceReason := h.engine.IsAcceptable(residualRisk, allReductionStepsApplied, req.AcceptanceJustification != "")
|
||||||
|
|
||||||
|
// Determine version
|
||||||
|
existingAssessments, _ := h.store.ListAssessments(c.Request.Context(), hazardID)
|
||||||
|
version := len(existingAssessments) + 1
|
||||||
|
|
||||||
|
assessment := &iace.RiskAssessment{
|
||||||
|
HazardID: hazardID,
|
||||||
|
Version: version,
|
||||||
|
AssessmentType: iace.AssessmentTypePostMitigation,
|
||||||
|
Severity: req.Severity,
|
||||||
|
Exposure: req.Exposure,
|
||||||
|
Probability: req.Probability,
|
||||||
|
Avoidance: req.Avoidance,
|
||||||
|
InherentRisk: inherentRisk,
|
||||||
|
ControlMaturity: req.ControlMaturity,
|
||||||
|
ControlCoverage: req.ControlCoverage,
|
||||||
|
TestEvidenceStrength: req.TestEvidenceStrength,
|
||||||
|
CEff: controlEff,
|
||||||
|
ResidualRisk: residualRisk,
|
||||||
|
RiskLevel: riskLevel,
|
||||||
|
IsAcceptable: acceptable,
|
||||||
|
AcceptanceJustification: req.AcceptanceJustification,
|
||||||
|
AssessedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateRiskAssessment(c.Request.Context(), assessment); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
newVals, _ := json.Marshal(assessment)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "risk_assessment", assessment.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"assessment": assessment,
|
||||||
|
"acceptable": acceptable,
|
||||||
|
"acceptance_reason": acceptanceReason,
|
||||||
|
"all_reduction_steps_applied": allReductionStepsApplied,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mitigations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateMitigation handles POST /projects/:id/hazards/:hid/mitigations
|
||||||
|
// Creates a new mitigation measure for a hazard.
|
||||||
|
func (h *IACEHandler) CreateMitigation(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazardID, err := uuid.Parse(c.Param("hid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.CreateMitigationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override hazard ID from URL path
|
||||||
|
req.HazardID = hazardID
|
||||||
|
|
||||||
|
mitigation, err := h.store.CreateMitigation(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hazard status to mitigated
|
||||||
|
h.store.UpdateHazard(c.Request.Context(), hazardID, map[string]interface{}{
|
||||||
|
"status": string(iace.HazardStatusMitigated),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
newVals, _ := json.Marshal(mitigation)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "mitigation", mitigation.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"mitigation": mitigation})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMitigation handles PUT /mitigations/:mid
|
||||||
|
// Updates a mitigation measure with the provided fields.
|
||||||
|
func (h *IACEHandler) UpdateMitigation(c *gin.Context) {
|
||||||
|
mitigationID, err := uuid.Parse(c.Param("mid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mitigation ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mitigation, err := h.store.UpdateMitigation(c.Request.Context(), mitigationID, updates)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mitigation == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "mitigation not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"mitigation": mitigation})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMitigation handles POST /mitigations/:mid/verify
|
||||||
|
// Marks a mitigation as verified with a verification result.
|
||||||
|
func (h *IACEHandler) VerifyMitigation(c *gin.Context) {
|
||||||
|
mitigationID, err := uuid.Parse(c.Param("mid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mitigation ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
VerificationResult string `json:"verification_result" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
if err := h.store.VerifyMitigation(
|
||||||
|
c.Request.Context(), mitigationID, req.VerificationResult, userID.String(),
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "mitigation verified"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Evidence & Verification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// UploadEvidence handles POST /projects/:id/evidence
|
||||||
|
// Creates a new evidence record for a project.
|
||||||
|
func (h *IACEHandler) UploadEvidence(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
MitigationID *uuid.UUID `json:"mitigation_id,omitempty"`
|
||||||
|
VerificationPlanID *uuid.UUID `json:"verification_plan_id,omitempty"`
|
||||||
|
FileName string `json:"file_name" binding:"required"`
|
||||||
|
FilePath string `json:"file_path" binding:"required"`
|
||||||
|
FileHash string `json:"file_hash" binding:"required"`
|
||||||
|
FileSize int64 `json:"file_size" binding:"required"`
|
||||||
|
MimeType string `json:"mime_type" binding:"required"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
evidence := &iace.Evidence{
|
||||||
|
ProjectID: projectID,
|
||||||
|
MitigationID: req.MitigationID,
|
||||||
|
VerificationPlanID: req.VerificationPlanID,
|
||||||
|
FileName: req.FileName,
|
||||||
|
FilePath: req.FilePath,
|
||||||
|
FileHash: req.FileHash,
|
||||||
|
FileSize: req.FileSize,
|
||||||
|
MimeType: req.MimeType,
|
||||||
|
Description: req.Description,
|
||||||
|
UploadedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateEvidence(c.Request.Context(), evidence); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
newVals, _ := json.Marshal(evidence)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "evidence", evidence.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"evidence": evidence})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEvidence handles GET /projects/:id/evidence
|
||||||
|
// Lists all evidence records for a project.
|
||||||
|
func (h *IACEHandler) ListEvidence(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence, err := h.store.ListEvidence(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if evidence == nil {
|
||||||
|
evidence = []iace.Evidence{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"evidence": evidence,
|
||||||
|
"total": len(evidence),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVerificationPlan handles POST /projects/:id/verification-plan
|
||||||
|
// Creates a new verification plan for a project.
|
||||||
|
func (h *IACEHandler) CreateVerificationPlan(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.CreateVerificationPlanRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override project ID from URL path
|
||||||
|
req.ProjectID = projectID
|
||||||
|
|
||||||
|
plan, err := h.store.CreateVerificationPlan(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
newVals, _ := json.Marshal(plan)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "verification_plan", plan.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"verification_plan": plan})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVerificationPlan handles PUT /verification-plan/:vid
|
||||||
|
// Updates a verification plan with the provided fields.
|
||||||
|
func (h *IACEHandler) UpdateVerificationPlan(c *gin.Context) {
|
||||||
|
planID, err := uuid.Parse(c.Param("vid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification plan ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := h.store.UpdateVerificationPlan(c.Request.Context(), planID, updates)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if plan == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "verification plan not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"verification_plan": plan})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteVerification handles POST /verification-plan/:vid/complete
|
||||||
|
// Marks a verification plan as completed with a result.
|
||||||
|
func (h *IACEHandler) CompleteVerification(c *gin.Context) {
|
||||||
|
planID, err := uuid.Parse(c.Param("vid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification plan ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Result string `json:"result" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
if err := h.store.CompleteVerification(
|
||||||
|
c.Request.Context(), planID, req.Result, userID.String(),
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "verification completed"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Monitoring
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateMonitoringEvent handles POST /projects/:id/monitoring
|
||||||
|
// Creates a new post-market monitoring event.
|
||||||
|
func (h *IACEHandler) CreateMonitoringEvent(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.CreateMonitoringEventRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override project ID from URL path
|
||||||
|
req.ProjectID = projectID
|
||||||
|
|
||||||
|
event, err := h.store.CreateMonitoringEvent(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
newVals, _ := json.Marshal(event)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "monitoring_event", event.ID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, newVals,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"monitoring_event": event})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMonitoringEvents handles GET /projects/:id/monitoring
|
||||||
|
// Lists all monitoring events for a project.
|
||||||
|
func (h *IACEHandler) ListMonitoringEvents(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := h.store.ListMonitoringEvents(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if events == nil {
|
||||||
|
events = []iace.MonitoringEvent{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"monitoring_events": events,
|
||||||
|
"total": len(events),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMonitoringEvent handles PUT /projects/:id/monitoring/:eid
|
||||||
|
// Updates a monitoring event with the provided fields.
|
||||||
|
func (h *IACEHandler) UpdateMonitoringEvent(c *gin.Context) {
|
||||||
|
_, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventID, err := uuid.Parse(c.Param("eid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid monitoring event ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := h.store.UpdateMonitoringEvent(c.Request.Context(), eventID, updates)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "monitoring event not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"monitoring_event": event})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditTrail handles GET /projects/:id/audit-trail
|
||||||
|
// Returns all audit trail entries for a project, newest first.
|
||||||
|
func (h *IACEHandler) GetAuditTrail(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := h.store.ListAuditTrail(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries == nil {
|
||||||
|
entries = []iace.AuditTrailEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"audit_trail": entries,
|
||||||
|
"total": len(entries),
|
||||||
|
})
|
||||||
|
}
|
||||||
310
ai-compliance-sdk/internal/api/handlers/iace_handler_projects.go
Normal file
310
ai-compliance-sdk/internal/api/handlers/iace_handler_projects.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateProject handles POST /projects
|
||||||
|
// Creates a new IACE compliance project for a machine or system.
|
||||||
|
func (h *IACEHandler) CreateProject(c *gin.Context) {
|
||||||
|
tenantID, err := getTenantID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.CreateProjectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.CreateProject(c.Request.Context(), tenantID, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"project": project})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjects handles GET /projects
|
||||||
|
// Lists all IACE projects for the authenticated tenant.
|
||||||
|
func (h *IACEHandler) ListProjects(c *gin.Context) {
|
||||||
|
tenantID, err := getTenantID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := h.store.ListProjects(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if projects == nil {
|
||||||
|
projects = []iace.Project{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, iace.ProjectListResponse{
|
||||||
|
Projects: projects,
|
||||||
|
Total: len(projects),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProject handles GET /projects/:id
|
||||||
|
// Returns a project with its components, classifications, and completeness gates.
|
||||||
|
func (h *IACEHandler) GetProject(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
components, _ := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
classifications, _ := h.store.GetClassifications(c.Request.Context(), projectID)
|
||||||
|
|
||||||
|
if components == nil {
|
||||||
|
components = []iace.Component{}
|
||||||
|
}
|
||||||
|
if classifications == nil {
|
||||||
|
classifications = []iace.RegulatoryClassification{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build completeness context to compute gates
|
||||||
|
ctx := h.buildCompletenessContext(c, project, components, classifications)
|
||||||
|
result := h.checker.Check(ctx)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, iace.ProjectDetailResponse{
|
||||||
|
Project: *project,
|
||||||
|
Components: components,
|
||||||
|
Classifications: classifications,
|
||||||
|
CompletenessGates: result.Gates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProject handles PUT /projects/:id
|
||||||
|
// Partially updates a project's mutable fields.
|
||||||
|
func (h *IACEHandler) UpdateProject(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.UpdateProjectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.UpdateProject(c.Request.Context(), projectID, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"project": project})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveProject handles DELETE /projects/:id
|
||||||
|
// Archives a project by setting its status to archived.
|
||||||
|
func (h *IACEHandler) ArchiveProject(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.ArchiveProject(c.Request.Context(), projectID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "project archived"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Onboarding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// InitFromProfile handles POST /projects/:id/init-from-profile
|
||||||
|
// Initializes a project from a company profile and compliance scope JSON payload.
|
||||||
|
func (h *IACEHandler) InitFromProfile(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.InitFromProfileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse compliance_scope to extract machine data
|
||||||
|
var scope struct {
|
||||||
|
MachineName string `json:"machine_name"`
|
||||||
|
MachineType string `json:"machine_type"`
|
||||||
|
IntendedUse string `json:"intended_use"`
|
||||||
|
HasSoftware bool `json:"has_software"`
|
||||||
|
HasFirmware bool `json:"has_firmware"`
|
||||||
|
HasAI bool `json:"has_ai"`
|
||||||
|
IsNetworked bool `json:"is_networked"`
|
||||||
|
ApplicableRegulations []string `json:"applicable_regulations"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(req.ComplianceScope, &scope)
|
||||||
|
|
||||||
|
// Parse company_profile to extract manufacturer
|
||||||
|
var profile struct {
|
||||||
|
CompanyName string `json:"company_name"`
|
||||||
|
ContactName string `json:"contact_name"`
|
||||||
|
ContactEmail string `json:"contact_email"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(req.CompanyProfile, &profile)
|
||||||
|
|
||||||
|
// Store the profile and scope in project metadata
|
||||||
|
profileData := map[string]json.RawMessage{
|
||||||
|
"company_profile": req.CompanyProfile,
|
||||||
|
"compliance_scope": req.ComplianceScope,
|
||||||
|
}
|
||||||
|
metadataBytes, _ := json.Marshal(profileData)
|
||||||
|
metadataRaw := json.RawMessage(metadataBytes)
|
||||||
|
|
||||||
|
// Build update request — fill project fields from scope/profile
|
||||||
|
updateReq := iace.UpdateProjectRequest{
|
||||||
|
Metadata: &metadataRaw,
|
||||||
|
}
|
||||||
|
if scope.MachineName != "" {
|
||||||
|
updateReq.MachineName = &scope.MachineName
|
||||||
|
}
|
||||||
|
if scope.MachineType != "" {
|
||||||
|
updateReq.MachineType = &scope.MachineType
|
||||||
|
}
|
||||||
|
if scope.IntendedUse != "" {
|
||||||
|
updateReq.Description = &scope.IntendedUse
|
||||||
|
}
|
||||||
|
if profile.CompanyName != "" {
|
||||||
|
updateReq.Manufacturer = &profile.CompanyName
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err = h.store.UpdateProject(c.Request.Context(), projectID, updateReq)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Create initial components from scope
|
||||||
|
var createdComponents []iace.Component
|
||||||
|
if scope.HasSoftware {
|
||||||
|
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||||
|
ProjectID: projectID, Name: "Software", ComponentType: iace.ComponentTypeSoftware,
|
||||||
|
IsSafetyRelevant: true, IsNetworked: scope.IsNetworked,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
createdComponents = append(createdComponents, *comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope.HasFirmware {
|
||||||
|
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||||
|
ProjectID: projectID, Name: "Firmware", ComponentType: iace.ComponentTypeFirmware,
|
||||||
|
IsSafetyRelevant: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
createdComponents = append(createdComponents, *comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope.HasAI {
|
||||||
|
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||||
|
ProjectID: projectID, Name: "KI-Modell", ComponentType: iace.ComponentTypeAIModel,
|
||||||
|
IsSafetyRelevant: true, IsNetworked: scope.IsNetworked,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
createdComponents = append(createdComponents, *comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope.IsNetworked {
|
||||||
|
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||||
|
ProjectID: projectID, Name: "Netzwerk-Schnittstelle", ComponentType: iace.ComponentTypeNetwork,
|
||||||
|
IsSafetyRelevant: false, IsNetworked: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
createdComponents = append(createdComponents, *comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger initial classifications for applicable regulations
|
||||||
|
regulationMap := map[string]iace.RegulationType{
|
||||||
|
"machinery_regulation": iace.RegulationMachineryRegulation,
|
||||||
|
"ai_act": iace.RegulationAIAct,
|
||||||
|
"cra": iace.RegulationCRA,
|
||||||
|
"nis2": iace.RegulationNIS2,
|
||||||
|
}
|
||||||
|
var triggeredRegulations []string
|
||||||
|
for _, regStr := range scope.ApplicableRegulations {
|
||||||
|
if regType, ok := regulationMap[regStr]; ok {
|
||||||
|
triggeredRegulations = append(triggeredRegulations, regStr)
|
||||||
|
// Create initial classification entry
|
||||||
|
h.store.UpsertClassification(ctx, projectID, regType, "pending", "medium", 0.5, "Initialisiert aus Compliance-Scope", nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance project status to onboarding
|
||||||
|
if err := h.store.UpdateProjectStatus(ctx, projectID, iace.ProjectStatusOnboarding); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audit trail entry
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
ctx, projectID, "project", projectID,
|
||||||
|
iace.AuditActionUpdate, userID.String(), nil, metadataBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "project initialized from profile",
|
||||||
|
"project": project,
|
||||||
|
"components_created": len(createdComponents),
|
||||||
|
"regulations_triggered": triggeredRegulations,
|
||||||
|
})
|
||||||
|
}
|
||||||
142
ai-compliance-sdk/internal/api/handlers/iace_handler_rag.go
Normal file
142
ai-compliance-sdk/internal/api/handlers/iace_handler_rag.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RAG Library Search (Phase 6)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// IACELibrarySearchRequest represents a semantic search against the IACE library corpus.
|
||||||
|
type IACELibrarySearchRequest struct {
|
||||||
|
Query string `json:"query" binding:"required"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
TopK int `json:"top_k,omitempty"`
|
||||||
|
Filters []string `json:"filters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchLibrary handles POST /iace/library-search
|
||||||
|
// Performs semantic search across the IACE hazard/component/measure library in Qdrant.
|
||||||
|
func (h *IACEHandler) SearchLibrary(c *gin.Context) {
|
||||||
|
var req IACELibrarySearchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topK := req.TopK
|
||||||
|
if topK <= 0 || topK > 50 {
|
||||||
|
topK = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use regulation filter for category-based search within the IACE collection
|
||||||
|
var filters []string
|
||||||
|
if req.Category != "" {
|
||||||
|
filters = append(filters, req.Category)
|
||||||
|
}
|
||||||
|
filters = append(filters, req.Filters...)
|
||||||
|
|
||||||
|
results, err := h.ragClient.SearchCollection(
|
||||||
|
c.Request.Context(),
|
||||||
|
"bp_iace_libraries",
|
||||||
|
req.Query,
|
||||||
|
filters,
|
||||||
|
topK,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "RAG search failed",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if results == nil {
|
||||||
|
results = []ucca.LegalSearchResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"query": req.Query,
|
||||||
|
"results": results,
|
||||||
|
"total": len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichTechFileSection handles POST /projects/:id/tech-file/:section/enrich
|
||||||
|
// Uses RAG to find relevant library content for a specific tech file section.
|
||||||
|
func (h *IACEHandler) EnrichTechFileSection(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionType := c.Param("section")
|
||||||
|
if sectionType == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "section type required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil || project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a contextual query based on section type and project data
|
||||||
|
queryParts := []string{project.MachineName, project.MachineType}
|
||||||
|
|
||||||
|
switch sectionType {
|
||||||
|
case "risk_assessment_report", "hazard_log_combined":
|
||||||
|
queryParts = append(queryParts, "Gefaehrdungen", "Risikobewertung", "ISO 12100")
|
||||||
|
case "essential_requirements":
|
||||||
|
queryParts = append(queryParts, "Sicherheitsanforderungen", "Maschinenrichtlinie")
|
||||||
|
case "design_specifications":
|
||||||
|
queryParts = append(queryParts, "Konstruktionsspezifikation", "Sicherheitskonzept")
|
||||||
|
case "test_reports":
|
||||||
|
queryParts = append(queryParts, "Pruefbericht", "Verifikation", "Nachweis")
|
||||||
|
case "standards_applied":
|
||||||
|
queryParts = append(queryParts, "harmonisierte Normen", "EN ISO")
|
||||||
|
case "ai_risk_management":
|
||||||
|
queryParts = append(queryParts, "KI-Risikomanagement", "AI Act", "Algorithmen")
|
||||||
|
case "ai_human_oversight":
|
||||||
|
queryParts = append(queryParts, "menschliche Aufsicht", "Human Oversight", "KI-Transparenz")
|
||||||
|
default:
|
||||||
|
queryParts = append(queryParts, sectionType)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.Join(queryParts, " ")
|
||||||
|
|
||||||
|
results, err := h.ragClient.SearchCollection(
|
||||||
|
c.Request.Context(),
|
||||||
|
"bp_iace_libraries",
|
||||||
|
query,
|
||||||
|
nil,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "RAG enrichment failed",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if results == nil {
|
||||||
|
results = []ucca.LegalSearchResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"project_id": projectID.String(),
|
||||||
|
"section_type": sectionType,
|
||||||
|
"query": query,
|
||||||
|
"context": results,
|
||||||
|
"total": len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
465
ai-compliance-sdk/internal/api/handlers/iace_handler_refdata.go
Normal file
465
ai-compliance-sdk/internal/api/handlers/iace_handler_refdata.go
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ISO 12100 Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListLifecyclePhases handles GET /lifecycle-phases
|
||||||
|
// Returns the 12 machine lifecycle phases with DE/EN labels.
|
||||||
|
func (h *IACEHandler) ListLifecyclePhases(c *gin.Context) {
|
||||||
|
phases, err := h.store.ListLifecyclePhases(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: return hardcoded 25 phases if DB table not yet migrated
|
||||||
|
phases = []iace.LifecyclePhaseInfo{
|
||||||
|
{ID: "transport", LabelDE: "Transport", LabelEN: "Transport", Sort: 1},
|
||||||
|
{ID: "storage", LabelDE: "Lagerung", LabelEN: "Storage", Sort: 2},
|
||||||
|
{ID: "assembly", LabelDE: "Montage", LabelEN: "Assembly", Sort: 3},
|
||||||
|
{ID: "installation", LabelDE: "Installation", LabelEN: "Installation", Sort: 4},
|
||||||
|
{ID: "commissioning", LabelDE: "Inbetriebnahme", LabelEN: "Commissioning", Sort: 5},
|
||||||
|
{ID: "parameterization", LabelDE: "Parametrierung", LabelEN: "Parameterization", Sort: 6},
|
||||||
|
{ID: "setup", LabelDE: "Einrichten / Setup", LabelEN: "Setup", Sort: 7},
|
||||||
|
{ID: "normal_operation", LabelDE: "Normalbetrieb", LabelEN: "Normal Operation", Sort: 8},
|
||||||
|
{ID: "automatic_operation", LabelDE: "Automatikbetrieb", LabelEN: "Automatic Operation", Sort: 9},
|
||||||
|
{ID: "manual_operation", LabelDE: "Handbetrieb", LabelEN: "Manual Operation", Sort: 10},
|
||||||
|
{ID: "teach_mode", LabelDE: "Teach-Modus", LabelEN: "Teach Mode", Sort: 11},
|
||||||
|
{ID: "production_start", LabelDE: "Produktionsstart", LabelEN: "Production Start", Sort: 12},
|
||||||
|
{ID: "production_stop", LabelDE: "Produktionsstopp", LabelEN: "Production Stop", Sort: 13},
|
||||||
|
{ID: "process_monitoring", LabelDE: "Prozessueberwachung", LabelEN: "Process Monitoring", Sort: 14},
|
||||||
|
{ID: "cleaning", LabelDE: "Reinigung", LabelEN: "Cleaning", Sort: 15},
|
||||||
|
{ID: "maintenance", LabelDE: "Wartung", LabelEN: "Maintenance", Sort: 16},
|
||||||
|
{ID: "inspection", LabelDE: "Inspektion", LabelEN: "Inspection", Sort: 17},
|
||||||
|
{ID: "calibration", LabelDE: "Kalibrierung", LabelEN: "Calibration", Sort: 18},
|
||||||
|
{ID: "fault_clearing", LabelDE: "Stoerungsbeseitigung", LabelEN: "Fault Clearing", Sort: 19},
|
||||||
|
{ID: "repair", LabelDE: "Reparatur", LabelEN: "Repair", Sort: 20},
|
||||||
|
{ID: "changeover", LabelDE: "Umruestung", LabelEN: "Changeover", Sort: 21},
|
||||||
|
{ID: "software_update", LabelDE: "Software-Update", LabelEN: "Software Update", Sort: 22},
|
||||||
|
{ID: "remote_maintenance", LabelDE: "Fernwartung", LabelEN: "Remote Maintenance", Sort: 23},
|
||||||
|
{ID: "decommissioning", LabelDE: "Ausserbetriebnahme", LabelEN: "Decommissioning", Sort: 24},
|
||||||
|
{ID: "disposal", LabelDE: "Demontage / Entsorgung", LabelEN: "Dismantling / Disposal", Sort: 25},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if phases == nil {
|
||||||
|
phases = []iace.LifecyclePhaseInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"lifecycle_phases": phases,
|
||||||
|
"total": len(phases),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProtectiveMeasures handles GET /protective-measures-library
|
||||||
|
// Returns the protective measures library, optionally filtered by ?reduction_type and ?hazard_category.
|
||||||
|
func (h *IACEHandler) ListProtectiveMeasures(c *gin.Context) {
|
||||||
|
reductionType := c.Query("reduction_type")
|
||||||
|
hazardCategory := c.Query("hazard_category")
|
||||||
|
|
||||||
|
all := iace.GetProtectiveMeasureLibrary()
|
||||||
|
|
||||||
|
var filtered []iace.ProtectiveMeasureEntry
|
||||||
|
for _, entry := range all {
|
||||||
|
if reductionType != "" && entry.ReductionType != reductionType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hazardCategory != "" && entry.HazardCategory != hazardCategory && entry.HazardCategory != "general" && entry.HazardCategory != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered == nil {
|
||||||
|
filtered = []iace.ProtectiveMeasureEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"protective_measures": filtered,
|
||||||
|
"total": len(filtered),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateMitigationHierarchy handles POST /projects/:id/validate-mitigation-hierarchy
|
||||||
|
// Validates if the proposed mitigation type follows the 3-step hierarchy principle.
|
||||||
|
func (h *IACEHandler) ValidateMitigationHierarchy(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req iace.ValidateMitigationHierarchyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing mitigations for the hazard
|
||||||
|
mitigations, err := h.store.ListMitigations(c.Request.Context(), req.HazardID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = projectID // projectID used for authorization context
|
||||||
|
|
||||||
|
warnings := h.engine.ValidateProtectiveMeasureHierarchy(req.ReductionType, mitigations)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, iace.ValidateMitigationHierarchyResponse{
|
||||||
|
Valid: len(warnings) == 0,
|
||||||
|
Warnings: warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoles handles GET /roles
|
||||||
|
// Returns the 20 affected person roles reference data.
|
||||||
|
func (h *IACEHandler) ListRoles(c *gin.Context) {
|
||||||
|
roles, err := h.store.ListRoles(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: return hardcoded roles if DB table not yet migrated
|
||||||
|
roles = []iace.RoleInfo{
|
||||||
|
{ID: "operator", LabelDE: "Maschinenbediener", LabelEN: "Machine Operator", Sort: 1},
|
||||||
|
{ID: "setter", LabelDE: "Einrichter", LabelEN: "Setter", Sort: 2},
|
||||||
|
{ID: "maintenance_tech", LabelDE: "Wartungstechniker", LabelEN: "Maintenance Technician", Sort: 3},
|
||||||
|
{ID: "service_tech", LabelDE: "Servicetechniker", LabelEN: "Service Technician", Sort: 4},
|
||||||
|
{ID: "cleaning_staff", LabelDE: "Reinigungspersonal", LabelEN: "Cleaning Staff", Sort: 5},
|
||||||
|
{ID: "production_manager", LabelDE: "Produktionsleiter", LabelEN: "Production Manager", Sort: 6},
|
||||||
|
{ID: "safety_officer", LabelDE: "Sicherheitsbeauftragter", LabelEN: "Safety Officer", Sort: 7},
|
||||||
|
{ID: "electrician", LabelDE: "Elektriker", LabelEN: "Electrician", Sort: 8},
|
||||||
|
{ID: "software_engineer", LabelDE: "Softwareingenieur", LabelEN: "Software Engineer", Sort: 9},
|
||||||
|
{ID: "maintenance_manager", LabelDE: "Instandhaltungsleiter", LabelEN: "Maintenance Manager", Sort: 10},
|
||||||
|
{ID: "plant_operator", LabelDE: "Anlagenfahrer", LabelEN: "Plant Operator", Sort: 11},
|
||||||
|
{ID: "qa_inspector", LabelDE: "Qualitaetssicherung", LabelEN: "Quality Assurance", Sort: 12},
|
||||||
|
{ID: "logistics_staff", LabelDE: "Logistikpersonal", LabelEN: "Logistics Staff", Sort: 13},
|
||||||
|
{ID: "subcontractor", LabelDE: "Fremdfirma / Subunternehmer", LabelEN: "Subcontractor", Sort: 14},
|
||||||
|
{ID: "visitor", LabelDE: "Besucher", LabelEN: "Visitor", Sort: 15},
|
||||||
|
{ID: "auditor", LabelDE: "Auditor", LabelEN: "Auditor", Sort: 16},
|
||||||
|
{ID: "it_admin", LabelDE: "IT-Administrator", LabelEN: "IT Administrator", Sort: 17},
|
||||||
|
{ID: "remote_service", LabelDE: "Fernwartungsdienst", LabelEN: "Remote Service", Sort: 18},
|
||||||
|
{ID: "plant_owner", LabelDE: "Betreiber", LabelEN: "Plant Owner / Operator", Sort: 19},
|
||||||
|
{ID: "emergency_responder", LabelDE: "Notfallpersonal", LabelEN: "Emergency Responder", Sort: 20},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if roles == nil {
|
||||||
|
roles = []iace.RoleInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"roles": roles,
|
||||||
|
"total": len(roles),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEvidenceTypes handles GET /evidence-types
|
||||||
|
// Returns the 50 evidence/verification types reference data.
|
||||||
|
func (h *IACEHandler) ListEvidenceTypes(c *gin.Context) {
|
||||||
|
types, err := h.store.ListEvidenceTypes(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: return empty if not migrated
|
||||||
|
types = []iace.EvidenceTypeInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if types == nil {
|
||||||
|
types = []iace.EvidenceTypeInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
category := c.Query("category")
|
||||||
|
if category != "" {
|
||||||
|
var filtered []iace.EvidenceTypeInfo
|
||||||
|
for _, t := range types {
|
||||||
|
if t.Category == category {
|
||||||
|
filtered = append(filtered, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filtered == nil {
|
||||||
|
filtered = []iace.EvidenceTypeInfo{}
|
||||||
|
}
|
||||||
|
types = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"evidence_types": types,
|
||||||
|
"total": len(types),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Library & Energy Sources (Phase 1)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListComponentLibrary handles GET /component-library
|
||||||
|
// Returns the built-in component library with optional category filter.
|
||||||
|
func (h *IACEHandler) ListComponentLibrary(c *gin.Context) {
|
||||||
|
category := c.Query("category")
|
||||||
|
|
||||||
|
all := iace.GetComponentLibrary()
|
||||||
|
var filtered []iace.ComponentLibraryEntry
|
||||||
|
for _, entry := range all {
|
||||||
|
if category != "" && entry.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered == nil {
|
||||||
|
filtered = []iace.ComponentLibraryEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"components": filtered,
|
||||||
|
"total": len(filtered),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnergySources handles GET /energy-sources
|
||||||
|
// Returns the built-in energy source library.
|
||||||
|
func (h *IACEHandler) ListEnergySources(c *gin.Context) {
|
||||||
|
sources := iace.GetEnergySources()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"energy_sources": sources,
|
||||||
|
"total": len(sources),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tag Taxonomy (Phase 2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListTags handles GET /tags
|
||||||
|
// Returns the tag taxonomy with optional domain filter.
|
||||||
|
func (h *IACEHandler) ListTags(c *gin.Context) {
|
||||||
|
domain := c.Query("domain")
|
||||||
|
|
||||||
|
all := iace.GetTagTaxonomy()
|
||||||
|
var filtered []iace.TagEntry
|
||||||
|
for _, entry := range all {
|
||||||
|
if domain != "" && entry.Domain != domain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered == nil {
|
||||||
|
filtered = []iace.TagEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"tags": filtered,
|
||||||
|
"total": len(filtered),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hazard Patterns & Pattern Engine (Phase 3+4)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListHazardPatterns handles GET /hazard-patterns
|
||||||
|
// Returns all built-in hazard patterns.
|
||||||
|
func (h *IACEHandler) ListHazardPatterns(c *gin.Context) {
|
||||||
|
patterns := iace.GetBuiltinHazardPatterns()
|
||||||
|
patterns = append(patterns, iace.GetExtendedHazardPatterns()...)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"patterns": patterns,
|
||||||
|
"total": len(patterns),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchPatterns handles POST /projects/:id/match-patterns
|
||||||
|
// Runs the pattern engine against the project's components and energy sources.
|
||||||
|
func (h *IACEHandler) MatchPatterns(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project exists
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil || project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input iace.MatchInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := iace.NewPatternEngine()
|
||||||
|
result := engine.Match(input)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyPatternResults handles POST /projects/:id/apply-patterns
|
||||||
|
// Accepts matched patterns and creates concrete hazards, mitigations, and
|
||||||
|
// verification plans in the project.
|
||||||
|
func (h *IACEHandler) ApplyPatternResults(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID, err := getTenantID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil || project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AcceptedHazards []iace.CreateHazardRequest `json:"accepted_hazards"`
|
||||||
|
AcceptedMeasures []iace.CreateMitigationRequest `json:"accepted_measures"`
|
||||||
|
AcceptedEvidence []iace.CreateVerificationPlanRequest `json:"accepted_evidence"`
|
||||||
|
SourcePatternIDs []string `json:"source_pattern_ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var createdHazards int
|
||||||
|
var createdMeasures int
|
||||||
|
var createdEvidence int
|
||||||
|
|
||||||
|
// Create hazards
|
||||||
|
for _, hazardReq := range req.AcceptedHazards {
|
||||||
|
hazardReq.ProjectID = projectID
|
||||||
|
_, err := h.store.CreateHazard(ctx, hazardReq)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdHazards++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mitigations
|
||||||
|
for _, mitigReq := range req.AcceptedMeasures {
|
||||||
|
_, err := h.store.CreateMitigation(ctx, mitigReq)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdMeasures++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create verification plans
|
||||||
|
for _, evidReq := range req.AcceptedEvidence {
|
||||||
|
evidReq.ProjectID = projectID
|
||||||
|
_, err := h.store.CreateVerificationPlan(ctx, evidReq)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdEvidence++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
h.store.AddAuditEntry(ctx, projectID, "pattern_matching", projectID,
|
||||||
|
iace.AuditActionCreate, tenantID.String(),
|
||||||
|
nil,
|
||||||
|
mustMarshalJSON(map[string]interface{}{
|
||||||
|
"source_patterns": req.SourcePatternIDs,
|
||||||
|
"created_hazards": createdHazards,
|
||||||
|
"created_measures": createdMeasures,
|
||||||
|
"created_evidence": createdEvidence,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"created_hazards": createdHazards,
|
||||||
|
"created_measures": createdMeasures,
|
||||||
|
"created_evidence": createdEvidence,
|
||||||
|
"message": "Pattern results applied successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestMeasuresForHazard handles POST /projects/:id/hazards/:hid/suggest-measures
|
||||||
|
// Suggests measures for a specific hazard based on its tags and category.
|
||||||
|
func (h *IACEHandler) SuggestMeasuresForHazard(c *gin.Context) {
|
||||||
|
hazardID, err := uuid.Parse(c.Param("hid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hazard, err := h.store.GetHazard(c.Request.Context(), hazardID)
|
||||||
|
if err != nil || hazard == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find measures matching the hazard category
|
||||||
|
all := iace.GetProtectiveMeasureLibrary()
|
||||||
|
var suggested []iace.ProtectiveMeasureEntry
|
||||||
|
for _, m := range all {
|
||||||
|
if m.HazardCategory == hazard.Category || m.HazardCategory == "general" {
|
||||||
|
suggested = append(suggested, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if suggested == nil {
|
||||||
|
suggested = []iace.ProtectiveMeasureEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"hazard_id": hazardID.String(),
|
||||||
|
"hazard_category": hazard.Category,
|
||||||
|
"suggested_measures": suggested,
|
||||||
|
"total": len(suggested),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestEvidenceForMitigation handles POST /projects/:id/mitigations/:mid/suggest-evidence
|
||||||
|
// Suggests evidence types for a specific mitigation.
|
||||||
|
func (h *IACEHandler) SuggestEvidenceForMitigation(c *gin.Context) {
|
||||||
|
mitigationID, err := uuid.Parse(c.Param("mid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mitigation ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mitigation, err := h.store.GetMitigation(c.Request.Context(), mitigationID)
|
||||||
|
if err != nil || mitigation == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "mitigation not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map reduction type to relevant evidence tags
|
||||||
|
var relevantTags []string
|
||||||
|
switch mitigation.ReductionType {
|
||||||
|
case iace.ReductionTypeDesign:
|
||||||
|
relevantTags = []string{"design_evidence", "analysis_evidence"}
|
||||||
|
case iace.ReductionTypeProtective:
|
||||||
|
relevantTags = []string{"test_evidence", "inspection_evidence"}
|
||||||
|
case iace.ReductionTypeInformation:
|
||||||
|
relevantTags = []string{"training_evidence", "operational_evidence"}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := iace.NewTagResolver()
|
||||||
|
suggested := resolver.FindEvidenceByTags(relevantTags)
|
||||||
|
|
||||||
|
if suggested == nil {
|
||||||
|
suggested = []iace.EvidenceTypeInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"mitigation_id": mitigationID.String(),
|
||||||
|
"reduction_type": string(mitigation.ReductionType),
|
||||||
|
"suggested_evidence": suggested,
|
||||||
|
"total": len(suggested),
|
||||||
|
})
|
||||||
|
}
|
||||||
452
ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go
Normal file
452
ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CE Technical File
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GenerateTechFile handles POST /projects/:id/tech-file/generate
|
||||||
|
// Generates technical file sections for a project.
|
||||||
|
// TODO: Integrate LLM for intelligent content generation based on project data.
|
||||||
|
func (h *IACEHandler) GenerateTechFile(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the standard CE technical file sections to generate
|
||||||
|
sectionDefinitions := []struct {
|
||||||
|
SectionType string
|
||||||
|
Title string
|
||||||
|
}{
|
||||||
|
{"general_description", "General Description of the Machinery"},
|
||||||
|
{"risk_assessment_report", "Risk Assessment Report"},
|
||||||
|
{"hazard_log_combined", "Combined Hazard Log"},
|
||||||
|
{"essential_requirements", "Essential Health and Safety Requirements"},
|
||||||
|
{"design_specifications", "Design Specifications and Drawings"},
|
||||||
|
{"test_reports", "Test Reports and Verification Results"},
|
||||||
|
{"standards_applied", "Applied Harmonised Standards"},
|
||||||
|
{"declaration_of_conformity", "EU Declaration of Conformity"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project has AI components for additional sections
|
||||||
|
components, _ := h.store.ListComponents(c.Request.Context(), projectID)
|
||||||
|
hasAI := false
|
||||||
|
for _, comp := range components {
|
||||||
|
if comp.ComponentType == iace.ComponentTypeAIModel {
|
||||||
|
hasAI = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAI {
|
||||||
|
sectionDefinitions = append(sectionDefinitions,
|
||||||
|
struct {
|
||||||
|
SectionType string
|
||||||
|
Title string
|
||||||
|
}{"ai_intended_purpose", "AI System Intended Purpose"},
|
||||||
|
struct {
|
||||||
|
SectionType string
|
||||||
|
Title string
|
||||||
|
}{"ai_model_description", "AI Model Description and Training Data"},
|
||||||
|
struct {
|
||||||
|
SectionType string
|
||||||
|
Title string
|
||||||
|
}{"ai_risk_management", "AI Risk Management System"},
|
||||||
|
struct {
|
||||||
|
SectionType string
|
||||||
|
Title string
|
||||||
|
}{"ai_human_oversight", "AI Human Oversight Measures"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate each section with LLM-based content
|
||||||
|
var sections []iace.TechFileSection
|
||||||
|
existingSections, _ := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
existingMap := make(map[string]bool)
|
||||||
|
for _, s := range existingSections {
|
||||||
|
existingMap[s.SectionType] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, def := range sectionDefinitions {
|
||||||
|
// Skip sections that already exist
|
||||||
|
if existingMap[def.SectionType] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate content via LLM (falls back to structured placeholder if LLM unavailable)
|
||||||
|
content, _ := h.techFileGen.GenerateSection(c.Request.Context(), projectID, def.SectionType)
|
||||||
|
if content == "" {
|
||||||
|
content = fmt.Sprintf("[Sektion: %s — Inhalt wird generiert]", def.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
section, err := h.store.CreateTechFileSection(
|
||||||
|
c.Request.Context(), projectID, def.SectionType, def.Title, content,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sections = append(sections, *section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project status
|
||||||
|
h.store.UpdateProjectStatus(c.Request.Context(), projectID, iace.ProjectStatusTechFile)
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "tech_file", projectID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"sections_created": len(sections),
|
||||||
|
"sections": sections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSingleSection handles POST /projects/:id/tech-file/:section/generate
|
||||||
|
// Generates or regenerates a single tech file section using LLM.
|
||||||
|
func (h *IACEHandler) GenerateSingleSection(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionType := c.Param("section")
|
||||||
|
if sectionType == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "section type required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate content via LLM
|
||||||
|
content, err := h.techFileGen.GenerateSection(c.Request.Context(), projectID, sectionType)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("generation failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing section and update, or create new
|
||||||
|
sections, _ := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
var sectionID uuid.UUID
|
||||||
|
found := false
|
||||||
|
for _, s := range sections {
|
||||||
|
if s.SectionType == sectionType {
|
||||||
|
sectionID = s.ID
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found {
|
||||||
|
if err := h.store.UpdateTechFileSection(c.Request.Context(), sectionID, content); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title := sectionType // fallback
|
||||||
|
sectionTitles := map[string]string{
|
||||||
|
"general_description": "General Description of the Machinery",
|
||||||
|
"risk_assessment_report": "Risk Assessment Report",
|
||||||
|
"hazard_log_combined": "Combined Hazard Log",
|
||||||
|
"essential_requirements": "Essential Health and Safety Requirements",
|
||||||
|
"design_specifications": "Design Specifications and Drawings",
|
||||||
|
"test_reports": "Test Reports and Verification Results",
|
||||||
|
"standards_applied": "Applied Harmonised Standards",
|
||||||
|
"declaration_of_conformity": "EU Declaration of Conformity",
|
||||||
|
"component_list": "Component List",
|
||||||
|
"classification_report": "Regulatory Classification Report",
|
||||||
|
"mitigation_report": "Mitigation Measures Report",
|
||||||
|
"verification_report": "Verification Report",
|
||||||
|
"evidence_index": "Evidence Index",
|
||||||
|
"instructions_for_use": "Instructions for Use",
|
||||||
|
"monitoring_plan": "Post-Market Monitoring Plan",
|
||||||
|
"ai_intended_purpose": "AI System Intended Purpose",
|
||||||
|
"ai_model_description": "AI Model Description and Training Data",
|
||||||
|
"ai_risk_management": "AI Risk Management System",
|
||||||
|
"ai_human_oversight": "AI Human Oversight Measures",
|
||||||
|
}
|
||||||
|
if t, ok := sectionTitles[sectionType]; ok {
|
||||||
|
title = t
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.store.CreateTechFileSection(c.Request.Context(), projectID, sectionType, title, content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "tech_file_section", projectID,
|
||||||
|
iace.AuditActionCreate, userID.String(), nil, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "section generated",
|
||||||
|
"section_type": sectionType,
|
||||||
|
"content": content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTechFileSections handles GET /projects/:id/tech-file
|
||||||
|
// Lists all technical file sections for a project.
|
||||||
|
func (h *IACEHandler) ListTechFileSections(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sections, err := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sections == nil {
|
||||||
|
sections = []iace.TechFileSection{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"sections": sections,
|
||||||
|
"total": len(sections),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTechFileSection handles PUT /projects/:id/tech-file/:section
|
||||||
|
// Updates the content of a technical file section (identified by section_type).
|
||||||
|
func (h *IACEHandler) UpdateTechFileSection(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionType := c.Param("section")
|
||||||
|
if sectionType == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "section type required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the section by project ID and section type
|
||||||
|
sections, err := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sectionID uuid.UUID
|
||||||
|
found := false
|
||||||
|
for _, s := range sections {
|
||||||
|
if s.SectionType == sectionType {
|
||||||
|
sectionID = s.ID
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("tech file section '%s' not found", sectionType)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateTechFileSection(c.Request.Context(), sectionID, req.Content); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "tech_file_section", sectionID,
|
||||||
|
iace.AuditActionUpdate, userID.String(), nil, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "tech file section updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveTechFileSection handles POST /projects/:id/tech-file/:section/approve
|
||||||
|
// Marks a technical file section as approved.
|
||||||
|
func (h *IACEHandler) ApproveTechFileSection(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionType := c.Param("section")
|
||||||
|
if sectionType == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "section type required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the section by project ID and section type
|
||||||
|
sections, err := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sectionID uuid.UUID
|
||||||
|
found := false
|
||||||
|
for _, s := range sections {
|
||||||
|
if s.SectionType == sectionType {
|
||||||
|
sectionID = s.ID
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("tech file section '%s' not found", sectionType)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
if err := h.store.ApproveTechFileSection(c.Request.Context(), sectionID, userID.String()); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(), projectID, "tech_file_section", sectionID,
|
||||||
|
iace.AuditActionApprove, userID.String(), nil, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "tech file section approved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportTechFile handles GET /projects/:id/tech-file/export?format=pdf|xlsx|docx|md|json
|
||||||
|
// Exports all tech file sections in the requested format.
|
||||||
|
func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.store.GetProject(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sections, err := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load hazards, assessments, mitigations, classifications for export
|
||||||
|
hazards, _ := h.store.ListHazards(c.Request.Context(), projectID)
|
||||||
|
var allAssessments []iace.RiskAssessment
|
||||||
|
var allMitigations []iace.Mitigation
|
||||||
|
for _, hazard := range hazards {
|
||||||
|
assessments, _ := h.store.ListAssessments(c.Request.Context(), hazard.ID)
|
||||||
|
allAssessments = append(allAssessments, assessments...)
|
||||||
|
mitigations, _ := h.store.ListMitigations(c.Request.Context(), hazard.ID)
|
||||||
|
allMitigations = append(allMitigations, mitigations...)
|
||||||
|
}
|
||||||
|
classifications, _ := h.store.GetClassifications(c.Request.Context(), projectID)
|
||||||
|
|
||||||
|
format := c.DefaultQuery("format", "json")
|
||||||
|
safeName := strings.ReplaceAll(project.MachineName, " ", "_")
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "pdf":
|
||||||
|
data, err := h.exporter.ExportPDF(project, sections, hazards, allAssessments, allMitigations, classifications)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
||||||
|
c.Data(http.StatusOK, "application/pdf", data)
|
||||||
|
|
||||||
|
case "xlsx":
|
||||||
|
data, err := h.exporter.ExportExcel(project, sections, hazards, allAssessments, allMitigations)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
||||||
|
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||||
|
|
||||||
|
case "docx":
|
||||||
|
data, err := h.exporter.ExportDOCX(project, sections)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
||||||
|
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
||||||
|
|
||||||
|
case "md":
|
||||||
|
data, err := h.exporter.ExportMarkdown(project, sections)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
||||||
|
c.Data(http.StatusOK, "text/markdown", data)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// JSON export (original behavior)
|
||||||
|
allApproved := true
|
||||||
|
for _, s := range sections {
|
||||||
|
if s.Status != iace.TechFileSectionStatusApproved {
|
||||||
|
allApproved = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
riskSummary, _ := h.store.GetRiskSummary(c.Request.Context(), projectID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"project": project,
|
||||||
|
"sections": sections,
|
||||||
|
"classifications": classifications,
|
||||||
|
"risk_summary": riskSummary,
|
||||||
|
"all_approved": allApproved,
|
||||||
|
"export_format": "json",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Assignment Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ComputeAssignments computes assignments for a user based on roles
|
||||||
|
// POST /sdk/v1/training/assignments/compute
|
||||||
|
func (h *TrainingHandlers) ComputeAssignments(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
var req training.ComputeAssignmentsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger := req.Trigger
|
||||||
|
if trigger == "" {
|
||||||
|
trigger = "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments, err := training.ComputeAssignments(
|
||||||
|
c.Request.Context(), h.store, tenantID,
|
||||||
|
req.UserID, req.UserName, req.UserEmail, req.Roles, trigger,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": assignments,
|
||||||
|
"created": len(assignments),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAssignments returns assignments for the tenant
|
||||||
|
// GET /sdk/v1/training/assignments
|
||||||
|
func (h *TrainingHandlers) ListAssignments(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
filters := &training.AssignmentFilters{
|
||||||
|
Limit: 50,
|
||||||
|
Offset: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := c.Query("user_id"); v != "" {
|
||||||
|
if uid, err := uuid.Parse(v); err == nil {
|
||||||
|
filters.UserID = &uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("module_id"); v != "" {
|
||||||
|
if mid, err := uuid.Parse(v); err == nil {
|
||||||
|
filters.ModuleID = &mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("role"); v != "" {
|
||||||
|
filters.RoleCode = v
|
||||||
|
}
|
||||||
|
if v := c.Query("status"); v != "" {
|
||||||
|
filters.Status = training.AssignmentStatus(v)
|
||||||
|
}
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filters.Limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filters.Offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments, total, err := h.store.ListAssignments(c.Request.Context(), tenantID, filters)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.AssignmentListResponse{
|
||||||
|
Assignments: assignments,
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssignment returns a single assignment
|
||||||
|
// GET /sdk/v1/training/assignments/:id
|
||||||
|
func (h *TrainingHandlers) GetAssignment(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assignment == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, assignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAssignment marks an assignment as started
|
||||||
|
// POST /sdk/v1/training/assignments/:id/start
|
||||||
|
func (h *TrainingHandlers) StartAssignment(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, training.AssignmentStatusInProgress, 0); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: &userID,
|
||||||
|
Action: training.AuditActionStarted,
|
||||||
|
EntityType: training.AuditEntityAssignment,
|
||||||
|
EntityID: &id,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "in_progress"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAssignmentProgress updates progress on an assignment
|
||||||
|
// POST /sdk/v1/training/assignments/:id/progress
|
||||||
|
func (h *TrainingHandlers) UpdateAssignmentProgress(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req training.UpdateAssignmentProgressRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := training.AssignmentStatusInProgress
|
||||||
|
if req.Progress >= 100 {
|
||||||
|
status = training.AssignmentStatusCompleted
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, status, req.Progress); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": string(status), "progress": req.Progress})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAssignment updates assignment fields (e.g. deadline)
|
||||||
|
// PUT /sdk/v1/training/assignments/:id
|
||||||
|
func (h *TrainingHandlers) UpdateAssignment(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Deadline *string `json:"deadline"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Deadline != nil {
|
||||||
|
deadline, err := time.Parse(time.RFC3339, *req.Deadline)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deadline format (use RFC3339)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpdateAssignmentDeadline(c.Request.Context(), id, deadline); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assignment == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, assignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteAssignment marks an assignment as completed
|
||||||
|
// POST /sdk/v1/training/assignments/:id/complete
|
||||||
|
func (h *TrainingHandlers) CompleteAssignment(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, training.AssignmentStatusCompleted, 100); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: &userID,
|
||||||
|
Action: training.AuditActionCompleted,
|
||||||
|
EntityType: training.AuditEntityAssignment,
|
||||||
|
EntityID: &id,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "completed"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Training Block Endpoints (Controls → Schulungsmodule)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListBlockConfigs returns all block configs for the tenant
|
||||||
|
// GET /sdk/v1/training/blocks
|
||||||
|
func (h *TrainingHandlers) ListBlockConfigs(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
configs, err := h.store.ListBlockConfigs(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"blocks": configs,
|
||||||
|
"total": len(configs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBlockConfig creates a new block configuration
|
||||||
|
// POST /sdk/v1/training/blocks
|
||||||
|
func (h *TrainingHandlers) CreateBlockConfig(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
var req training.CreateBlockConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &training.TrainingBlockConfig{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
DomainFilter: req.DomainFilter,
|
||||||
|
CategoryFilter: req.CategoryFilter,
|
||||||
|
SeverityFilter: req.SeverityFilter,
|
||||||
|
TargetAudienceFilter: req.TargetAudienceFilter,
|
||||||
|
RegulationArea: req.RegulationArea,
|
||||||
|
ModuleCodePrefix: req.ModuleCodePrefix,
|
||||||
|
FrequencyType: req.FrequencyType,
|
||||||
|
DurationMinutes: req.DurationMinutes,
|
||||||
|
PassThreshold: req.PassThreshold,
|
||||||
|
MaxControlsPerModule: req.MaxControlsPerModule,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.FrequencyType == "" {
|
||||||
|
config.FrequencyType = training.FrequencyAnnual
|
||||||
|
}
|
||||||
|
if config.DurationMinutes == 0 {
|
||||||
|
config.DurationMinutes = 45
|
||||||
|
}
|
||||||
|
if config.PassThreshold == 0 {
|
||||||
|
config.PassThreshold = 70
|
||||||
|
}
|
||||||
|
if config.MaxControlsPerModule == 0 {
|
||||||
|
config.MaxControlsPerModule = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateBlockConfig(c.Request.Context(), config); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlockConfig returns a single block config
|
||||||
|
// GET /sdk/v1/training/blocks/:id
|
||||||
|
func (h *TrainingHandlers) GetBlockConfig(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBlockConfig updates a block config
|
||||||
|
// PUT /sdk/v1/training/blocks/:id
|
||||||
|
func (h *TrainingHandlers) UpdateBlockConfig(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req training.UpdateBlockConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
config.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
config.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.DomainFilter != nil {
|
||||||
|
config.DomainFilter = *req.DomainFilter
|
||||||
|
}
|
||||||
|
if req.CategoryFilter != nil {
|
||||||
|
config.CategoryFilter = *req.CategoryFilter
|
||||||
|
}
|
||||||
|
if req.SeverityFilter != nil {
|
||||||
|
config.SeverityFilter = *req.SeverityFilter
|
||||||
|
}
|
||||||
|
if req.TargetAudienceFilter != nil {
|
||||||
|
config.TargetAudienceFilter = *req.TargetAudienceFilter
|
||||||
|
}
|
||||||
|
if req.MaxControlsPerModule != nil {
|
||||||
|
config.MaxControlsPerModule = *req.MaxControlsPerModule
|
||||||
|
}
|
||||||
|
if req.DurationMinutes != nil {
|
||||||
|
config.DurationMinutes = *req.DurationMinutes
|
||||||
|
}
|
||||||
|
if req.PassThreshold != nil {
|
||||||
|
config.PassThreshold = *req.PassThreshold
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
config.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateBlockConfig(c.Request.Context(), config); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBlockConfig deletes a block config
|
||||||
|
// DELETE /sdk/v1/training/blocks/:id
|
||||||
|
func (h *TrainingHandlers) DeleteBlockConfig(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.DeleteBlockConfig(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewBlock performs a dry run showing matching controls and proposed roles
|
||||||
|
// POST /sdk/v1/training/blocks/:id/preview
|
||||||
|
func (h *TrainingHandlers) PreviewBlock(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := h.blockGenerator.Preview(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBlock runs the full generation pipeline
|
||||||
|
// POST /sdk/v1/training/blocks/:id/generate
|
||||||
|
func (h *TrainingHandlers) GenerateBlock(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req training.GenerateBlockRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
// Defaults are fine
|
||||||
|
req.Language = "de"
|
||||||
|
req.AutoMatrix = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.blockGenerator.Generate(c.Request.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlockControls returns control links for a block config
|
||||||
|
// GET /sdk/v1/training/blocks/:id/controls
|
||||||
|
func (h *TrainingHandlers) GetBlockControls(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
links, err := h.store.GetControlLinksForBlock(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"controls": links,
|
||||||
|
"total": len(links),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCanonicalControls returns filtered canonical controls for browsing
|
||||||
|
// GET /sdk/v1/training/canonical/controls
|
||||||
|
func (h *TrainingHandlers) ListCanonicalControls(c *gin.Context) {
|
||||||
|
domain := c.Query("domain")
|
||||||
|
category := c.Query("category")
|
||||||
|
severity := c.Query("severity")
|
||||||
|
targetAudience := c.Query("target_audience")
|
||||||
|
|
||||||
|
controls, err := h.store.QueryCanonicalControls(c.Request.Context(),
|
||||||
|
domain, category, severity, targetAudience,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"controls": controls,
|
||||||
|
"total": len(controls),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanonicalMeta returns aggregated metadata about canonical controls
|
||||||
|
// GET /sdk/v1/training/canonical/meta
|
||||||
|
func (h *TrainingHandlers) GetCanonicalMeta(c *gin.Context) {
|
||||||
|
meta, err := h.store.GetCanonicalControlMeta(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, meta)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Content Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GenerateContent generates module content via LLM
|
||||||
|
// POST /sdk/v1/training/content/generate
|
||||||
|
func (h *TrainingHandlers) GenerateContent(c *gin.Context) {
|
||||||
|
var req training.GenerateContentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), req.ModuleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := h.contentGenerator.GenerateModuleContent(c.Request.Context(), *module, req.Language)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQuiz generates quiz questions via LLM
|
||||||
|
// POST /sdk/v1/training/content/generate-quiz
|
||||||
|
func (h *TrainingHandlers) GenerateQuiz(c *gin.Context) {
|
||||||
|
var req training.GenerateQuizRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), req.ModuleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count := req.Count
|
||||||
|
if count <= 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
questions, err := h.contentGenerator.GenerateQuizQuestions(c.Request.Context(), *module, count)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"questions": questions,
|
||||||
|
"total": len(questions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContent returns published content for a module
|
||||||
|
// GET /sdk/v1/training/content/:moduleId
|
||||||
|
func (h *TrainingHandlers) GetContent(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := h.store.GetPublishedContent(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
// Try latest unpublished
|
||||||
|
content, err = h.store.GetLatestContent(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no content found for this module"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishContent publishes a content version
|
||||||
|
// POST /sdk/v1/training/content/:id/publish
|
||||||
|
func (h *TrainingHandlers) PublishContent(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewedBy := rbac.GetUserID(c)
|
||||||
|
|
||||||
|
if err := h.store.PublishContent(c.Request.Context(), id, reviewedBy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "published"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllContent generates content for all modules that don't have content yet
|
||||||
|
// POST /sdk/v1/training/content/generate-all
|
||||||
|
func (h *TrainingHandlers) GenerateAllContent(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
language := "de"
|
||||||
|
if v := c.Query("language"); v != "" {
|
||||||
|
language = v
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.contentGenerator.GenerateAllModuleContent(c.Request.Context(), tenantID, language)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllQuizzes generates quiz questions for all modules that don't have questions yet
|
||||||
|
// POST /sdk/v1/training/content/generate-all-quiz
|
||||||
|
func (h *TrainingHandlers) GenerateAllQuizzes(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
count := 5
|
||||||
|
|
||||||
|
result, err := h.contentGenerator.GenerateAllQuizQuestions(c.Request.Context(), tenantID, count)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAudio generates audio for a module via TTS service
|
||||||
|
// POST /sdk/v1/training/content/:moduleId/generate-audio
|
||||||
|
func (h *TrainingHandlers) GenerateAudio(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
media, err := h.contentGenerator.GenerateAudio(c.Request.Context(), *module)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, media)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVideo generates a presentation video for a module
|
||||||
|
// POST /sdk/v1/training/content/:moduleId/generate-video
|
||||||
|
func (h *TrainingHandlers) GenerateVideo(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
media, err := h.contentGenerator.GenerateVideo(c.Request.Context(), *module)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, media)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewVideoScript generates and returns a video script preview without creating the video
|
||||||
|
// POST /sdk/v1/training/content/:moduleId/preview-script
|
||||||
|
func (h *TrainingHandlers) PreviewVideoScript(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := h.contentGenerator.GenerateVideoScript(c.Request.Context(), *module)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInteractiveVideo triggers the full interactive video pipeline
|
||||||
|
// POST /sdk/v1/training/content/:moduleId/generate-interactive
|
||||||
|
func (h *TrainingHandlers) GenerateInteractiveVideo(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
media, err := h.contentGenerator.GenerateInteractiveVideo(c.Request.Context(), *module)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, media)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Matrix Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetMatrix returns the full CTM for the tenant
|
||||||
|
// GET /sdk/v1/training/matrix
|
||||||
|
func (h *TrainingHandlers) GetMatrix(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
entries, err := h.store.GetMatrixForTenant(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := training.BuildMatrixResponse(entries)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatrixForRole returns matrix entries for a specific role
|
||||||
|
// GET /sdk/v1/training/matrix/:role
|
||||||
|
func (h *TrainingHandlers) GetMatrixForRole(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
role := c.Param("role")
|
||||||
|
|
||||||
|
entries, err := h.store.GetMatrixForRole(c.Request.Context(), tenantID, role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"role": role,
|
||||||
|
"label": training.RoleLabels[role],
|
||||||
|
"entries": entries,
|
||||||
|
"total": len(entries),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatrixEntry creates or updates a CTM entry
|
||||||
|
// POST /sdk/v1/training/matrix
|
||||||
|
func (h *TrainingHandlers) SetMatrixEntry(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
var req training.SetMatrixEntryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &training.TrainingMatrixEntry{
|
||||||
|
TenantID: tenantID,
|
||||||
|
RoleCode: req.RoleCode,
|
||||||
|
ModuleID: req.ModuleID,
|
||||||
|
IsMandatory: req.IsMandatory,
|
||||||
|
Priority: req.Priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.SetMatrixEntry(c.Request.Context(), entry); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMatrixEntry removes a CTM entry
|
||||||
|
// DELETE /sdk/v1/training/matrix/:role/:moduleId
|
||||||
|
func (h *TrainingHandlers) DeleteMatrixEntry(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
role := c.Param("role")
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.DeleteMatrixEntry(c.Request.Context(), tenantID, role, moduleID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Media Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetModuleMedia returns all media files for a module
|
||||||
|
// GET /sdk/v1/training/media/:moduleId
|
||||||
|
func (h *TrainingHandlers) GetModuleMedia(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"media": mediaList,
|
||||||
|
"total": len(mediaList),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaURL returns a presigned URL for a media file
|
||||||
|
// GET /sdk/v1/training/media/:id/url
|
||||||
|
func (h *TrainingHandlers) GetMediaURL(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
media, err := h.store.GetMedia(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if media == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the object info for the frontend to construct the URL
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"bucket": media.Bucket,
|
||||||
|
"object_key": media.ObjectKey,
|
||||||
|
"mime_type": media.MimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishMedia publishes or unpublishes a media file
|
||||||
|
// POST /sdk/v1/training/media/:id/publish
|
||||||
|
func (h *TrainingHandlers) PublishMedia(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Publish bool `json:"publish"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
req.Publish = true // Default to publish
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.PublishMedia(c.Request.Context(), id, req.Publish); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "is_published": req.Publish})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamMedia returns a redirect to a presigned URL for a media file
|
||||||
|
// GET /sdk/v1/training/media/:mediaId/stream
|
||||||
|
func (h *TrainingHandlers) StreamMedia(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
media, err := h.store.GetMedia(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if media == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.ttsClient == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "media streaming not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), media.Bucket, media.ObjectKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate streaming URL: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Interactive Video Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetInteractiveManifest returns the interactive video manifest with checkpoints and progress
|
||||||
|
// GET /sdk/v1/training/content/:moduleId/interactive-manifest
|
||||||
|
func (h *TrainingHandlers) GetInteractiveManifest(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get interactive video media
|
||||||
|
mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find interactive video
|
||||||
|
var interactiveMedia *training.TrainingMedia
|
||||||
|
for i := range mediaList {
|
||||||
|
if mediaList[i].MediaType == training.MediaTypeInteractiveVideo && mediaList[i].Status == training.MediaStatusCompleted {
|
||||||
|
interactiveMedia = &mediaList[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if interactiveMedia == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no interactive video found for this module"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get checkpoints
|
||||||
|
checkpoints, err := h.store.ListCheckpoints(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: get assignment ID for progress
|
||||||
|
assignmentIDStr := c.Query("assignment_id")
|
||||||
|
|
||||||
|
// Build manifest entries
|
||||||
|
entries := make([]training.CheckpointManifestEntry, len(checkpoints))
|
||||||
|
for i, cp := range checkpoints {
|
||||||
|
// Get questions for this checkpoint
|
||||||
|
questions, _ := h.store.GetCheckpointQuestions(c.Request.Context(), cp.ID)
|
||||||
|
|
||||||
|
cpQuestions := make([]training.CheckpointQuestion, len(questions))
|
||||||
|
for j, q := range questions {
|
||||||
|
cpQuestions[j] = training.CheckpointQuestion{
|
||||||
|
Question: q.Question,
|
||||||
|
Options: q.Options,
|
||||||
|
CorrectIndex: q.CorrectIndex,
|
||||||
|
Explanation: q.Explanation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := training.CheckpointManifestEntry{
|
||||||
|
CheckpointID: cp.ID,
|
||||||
|
Index: cp.CheckpointIndex,
|
||||||
|
Title: cp.Title,
|
||||||
|
TimestampSeconds: cp.TimestampSeconds,
|
||||||
|
Questions: cpQuestions,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get progress if assignment_id provided
|
||||||
|
if assignmentIDStr != "" {
|
||||||
|
if assignmentID, err := uuid.Parse(assignmentIDStr); err == nil {
|
||||||
|
progress, _ := h.store.GetCheckpointProgress(c.Request.Context(), assignmentID, cp.ID)
|
||||||
|
entry.Progress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[i] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stream URL
|
||||||
|
streamURL := ""
|
||||||
|
if h.ttsClient != nil {
|
||||||
|
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), interactiveMedia.Bucket, interactiveMedia.ObjectKey)
|
||||||
|
if err == nil {
|
||||||
|
streamURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := training.InteractiveVideoManifest{
|
||||||
|
MediaID: interactiveMedia.ID,
|
||||||
|
StreamURL: streamURL,
|
||||||
|
Checkpoints: entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitCheckpointQuiz handles checkpoint quiz submission
|
||||||
|
// POST /sdk/v1/training/checkpoints/:checkpointId/submit
|
||||||
|
func (h *TrainingHandlers) SubmitCheckpointQuiz(c *gin.Context) {
|
||||||
|
checkpointID, err := uuid.Parse(c.Param("checkpointId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid checkpoint ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req training.SubmitCheckpointQuizRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentID, err := uuid.Parse(req.AssignmentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get checkpoint questions
|
||||||
|
questions, err := h.store.GetCheckpointQuestions(c.Request.Context(), checkpointID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(questions) == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no questions found for this checkpoint"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grade answers
|
||||||
|
correctCount := 0
|
||||||
|
feedback := make([]training.CheckpointQuizFeedback, len(questions))
|
||||||
|
for i, q := range questions {
|
||||||
|
isCorrect := false
|
||||||
|
if i < len(req.Answers) && req.Answers[i] == q.CorrectIndex {
|
||||||
|
isCorrect = true
|
||||||
|
correctCount++
|
||||||
|
}
|
||||||
|
feedback[i] = training.CheckpointQuizFeedback{
|
||||||
|
Question: q.Question,
|
||||||
|
Correct: isCorrect,
|
||||||
|
Explanation: q.Explanation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score := float64(correctCount) / float64(len(questions)) * 100
|
||||||
|
passed := score >= 70 // 70% threshold for checkpoint
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
progress := &training.CheckpointProgress{
|
||||||
|
AssignmentID: assignmentID,
|
||||||
|
CheckpointID: checkpointID,
|
||||||
|
Passed: passed,
|
||||||
|
Attempts: 1,
|
||||||
|
}
|
||||||
|
if err := h.store.UpsertCheckpointProgress(c.Request.Context(), progress); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||||
|
TenantID: rbac.GetTenantID(c),
|
||||||
|
UserID: &userID,
|
||||||
|
Action: training.AuditAction("checkpoint_submitted"),
|
||||||
|
EntityType: training.AuditEntityType("checkpoint"),
|
||||||
|
EntityID: &checkpointID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"assignment_id": assignmentID.String(),
|
||||||
|
"score": score,
|
||||||
|
"passed": passed,
|
||||||
|
"correct": correctCount,
|
||||||
|
"total": len(questions),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.SubmitCheckpointQuizResponse{
|
||||||
|
Passed: passed,
|
||||||
|
Score: score,
|
||||||
|
Feedback: feedback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckpointProgress returns all checkpoint progress for an assignment
|
||||||
|
// GET /sdk/v1/training/checkpoints/progress/:assignmentId
|
||||||
|
func (h *TrainingHandlers) GetCheckpointProgress(c *gin.Context) {
|
||||||
|
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := h.store.ListCheckpointProgress(c.Request.Context(), assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"progress": progress,
|
||||||
|
"total": len(progress),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Module Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ListModules returns all training modules for the tenant
|
||||||
|
// GET /sdk/v1/training/modules
|
||||||
|
func (h *TrainingHandlers) ListModules(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
filters := &training.ModuleFilters{
|
||||||
|
Limit: 50,
|
||||||
|
Offset: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := c.Query("regulation_area"); v != "" {
|
||||||
|
filters.RegulationArea = training.RegulationArea(v)
|
||||||
|
}
|
||||||
|
if v := c.Query("frequency_type"); v != "" {
|
||||||
|
filters.FrequencyType = training.FrequencyType(v)
|
||||||
|
}
|
||||||
|
if v := c.Query("search"); v != "" {
|
||||||
|
filters.Search = v
|
||||||
|
}
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filters.Limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filters.Offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules, total, err := h.store.ListModules(c.Request.Context(), tenantID, filters)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.ModuleListResponse{
|
||||||
|
Modules: modules,
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModule returns a single training module with content and quiz
|
||||||
|
// GET /sdk/v1/training/modules/:id
|
||||||
|
func (h *TrainingHandlers) GetModule(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include content and quiz questions
|
||||||
|
content, _ := h.store.GetPublishedContent(c.Request.Context(), id)
|
||||||
|
questions, _ := h.store.ListQuizQuestions(c.Request.Context(), id)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"module": module,
|
||||||
|
"content": content,
|
||||||
|
"questions": questions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateModule creates a new training module
|
||||||
|
// POST /sdk/v1/training/modules
|
||||||
|
func (h *TrainingHandlers) CreateModule(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
var req training.CreateModuleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module := &training.TrainingModule{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ModuleCode: req.ModuleCode,
|
||||||
|
Title: req.Title,
|
||||||
|
Description: req.Description,
|
||||||
|
RegulationArea: req.RegulationArea,
|
||||||
|
NIS2Relevant: req.NIS2Relevant,
|
||||||
|
ISOControls: req.ISOControls,
|
||||||
|
FrequencyType: req.FrequencyType,
|
||||||
|
ValidityDays: req.ValidityDays,
|
||||||
|
RiskWeight: req.RiskWeight,
|
||||||
|
ContentType: req.ContentType,
|
||||||
|
DurationMinutes: req.DurationMinutes,
|
||||||
|
PassThreshold: req.PassThreshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
if module.ValidityDays == 0 {
|
||||||
|
module.ValidityDays = 365
|
||||||
|
}
|
||||||
|
if module.RiskWeight == 0 {
|
||||||
|
module.RiskWeight = 2.0
|
||||||
|
}
|
||||||
|
if module.ContentType == "" {
|
||||||
|
module.ContentType = "text"
|
||||||
|
}
|
||||||
|
if module.PassThreshold == 0 {
|
||||||
|
module.PassThreshold = 70
|
||||||
|
}
|
||||||
|
if module.ISOControls == nil {
|
||||||
|
module.ISOControls = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateModule(c.Request.Context(), module); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModule updates a training module
|
||||||
|
// PUT /sdk/v1/training/modules/:id
|
||||||
|
func (h *TrainingHandlers) UpdateModule(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req training.UpdateModuleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Title != nil {
|
||||||
|
module.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
module.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.NIS2Relevant != nil {
|
||||||
|
module.NIS2Relevant = *req.NIS2Relevant
|
||||||
|
}
|
||||||
|
if req.ISOControls != nil {
|
||||||
|
module.ISOControls = req.ISOControls
|
||||||
|
}
|
||||||
|
if req.ValidityDays != nil {
|
||||||
|
module.ValidityDays = *req.ValidityDays
|
||||||
|
}
|
||||||
|
if req.RiskWeight != nil {
|
||||||
|
module.RiskWeight = *req.RiskWeight
|
||||||
|
}
|
||||||
|
if req.DurationMinutes != nil {
|
||||||
|
module.DurationMinutes = *req.DurationMinutes
|
||||||
|
}
|
||||||
|
if req.PassThreshold != nil {
|
||||||
|
module.PassThreshold = *req.PassThreshold
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
module.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateModule(c.Request.Context(), module); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteModule deletes a training module
|
||||||
|
// DELETE /sdk/v1/training/modules/:id
|
||||||
|
func (h *TrainingHandlers) DeleteModule(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module, err := h.store.GetModule(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if module == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.DeleteModule(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Quiz Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetQuiz returns quiz questions for a module
|
||||||
|
// GET /sdk/v1/training/quiz/:moduleId
|
||||||
|
func (h *TrainingHandlers) GetQuiz(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
questions, err := h.store.ListQuizQuestions(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip correct_index for the student-facing response
|
||||||
|
type safeQuestion struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
Difficulty string `json:"difficulty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
safe := make([]safeQuestion, len(questions))
|
||||||
|
for i, q := range questions {
|
||||||
|
safe[i] = safeQuestion{
|
||||||
|
ID: q.ID,
|
||||||
|
Question: q.Question,
|
||||||
|
Options: q.Options,
|
||||||
|
Difficulty: string(q.Difficulty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"questions": safe,
|
||||||
|
"total": len(safe),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitQuiz submits quiz answers and returns the score
|
||||||
|
// POST /sdk/v1/training/quiz/:moduleId/submit
|
||||||
|
func (h *TrainingHandlers) SubmitQuiz(c *gin.Context) {
|
||||||
|
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
var req training.SubmitTrainingQuizRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the correct answers
|
||||||
|
questions, err := h.store.ListQuizQuestions(c.Request.Context(), moduleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build answer map
|
||||||
|
questionMap := make(map[uuid.UUID]training.QuizQuestion)
|
||||||
|
for _, q := range questions {
|
||||||
|
questionMap[q.ID] = q
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score the answers
|
||||||
|
correctCount := 0
|
||||||
|
totalCount := len(req.Answers)
|
||||||
|
scoredAnswers := make([]training.QuizAnswer, len(req.Answers))
|
||||||
|
|
||||||
|
for i, answer := range req.Answers {
|
||||||
|
q, exists := questionMap[answer.QuestionID]
|
||||||
|
correct := exists && answer.SelectedIndex == q.CorrectIndex
|
||||||
|
|
||||||
|
scoredAnswers[i] = training.QuizAnswer{
|
||||||
|
QuestionID: answer.QuestionID,
|
||||||
|
SelectedIndex: answer.SelectedIndex,
|
||||||
|
Correct: correct,
|
||||||
|
}
|
||||||
|
|
||||||
|
if correct {
|
||||||
|
correctCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score := float64(0)
|
||||||
|
if totalCount > 0 {
|
||||||
|
score = float64(correctCount) / float64(totalCount) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get module for pass threshold
|
||||||
|
module, _ := h.store.GetModule(c.Request.Context(), moduleID)
|
||||||
|
threshold := 70
|
||||||
|
if module != nil {
|
||||||
|
threshold = module.PassThreshold
|
||||||
|
}
|
||||||
|
passed := score >= float64(threshold)
|
||||||
|
|
||||||
|
// Record the attempt
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
attempt := &training.QuizAttempt{
|
||||||
|
AssignmentID: req.AssignmentID,
|
||||||
|
UserID: userID,
|
||||||
|
Answers: scoredAnswers,
|
||||||
|
Score: score,
|
||||||
|
Passed: passed,
|
||||||
|
CorrectCount: correctCount,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
DurationSeconds: req.DurationSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateQuizAttempt(c.Request.Context(), attempt); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update assignment quiz result
|
||||||
|
// Count total attempts
|
||||||
|
attempts, _ := h.store.ListQuizAttempts(c.Request.Context(), req.AssignmentID)
|
||||||
|
h.store.UpdateAssignmentQuizResult(c.Request.Context(), req.AssignmentID, score, passed, len(attempts))
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: &userID,
|
||||||
|
Action: training.AuditActionQuizSubmitted,
|
||||||
|
EntityType: training.AuditEntityQuiz,
|
||||||
|
EntityID: &attempt.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"module_id": moduleID.String(),
|
||||||
|
"score": score,
|
||||||
|
"passed": passed,
|
||||||
|
"correct_count": correctCount,
|
||||||
|
"total_count": totalCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.SubmitTrainingQuizResponse{
|
||||||
|
AttemptID: attempt.ID,
|
||||||
|
Score: score,
|
||||||
|
Passed: passed,
|
||||||
|
CorrectCount: correctCount,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Threshold: threshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuizAttempts returns quiz attempts for an assignment
|
||||||
|
// GET /sdk/v1/training/quiz/attempts/:assignmentId
|
||||||
|
func (h *TrainingHandlers) GetQuizAttempts(c *gin.Context) {
|
||||||
|
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := h.store.ListQuizAttempts(c.Request.Context(), assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"attempts": attempts,
|
||||||
|
"total": len(attempts),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Deadline / Escalation Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetDeadlines returns upcoming deadlines
|
||||||
|
// GET /sdk/v1/training/deadlines
|
||||||
|
func (h *TrainingHandlers) GetDeadlines(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deadlines, err := h.store.GetDeadlines(c.Request.Context(), tenantID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.DeadlineListResponse{
|
||||||
|
Deadlines: deadlines,
|
||||||
|
Total: len(deadlines),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOverdueDeadlines returns overdue assignments
|
||||||
|
// GET /sdk/v1/training/deadlines/overdue
|
||||||
|
func (h *TrainingHandlers) GetOverdueDeadlines(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
deadlines, err := training.GetOverdueDeadlines(c.Request.Context(), h.store, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.DeadlineListResponse{
|
||||||
|
Deadlines: deadlines,
|
||||||
|
Total: len(deadlines),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckEscalation runs the escalation check
|
||||||
|
// POST /sdk/v1/training/escalation/check
|
||||||
|
func (h *TrainingHandlers) CheckEscalation(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
results, err := training.CheckEscalations(c.Request.Context(), h.store, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overdueAll, _ := h.store.ListOverdueAssignments(c.Request.Context(), tenantID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.EscalationResponse{
|
||||||
|
Results: results,
|
||||||
|
TotalChecked: len(overdueAll),
|
||||||
|
Escalated: len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Audit / Stats Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetAuditLog returns the training audit trail
|
||||||
|
// GET /sdk/v1/training/audit-log
|
||||||
|
func (h *TrainingHandlers) GetAuditLog(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
filters := &training.AuditLogFilters{
|
||||||
|
Limit: 50,
|
||||||
|
Offset: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := c.Query("action"); v != "" {
|
||||||
|
filters.Action = training.AuditAction(v)
|
||||||
|
}
|
||||||
|
if v := c.Query("entity_type"); v != "" {
|
||||||
|
filters.EntityType = training.AuditEntityType(v)
|
||||||
|
}
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filters.Limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filters.Offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, total, err := h.store.ListAuditLog(c.Request.Context(), tenantID, filters)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, training.AuditLogResponse{
|
||||||
|
Entries: entries,
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns training dashboard statistics
|
||||||
|
// GET /sdk/v1/training/stats
|
||||||
|
func (h *TrainingHandlers) GetStats(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
stats, err := h.store.GetTrainingStats(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCertificate verifies a certificate
|
||||||
|
// GET /sdk/v1/training/certificates/:id/verify
|
||||||
|
func (h *TrainingHandlers) VerifyCertificate(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, assignment, err := training.VerifyCertificate(c.Request.Context(), h.store, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"valid": valid,
|
||||||
|
"assignment": assignment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Certificate Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GenerateCertificate generates a certificate for a completed assignment
|
||||||
|
// POST /sdk/v1/training/certificates/generate/:assignmentId
|
||||||
|
func (h *TrainingHandlers) GenerateCertificate(c *gin.Context) {
|
||||||
|
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
assignment, err := h.store.GetAssignment(c.Request.Context(), assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assignment == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignment.Status != training.AssignmentStatusCompleted {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "assignment is not completed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assignment.QuizPassed == nil || !*assignment.QuizPassed {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "quiz has not been passed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate certificate ID
|
||||||
|
certID := uuid.New()
|
||||||
|
if err := h.store.SetCertificateID(c.Request.Context(), assignmentID, certID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: &userID,
|
||||||
|
Action: training.AuditActionCertificateIssued,
|
||||||
|
EntityType: training.AuditEntityCertificate,
|
||||||
|
EntityID: &certID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"assignment_id": assignmentID.String(),
|
||||||
|
"user_name": assignment.UserName,
|
||||||
|
"module_title": assignment.ModuleTitle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload assignment with certificate_id
|
||||||
|
assignment, _ = h.store.GetAssignment(c.Request.Context(), assignmentID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"certificate_id": certID,
|
||||||
|
"assignment": assignment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadCertificatePDF generates and returns a PDF certificate
|
||||||
|
// GET /sdk/v1/training/certificates/:id/pdf
|
||||||
|
func (h *TrainingHandlers) DownloadCertificatePDF(c *gin.Context) {
|
||||||
|
certID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.store.GetAssignmentByCertificateID(c.Request.Context(), certID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assignment == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get module for title
|
||||||
|
module, _ := h.store.GetModule(c.Request.Context(), assignment.ModuleID)
|
||||||
|
courseName := assignment.ModuleTitle
|
||||||
|
if module != nil {
|
||||||
|
courseName = module.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
if assignment.QuizScore != nil {
|
||||||
|
score = int(*assignment.QuizScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
issuedAt := assignment.UpdatedAt
|
||||||
|
if assignment.CompletedAt != nil {
|
||||||
|
issuedAt = *assignment.CompletedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use academy PDF generator
|
||||||
|
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
|
||||||
|
CertificateID: certID.String(),
|
||||||
|
UserName: assignment.UserName,
|
||||||
|
CourseName: courseName,
|
||||||
|
Score: score,
|
||||||
|
IssuedAt: issuedAt,
|
||||||
|
ValidUntil: issuedAt.AddDate(1, 0, 0),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+certID.String()[:8]+".pdf")
|
||||||
|
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCertificates returns all certificates for a tenant
|
||||||
|
// GET /sdk/v1/training/certificates
|
||||||
|
func (h *TrainingHandlers) ListCertificates(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
certificates, err := h.store.ListCertificates(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"certificates": certificates,
|
||||||
|
"total": len(certificates),
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user