From 3f306fb6f0352bdf3e4cbd2c001f679556798cfd Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:17:20 +0200 Subject: [PATCH] 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 --- .../internal/api/handlers/iace_handler.go | 2584 +---------------- .../api/handlers/iace_handler_components.go | 387 +++ .../api/handlers/iace_handler_hazards.go | 469 +++ .../api/handlers/iace_handler_mitigations.go | 293 ++ .../api/handlers/iace_handler_monitoring.go | 134 + .../api/handlers/iace_handler_projects.go | 310 ++ .../internal/api/handlers/iace_handler_rag.go | 142 + .../api/handlers/iace_handler_refdata.go | 465 +++ .../api/handlers/iace_handler_techfile.go | 452 +++ .../api/handlers/training_handlers.go | 1841 ------------ .../handlers/training_handlers_assignments.go | 243 ++ .../api/handlers/training_handlers_blocks.go | 280 ++ .../api/handlers/training_handlers_content.go | 274 ++ .../api/handlers/training_handlers_matrix.go | 95 + .../api/handlers/training_handlers_media.go | 325 +++ .../api/handlers/training_handlers_modules.go | 226 ++ .../api/handlers/training_handlers_quiz.go | 185 ++ .../api/handlers/training_handlers_stats.go | 290 ++ 18 files changed, 4587 insertions(+), 4408 deletions(-) create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_components.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_mitigations.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_monitoring.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_projects.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_rag.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_refdata.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_assignments.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_blocks.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_content.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_matrix.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_media.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_modules.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_quiz.go create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_stats.go diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler.go b/ai-compliance-sdk/internal/api/handlers/iace_handler.go index 255f441..553b36a 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler.go @@ -3,7 +3,6 @@ package handlers import ( "encoding/json" "fmt" - "net/http" "strings" "github.com/breakpilot/ai-compliance-sdk/internal/iace" @@ -23,13 +22,13 @@ import ( // onboarding, regulatory classification, hazard/risk analysis, evidence management, // CE technical file generation, and post-market monitoring. type IACEHandler struct { - store *iace.Store - engine *iace.RiskEngine - classifier *iace.Classifier - checker *iace.CompletenessChecker - ragClient *ucca.LegalRAGClient - techFileGen *iace.TechFileGenerator - exporter *iace.DocumentExporter + store *iace.Store + engine *iace.RiskEngine + classifier *iace.Classifier + checker *iace.CompletenessChecker + ragClient *ucca.LegalRAGClient + techFileGen *iace.TechFileGenerator + exporter *iace.DocumentExporter } // NewIACEHandler creates a new IACEHandler with all required dependencies. @@ -67,1977 +66,6 @@ func getTenantID(c *gin.Context) (uuid.UUID, error) { return uuid.Parse(tenantStr) } -// ============================================================================ -// 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, - }) -} - -// 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}) -} - -// ============================================================================ -// Hazard & Risk -// ============================================================================ - -// 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), - }) -} - -// containsString checks if a string slice contains the given value. -func containsString(slice []string, val string) bool { - for _, s := range slice { - if s == val { - return true - } - } - return false -} - -// 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", - }) -} - -// 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}) -} - -// 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"}) -} - -// 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, - }) -} - -// ============================================================================ -// 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"}) -} - -// ============================================================================ -// 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", - }) - } -} - -// ============================================================================ -// 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), - }) -} - // ============================================================================ // Internal Helpers // ============================================================================ @@ -2088,6 +116,16 @@ func (h *IACEHandler) buildCompletenessContext( } } +// containsString checks if a string slice contains the given value. +func containsString(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} + // componentTypeKeys extracts keys from a map[string]bool and returns them as a sorted slice. func componentTypeKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) @@ -2108,594 +146,6 @@ func sortStrings(s []string) { } } -// ============================================================================ -// 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), - }) -} - -// ============================================================================ -// 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), - }) -} - // mustMarshalJSON marshals the given value to json.RawMessage. func mustMarshalJSON(v interface{}) json.RawMessage { data, err := json.Marshal(v) diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_components.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_components.go new file mode 100644 index 0000000..eb3329d --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_components.go @@ -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}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go new file mode 100644 index 0000000..1e44caa --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go @@ -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, + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_mitigations.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_mitigations.go new file mode 100644 index 0000000..253b15e --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_mitigations.go @@ -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"}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_monitoring.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_monitoring.go new file mode 100644 index 0000000..4778803 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_monitoring.go @@ -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), + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_projects.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_projects.go new file mode 100644 index 0000000..6858e90 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_projects.go @@ -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, + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_rag.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_rag.go new file mode 100644 index 0000000..3f85c28 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_rag.go @@ -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), + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_refdata.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_refdata.go new file mode 100644 index 0000000..4cf6a2d --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_refdata.go @@ -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), + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go new file mode 100644 index 0000000..8442c8f --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go @@ -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", + }) + } +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers.go b/ai-compliance-sdk/internal/api/handlers/training_handlers.go index be02332..3aef09d 100644 --- a/ai-compliance-sdk/internal/api/handlers/training_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers.go @@ -1,15 +1,7 @@ package handlers import ( - "net/http" - "strconv" - "time" - - "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" ) // TrainingHandlers handles training-related API requests @@ -29,1836 +21,3 @@ func NewTrainingHandlers(store *training.Store, contentGenerator *training.Conte ttsClient: ttsClient, } } - -// ============================================================================ -// 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"}) -} - -// ============================================================================ -// 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"}) -} - -// ============================================================================ -// 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"}) -} - -// ============================================================================ -// 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), - }) -} - -// ============================================================================ -// 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"}) -} - -// ============================================================================ -// 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, - }) -} - -// 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) -} - -// 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}) -} - -// 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) -} - -// ============================================================================ -// 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) -} - -// ============================================================================ -// Media Streaming Endpoint -// ============================================================================ - -// 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) -} - -// ============================================================================ -// 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), - }) -} - -// ============================================================================ -// Interactive Video Endpoints -// ============================================================================ - -// 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) -} - -// 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), - }) -} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_assignments.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_assignments.go new file mode 100644 index 0000000..35450c9 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_assignments.go @@ -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"}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_blocks.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_blocks.go new file mode 100644 index 0000000..d48cfeb --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_blocks.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_content.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_content.go new file mode 100644 index 0000000..87e8a37 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_content.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_matrix.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_matrix.go new file mode 100644 index 0000000..da05b2f --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_matrix.go @@ -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"}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_media.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_media.go new file mode 100644 index 0000000..afa0870 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_media.go @@ -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), + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_modules.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_modules.go new file mode 100644 index 0000000..de491f3 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_modules.go @@ -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"}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_quiz.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_quiz.go new file mode 100644 index 0000000..147fa44 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_quiz.go @@ -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), + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers_stats.go b/ai-compliance-sdk/internal/api/handlers/training_handlers_stats.go new file mode 100644 index 0000000..cbf7772 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers_stats.go @@ -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), + }) +}