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