package handlers import ( "encoding/csv" "encoding/json" "fmt" "net/http" "sort" "strings" "time" "github.com/breakpilot/ai-compliance-sdk/internal/iace" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // projectMetadataRoot is the shape we store inside iace_projects.metadata. // We only own the "clarification_answers" key; everything else is preserved // as opaque JSON so we don't trample on existing fields (limits_form, etc). type projectMetadataRoot map[string]json.RawMessage const clarificationAnswersKey = "clarification_answers" // readClarificationAnswers parses project.metadata and returns the // clarification_answers map. Missing/empty metadata yields an empty map. func readClarificationAnswers(meta json.RawMessage) (map[string]iace.ClarificationAnswer, projectMetadataRoot) { root := projectMetadataRoot{} if len(meta) > 0 { _ = json.Unmarshal(meta, &root) } answers := map[string]iace.ClarificationAnswer{} if raw, ok := root[clarificationAnswersKey]; ok && len(raw) > 0 { _ = json.Unmarshal(raw, &answers) } return answers, root } // reconstructHazardPatterns re-runs the pattern engine for the project's // narrative so we can map each hazard back to the patterns that fired for // it. The Hazard table itself doesn't persist the source-pattern list, so // this is the only way to know "which clarifications apply to which hazard". func (h *IACEHandler) reconstructHazardPatterns(narrative string, machineType string, hazards []iace.Hazard) map[uuid.UUID][]string { parsed := iace.ParseNarrative(narrative, machineType) compIDs := make([]string, 0, len(parsed.Components)) for _, c := range parsed.Components { compIDs = append(compIDs, c.LibraryID) } energyIDs := make([]string, 0, len(parsed.EnergySources)) for _, e := range parsed.EnergySources { energyIDs = append(energyIDs, e.SourceID) } engine := iace.NewPatternEngine() out := engine.Match(iace.MatchInput{ ComponentLibraryIDs: compIDs, EnergySourceIDs: energyIDs, LifecyclePhases: parsed.LifecyclePhases, CustomTags: parsed.CustomTags, OperationalStates: parsed.OperationalStates, StateTransitions: parsed.StateTransitions, HumanRoles: parsed.Roles, MachineTypes: []string{machineType}, }) // Map hazard.HazardousZone → set of HP-IDs by substring-matching the // pattern's ZoneDE. The hazard table doesn't keep a back-pointer to // the source pattern, so this approximation re-runs pattern matching // against the narrative and matches by normalised zone. hazardToPatterns := map[uuid.UUID][]string{} for _, hz := range hazards { hzZone := normalizeKey(hz.HazardousZone) if hzZone == "" { continue } for _, m := range out.MatchedPatterns { pz := normalizeKey(m.ZoneDE) if pz == "" { continue } if pz == hzZone || containsSubstring(hzZone, pz) || containsSubstring(pz, hzZone) { hazardToPatterns[hz.ID] = appendUnique(hazardToPatterns[hz.ID], m.PatternID) } } } return hazardToPatterns } func normalizeKey(s string) string { s = iace.NormalizeDEPublic(s) out := []rune{} for _, r := range s { switch r { case ',', '/', '(', ')', '-', '.', ':', ';': out = append(out, ' ') default: out = append(out, r) } } return string(out) } func appendUnique(slice []string, s string) []string { for _, x := range slice { if x == s { return slice } } return append(slice, s) } // ListClarifications handles GET /projects/:id/clarifications. // Returns the aggregated clarification list with affected-hazard cross-refs // and the persisted answer state. // // Phase 3 storage model: answers live in the iace_clarifications table // when migration 028 has been applied. The JSONB fallback in // project.metadata.clarification_answers is still read so projects that // were answered before the migration keep their state until the one-shot // upcopy runs. func (h *IACEHandler) ListClarifications(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return } ctx := c.Request.Context() project, err := h.store.GetProject(ctx, projectID) if err != nil || project == nil { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return } hazards, _ := h.store.ListHazards(ctx, projectID) // Primary: relational answers answers := map[string]iace.ClarificationAnswer{} if rows, rerr := h.store.ListClarificationsForProject(ctx, projectID); rerr == nil { for _, r := range rows { answeredAt := "" if r.AnsweredAt != nil { answeredAt = r.AnsweredAt.UTC().Format(time.RFC3339) } answers[r.ClarificationKey] = iace.ClarificationAnswer{ Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning, AnsweredBy: r.AnsweredBy, AnsweredAt: answeredAt, AssignedTo: r.AssignedTo, } } } // Fallback: JSONB legacy answers (keep until one-shot upcopy is done) if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 { for k, v := range legacy { if _, ok := answers[k]; !ok { answers[k] = v } } } narrative := extractNarrativeFromMetadata(project.Metadata) hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards) manufHits := iace.LookupManufacturerFeaturesInText(narrative) clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers) sort.Slice(clarifications, func(i, j int) bool { // Open first, then answered. Within a status, group by category, then by source. if clarifications[i].Status != clarifications[j].Status { return clarifications[i].Status == "open" } if clarifications[i].Category != clarifications[j].Category { return clarifications[i].Category < clarifications[j].Category } return clarifications[i].Source < clarifications[j].Source }) openCount, answeredCount := 0, 0 for _, cl := range clarifications { switch cl.Status { case "answered", "not_relevant": answeredCount++ default: openCount++ } } c.JSON(http.StatusOK, gin.H{ "clarifications": clarifications, "open_count": openCount, "answered_count": answeredCount, "total": len(clarifications), }) } // AnswerClarificationRequest is the request body for POST .../answer. type AnswerClarificationRequest struct { Status string `json:"status"` // open | in_progress | answered | not_relevant Answer string `json:"answer"` // ja | nein | teilweise Reasoning string `json:"reasoning"` AnsweredBy string `json:"answered_by"` AssignedTo string `json:"assigned_to"` // Snapshot fields written into the new table on first contact so the // audit trail does not break if the pattern library changes later. Question string `json:"question,omitempty"` Source string `json:"source,omitempty"` Category string `json:"category,omitempty"` NormReferences []string `json:"norm_references,omitempty"` } // AnswerClarification handles POST /projects/:id/clarifications/:cid/answer. // Upserts the answer in iace_clarifications (Phase 3). Old JSONB answers // remain readable but are no longer written. func (h *IACEHandler) AnswerClarification(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return } cid := c.Param("cid") if cid == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing clarification id"}) return } var req AnswerClarificationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Status == "" { if req.Answer != "" { req.Status = "answered" } else { req.Status = "open" } } ctx := c.Request.Context() project, err := h.store.GetProject(ctx, projectID) if err != nil || project == nil { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return } tenantID, terr := getTenantID(c) if terr != nil { tenantID = project.TenantID } // If the client didn't supply snapshot fields, fall back to whatever // the engine currently produces for this clarification id. if req.Question == "" || req.Source == "" { if prev, _ := h.store.GetClarificationByKey(ctx, projectID, cid); prev != nil { if req.Question == "" { req.Question = prev.Question } if req.Source == "" { req.Source = prev.Source } if req.Category == "" { req.Category = prev.Category } if len(req.NormReferences) == 0 { req.NormReferences = prev.NormReferences } } } now := time.Now().UTC() answeredAt := &now if req.Status != "answered" && req.Status != "not_relevant" { answeredAt = nil } in := iace.ClarificationRow{ TenantID: tenantID, ProjectID: projectID, ClarificationKey: cid, Question: req.Question, Source: req.Source, Category: req.Category, NormReferences: req.NormReferences, Status: req.Status, Answer: req.Answer, Reasoning: req.Reasoning, AssignedTo: req.AssignedTo, AnsweredBy: req.AnsweredBy, AnsweredAt: answeredAt, } row, err := h.store.UpsertClarification(ctx, in) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "clarification_id": cid, "row": row, }) } // CommentRequest is the body for POST .../comment. type CommentRequest struct { Author string `json:"author"` Body string `json:"body"` } // PostClarificationComment handles POST /projects/:id/clarifications/:cid/comment. func (h *IACEHandler) PostClarificationComment(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return } cid := c.Param("cid") var req CommentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Body == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "body required"}) return } ctx := c.Request.Context() row, err := h.store.GetClarificationByKey(ctx, projectID, cid) if err != nil || row == nil { c.JSON(http.StatusNotFound, gin.H{"error": "clarification not found — answer/assign it first to create the row"}) return } comment, err := h.store.AddClarificationComment(ctx, row.ID, req.Author, req.Body) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"comment": comment}) } // ListClarificationDetail handles GET /projects/:id/clarifications/:cid/detail // and returns comments + history for one clarification. func (h *IACEHandler) ListClarificationDetail(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return } cid := c.Param("cid") ctx := c.Request.Context() row, _ := h.store.GetClarificationByKey(ctx, projectID, cid) if row == nil { c.JSON(http.StatusOK, gin.H{"row": nil, "comments": []any{}, "history": []any{}}) return } comments, _ := h.store.ListClarificationComments(ctx, row.ID) history, _ := h.store.ListClarificationHistory(ctx, row.ID) c.JSON(http.StatusOK, gin.H{ "row": row, "comments": comments, "history": history, }) _ = json.RawMessage{} // keep encoding/json import in case of future fields } // ExportClarificationsCSV handles GET /projects/:id/clarifications.csv. // Returns the aggregated clarifications as a CSV for handover to the // Anlagenbauer — one row per question with all referenced hazards and // the current answer state. func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return } ctx := c.Request.Context() project, err := h.store.GetProject(ctx, projectID) if err != nil || project == nil { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return } hazards, _ := h.store.ListHazards(ctx, projectID) answers, _ := readClarificationAnswers(project.Metadata) narrative := extractNarrativeFromMetadata(project.Metadata) hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards) manufHits := iace.LookupManufacturerFeaturesInText(narrative) clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers) sort.Slice(clarifications, func(i, j int) bool { if clarifications[i].Status != clarifications[j].Status { return clarifications[i].Status == "open" } return clarifications[i].Source < clarifications[j].Source }) filename := fmt.Sprintf("klaerungen_%s_%s.csv", project.MachineName, time.Now().Format("2006-01-02")) filename = strings.ReplaceAll(filename, " ", "_") c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) // Excel-Erkennung: UTF-8 BOM voranstellen c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) w := csv.NewWriter(c.Writer) w.Comma = ';' _ = w.Write([]string{ "ID", "Quelle", "Kategorie", "Frage", "Status", "Antwort", "Begruendung", "Bearbeiter", "Beantwortet_am", "Anzahl_Gefaehrdungen", "Gefaehrdungen", "Norm_Referenzen", }) for _, cl := range clarifications { _ = w.Write([]string{ cl.ID, cl.Source, cl.Category, cl.Question, cl.Status, cl.Answer, cl.Reasoning, cl.AnsweredBy, cl.AnsweredAt, fmt.Sprintf("%d", len(cl.AffectedHazardIDs)), strings.Join(cl.AffectedHazardNames, " | "), strings.Join(cl.NormReferences, " | "), }) } w.Flush() } // methodologyBlock returns the standardised methodology paragraph that // must be printed at the start of every IACE risk-assessment report. // Pure references to norm identifiers (no norm text) — kept here so // the same wording appears in every export. const methodologyBlock = `

Methodik der Risikobeurteilung

` // ExportClarificationsHTML handles GET /projects/:id/clarifications.html // and returns a print-friendly standalone HTML document that the browser // can render to PDF (no server-side PDF dependency needed). The Bediener // opens the link, hits Cmd-P / Strg-P and saves as PDF. func (h *IACEHandler) ExportClarificationsHTML(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return } ctx := c.Request.Context() project, err := h.store.GetProject(ctx, projectID) if err != nil || project == nil { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return } hazards, _ := h.store.ListHazards(ctx, projectID) answers := map[string]iace.ClarificationAnswer{} if rows, _ := h.store.ListClarificationsForProject(ctx, projectID); rows != nil { for _, r := range rows { at := "" if r.AnsweredAt != nil { at = r.AnsweredAt.UTC().Format(time.RFC3339) } answers[r.ClarificationKey] = iace.ClarificationAnswer{ Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning, AnsweredBy: r.AnsweredBy, AnsweredAt: at, } } } if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 { for k, v := range legacy { if _, ok := answers[k]; !ok { answers[k] = v } } } narrative := extractNarrativeFromMetadata(project.Metadata) hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards) manufHits := iace.LookupManufacturerFeaturesInText(narrative) cls := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers) sort.Slice(cls, func(i, j int) bool { if cls[i].Status != cls[j].Status { return cls[i].Status == "open" } return cls[i].Source < cls[j].Source }) c.Header("Content-Type", "text/html; charset=utf-8") w := c.Writer fmt.Fprintf(w, ` Klaerungen — %s
Tipp: Mit Strg+P / Cmd+P als PDF speichern.

Klaerungsliste — %s

Projekt-ID %s · Stand %s
` + methodologyBlock + `
%d offen %d beantwortet %d gesamt
`, htmlEscape(project.MachineName), htmlEscape(project.MachineName), project.ID.String(), time.Now().Format("2006-01-02 15:04"), countByStatus(cls, false), countByStatus(cls, true), len(cls), ) for _, cl := range cls { statusCls := "open" statusLabel := "Offen" if cl.Status == "answered" { statusCls, statusLabel = "done", "Beantwortet" } else if cl.Status == "not_relevant" { statusCls, statusLabel = "gray", "Nicht relevant" } else if cl.Status == "in_progress" { statusCls, statusLabel = "open", "In Klaerung" } fmt.Fprintf(w, `
%s · %s

%s

`, htmlEscape(cl.Source), statusCls, statusLabel, htmlEscape(cl.Question), ) if len(cl.NormReferences) > 0 { fmt.Fprintf(w, `
Normen: %s
`, htmlEscape(strings.Join(cl.NormReferences, " | "))) } if len(cl.AffectedHazardNames) > 0 { fmt.Fprintf(w, `
Betrifft %d Gefaehrdung(en): %s
`, len(cl.AffectedHazardIDs), htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")), ) } if cl.Status == "answered" || cl.Status == "not_relevant" { fmt.Fprintf(w, `
Antwort (%s): %s`, htmlEscape(cl.Answer), htmlEscape(cl.Reasoning), ) if cl.AnsweredBy != "" { ts := cl.AnsweredAt if len(ts) > 10 { ts = ts[:10] } fmt.Fprintf(w, ` — %s, %s`, htmlEscape(cl.AnsweredBy), htmlEscape(ts)) } fmt.Fprintf(w, `
`) } fmt.Fprintf(w, `
`) } fmt.Fprintf(w, `
Anlagenbauer · Datum · Unterschrift
Bediener · Datum · Unterschrift
`) fmt.Fprintf(w, ``) } func htmlEscape(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, `'`, "'") return r.Replace(s) } func countByStatus(cls []iace.Clarification, answered bool) int { n := 0 for _, c := range cls { isDone := c.Status == "answered" || c.Status == "not_relevant" if isDone == answered { n++ } } return n }