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:
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user