feat: IACE CE-Compliance Module — Normen, Risikobewertung, Production Lines

Major features:
- 215 norms library with section references + Beuth URLs (A/B1/B2/C norms)
- 173 hazard patterns with detail fields (scenario, trigger, harm, zone)
- Deterministic pattern matching: Component × Lifecycle × Pattern cross-product
- SIL/PL auto-calculation from S×E×P risk graph
- Risk assessment table with editable S/E/P dropdowns
- Production Line Dashboard with animated station flow (Running Dots)
- IACE process flow + norms coverage on start page
- Non-blocking cookie banner, ProcessFlow SSR fix
- 104 Playwright E2E tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-07 10:53:26 +02:00
parent 3853a0838a
commit e7f2f98da3
59 changed files with 8326 additions and 525 deletions
@@ -148,9 +148,25 @@ func (h *IACEHandler) ListHazards(c *gin.Context) {
hazards = []iace.Hazard{}
}
// Enrich hazards with latest risk assessment
type enrichedHazard struct {
iace.Hazard
RiskAssessment interface{} `json:"risk_assessment"`
}
enriched := make([]enrichedHazard, len(hazards))
for i, hz := range hazards {
enriched[i] = enrichedHazard{Hazard: hz}
// Get latest assessment for this hazard
assessments, err := h.store.ListAssessments(c.Request.Context(), hz.ID)
if err == nil && len(assessments) > 0 {
enriched[i].RiskAssessment = assessments[len(assessments)-1]
}
}
c.JSON(http.StatusOK, gin.H{
"hazards": hazards,
"total": len(hazards),
"hazards": enriched,
"total": len(enriched),
})
}
@@ -14,6 +14,31 @@ import (
// Mitigations
// ============================================================================
// ListProjectMitigations handles GET /projects/:id/mitigations
// Returns all mitigations for all hazards in a project.
func (h *IACEHandler) ListProjectMitigations(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
mitigations, err := h.store.ListMitigationsByProject(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if mitigations == nil {
mitigations = []iace.Mitigation{}
}
c.JSON(http.StatusOK, gin.H{
"mitigations": mitigations,
"total": len(mitigations),
})
}
// CreateMitigation handles POST /projects/:id/hazards/:hid/mitigations
// Creates a new mitigation measure for a hazard.
func (h *IACEHandler) CreateMitigation(c *gin.Context) {
@@ -88,6 +113,23 @@ func (h *IACEHandler) UpdateMitigation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"mitigation": mitigation})
}
// DeleteMitigation handles DELETE /projects/:id/mitigations/:mid
// Deletes a mitigation by ID.
func (h *IACEHandler) DeleteMitigation(c *gin.Context) {
mitigationID, err := uuid.Parse(c.Param("mid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mitigation ID"})
return
}
if err := h.store.DeleteMitigation(c.Request.Context(), mitigationID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "mitigation deleted"})
}
// VerifyMitigation handles POST /mitigations/:mid/verify
// Marks a mitigation as verified with a verification result.
func (h *IACEHandler) VerifyMitigation(c *gin.Context) {
@@ -0,0 +1,196 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Norms Library & Norm Suggestions
// ============================================================================
// ListNormsLibrary handles GET /norms-library
// Returns the full norms library, optionally filtered by ?type=A|B1|B2|C
// and ?hazard_category=xxx.
func (h *IACEHandler) ListNormsLibrary(c *gin.Context) {
normType := c.Query("type")
hazardCat := c.Query("hazard_category")
allNorms := iace.GetNormsLibrary()
allNorms = append(allNorms, iace.GetExtendedB2Norms()...)
allNorms = append(allNorms, iace.GetCNormsLibrary()...)
allNorms = append(allNorms, iace.GetExtendedCNormsLibrary()...)
allNorms = append(allNorms, iace.GetWoodMetalCNorms()...)
allNorms = append(allNorms, iace.GetFoodPkgCNorms()...)
allNorms = append(allNorms, iace.GetLiftMiscCNorms()...)
var filtered []iace.NormReference
for _, norm := range allNorms {
if normType != "" && norm.NormType != normType {
continue
}
if hazardCat != "" && !containsString(norm.HazardCats, hazardCat) {
continue
}
filtered = append(filtered, norm)
}
if filtered == nil {
filtered = []iace.NormReference{}
}
c.JSON(http.StatusOK, gin.H{
"norms": filtered,
"total": len(filtered),
})
}
// SuggestProjectNorms handles GET /projects/:id/suggested-norms
// Returns norm suggestions based on the project's machine type, identified
// hazards, and component tags.
func (h *IACEHandler) SuggestProjectNorms(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
// Fetch project to get machine type
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
}
// Collect unique hazard categories from project hazards
hazardCategories := collectHazardCategories(h, c, projectID)
// Collect tags from component metadata
tags := collectComponentTags(h, c, projectID)
result := iace.SuggestNorms(project.MachineType, hazardCategories, tags)
c.JSON(http.StatusOK, gin.H{
"suggestions": result,
"machine_type": project.MachineType,
"hazard_categories": hazardCategories,
"tags": tags,
})
}
// collectHazardCategories extracts unique hazard categories from a project's hazards.
func collectHazardCategories(h *IACEHandler, c *gin.Context, projectID uuid.UUID) []string {
hazards, err := h.store.ListHazards(c.Request.Context(), projectID)
if err != nil {
return []string{}
}
seen := make(map[string]bool)
var categories []string
for _, hz := range hazards {
if hz.Category != "" && !seen[hz.Category] {
seen[hz.Category] = true
categories = append(categories, hz.Category)
}
}
return categories
}
// collectComponentTags extracts tags from the metadata JSON of project components.
// Components store tags in their metadata field as {"tags": ["tag1", "tag2"]}.
// Additionally, component types are mapped to tags via the component library.
func collectComponentTags(h *IACEHandler, c *gin.Context, projectID uuid.UUID) []string {
components, err := h.store.ListComponents(c.Request.Context(), projectID)
if err != nil {
return []string{}
}
seen := make(map[string]bool)
var tags []string
for _, comp := range components {
// Extract tags from metadata JSON
extracted := extractTagsFromMetadata(comp.Metadata)
for _, t := range extracted {
if !seen[t] {
seen[t] = true
tags = append(tags, t)
}
}
// Extract tags from description field ("Tags: x, y, z" pattern)
descTags := parseDescriptionTags(comp.Description)
for _, t := range descTags {
if !seen[t] {
seen[t] = true
tags = append(tags, t)
}
}
}
return tags
}
// extractTagsFromMetadata parses the component metadata JSON for a "tags" array.
func extractTagsFromMetadata(metadata json.RawMessage) []string {
if len(metadata) == 0 {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal(metadata, &m); err != nil {
return nil
}
tagsRaw, ok := m["tags"]
if !ok {
return nil
}
arr, ok := tagsRaw.([]interface{})
if !ok {
return nil
}
var tags []string
for _, v := range arr {
if s, ok := v.(string); ok && s != "" {
tags = append(tags, s)
}
}
return tags
}
// parseDescriptionTags looks for a "Tags: x, y, z" pattern in the description.
func parseDescriptionTags(description string) []string {
idx := strings.Index(strings.ToLower(description), "tags:")
if idx < 0 {
return nil
}
// Take everything after "Tags:"
rest := strings.TrimSpace(description[idx+5:])
if rest == "" {
return nil
}
// Split by comma and trim each tag
parts := strings.Split(rest, ",")
var tags []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
tags = append(tags, t)
}
}
return tags
}
@@ -0,0 +1,156 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Production Line Handlers
// ============================================================================
// CreateProductionLine handles POST /iace/production-lines
// Creates a new production line for the authenticated tenant.
func (h *IACEHandler) CreateProductionLine(c *gin.Context) {
tenantID, err := getTenantID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req iace.CreateProductionLineRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
line, err := h.store.CreateProductionLine(c.Request.Context(), tenantID, req.Name, req.Description)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"line": line})
}
// ListProductionLines handles GET /iace/production-lines
// Lists all production lines for the authenticated tenant.
func (h *IACEHandler) ListProductionLines(c *gin.Context) {
tenantID, err := getTenantID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
lines, err := h.store.ListProductionLines(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lines == nil {
lines = []iace.ProductionLine{}
}
c.JSON(http.StatusOK, iace.ProductionLineListResponse{
Lines: lines,
Total: len(lines),
})
}
// GetProductionLineDashboard handles GET /iace/production-lines/:lid/dashboard
// Returns the aggregated risk dashboard for a production line.
func (h *IACEHandler) GetProductionLineDashboard(c *gin.Context) {
lineID, err := uuid.Parse(c.Param("lid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line ID"})
return
}
dashboard, err := h.store.GetLineDashboard(c.Request.Context(), lineID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if dashboard == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "production line not found"})
return
}
c.JSON(http.StatusOK, dashboard)
}
// AddStationToLine handles POST /iace/production-lines/:lid/stations
// Adds a station (IACE project reference) to a production line.
func (h *IACEHandler) AddStationToLine(c *gin.Context) {
lineID, err := uuid.Parse(c.Param("lid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line ID"})
return
}
var req iace.AddStationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify the production line exists
line, err := h.store.GetProductionLine(c.Request.Context(), lineID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if line == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "production line not found"})
return
}
// Verify the referenced project exists
project, err := h.store.GetProject(c.Request.Context(), req.ProjectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if project == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "referenced project not found"})
return
}
station, err := h.store.AddStation(
c.Request.Context(), lineID, req.ProjectID,
req.StationType, req.StationLabel, req.SortOrder,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"station": station})
}
// RemoveStationFromLine handles DELETE /iace/production-lines/:lid/stations/:sid
// Removes a station from a production line.
func (h *IACEHandler) RemoveStationFromLine(c *gin.Context) {
_, err := uuid.Parse(c.Param("lid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line ID"})
return
}
stationID, err := uuid.Parse(c.Param("sid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid station ID"})
return
}
if err := h.store.RemoveStation(c.Request.Context(), stationID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "station removed"})
}