feat(iace): Klaerungen Phase 3 — DB-Tabelle + Multi-User + PDF-Export

[migration-approved]

Three pieces complete the Klaerungen lifecycle:

1. Migration 028: iace_clarifications + iace_clarification_comments +
   iace_clarification_history. Deterministic clarification_key
   (UNIQUE per project) so engine re-inits don't lose answers.
   History table logs every status/answer transition. The previous
   JSONB-in-metadata storage is kept as read-only fallback for
   pre-migration projects until a one-shot upcopy script runs.

2. Multi-User-Workflow:
   - assigned_to field on every clarification (free-text user kuerzel
     for now; an FK to users can be added in a follow-up).
   - Comment thread per clarification (POST .../comment, GET
     .../detail returns the thread).
   - Status-history log written by UpsertClarification when the
     status or answer actually changes.
   - Frontend Modal: Zugewiesen-an + Bearbeiter fields, comment
     thread with inline post, collapsible history section.

3. PDF-Export via print-friendly HTML:
   - GET /clarifications.html returns a standalone A4-styled
     document with status badges, norm references, affected hazards
     and a signature row at the bottom. The Bediener opens the link
     and uses Strg-P / Cmd-P to save as PDF. No server-side PDF
     dependency added.
   - Frontend "PDF / Druck" button next to CSV export.

Backend:
- internal/iace/store_clarifications.go: UpsertClarification,
  ListClarificationsForProject, GetClarificationByKey,
  AddClarificationComment, ListClarificationComments,
  ListClarificationHistory.
- internal/api/handlers/iace_handler_clarifications.go:
  - AnswerClarification now writes the SQL row, falls back to legacy
    JSONB read on list.
  - PostClarificationComment, ListClarificationDetail,
    ExportClarificationsHTML added.

Migration must be applied manually on Mac Mini and prod via
psql -f /migrations/028_iace_clarifications.sql — pattern as in
scripts/apply_*_migration.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 01:39:17 +02:00
parent b2b4d77877
commit c4be077c5d
6 changed files with 778 additions and 29 deletions
@@ -16,6 +16,7 @@ type Clarification = {
reasoning?: string
answered_by?: string
answered_at?: string
assigned_to?: string
}
type ListResponse = {
@@ -117,7 +118,19 @@ export default function ClarificationsPage() {
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
CSV-Export
CSV
</a>
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.html`}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="Druckansicht öffnen — mit Strg/Cmd-P als PDF speichern"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF / Druck
</a>
</div>
</div>
@@ -220,13 +233,23 @@ function Badge({ color, label }: { color: string; label: string }) {
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
}
type Comment = { id: string; author: string; body: string; created_at: string }
type HistoryEntry = {
actor: string
from_status?: string
to_status?: string
from_answer?: string
to_answer?: string
created_at: string
}
function AnswerModal({
clarification,
projectId,
onClose,
onSaved,
}: {
clarification: Clarification
clarification: Clarification & { assigned_to?: string }
projectId: string
onClose: () => void
onSaved: () => void
@@ -237,9 +260,26 @@ function AnswerModal({
)
const [reasoning, setReasoning] = useState(clarification.reasoning || '')
const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '')
const [assignedTo, setAssignedTo] = useState(clarification.assigned_to || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [history, setHistory] = useState<HistoryEntry[]>([])
const [newComment, setNewComment] = useState('')
const [postingComment, setPostingComment] = useState(false)
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/detail`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return
setComments(d.comments || [])
setHistory(d.history || [])
})
.catch(() => {})
}, [projectId, clarification.id])
const save = async () => {
setSaving(true)
setError(null)
@@ -249,7 +289,15 @@ function AnswerModal({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, answer, reasoning, answered_by: answeredBy }),
body: JSON.stringify({
status, answer, reasoning,
answered_by: answeredBy,
assigned_to: assignedTo,
question: clarification.question,
source: clarification.source,
category: clarification.category,
norm_references: clarification.norm_references,
}),
}
)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
@@ -261,12 +309,59 @@ function AnswerModal({
}
}
const postComment = async () => {
if (!newComment.trim()) return
setPostingComment(true)
try {
const r = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/comment`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: answeredBy || assignedTo || 'unbekannt', body: newComment }),
}
)
if (r.ok) {
const d = await r.json()
if (d.comment) setComments(prev => [...prev, d.comment])
setNewComment('')
} else {
setError(`Kommentar HTTP ${r.status} — bitte zuerst Status setzen, damit der Klärungs-Datensatz angelegt wird.`)
}
} finally {
setPostingComment(false)
}
}
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg max-w-xl w-full p-5 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4 overflow-y-auto" onClick={onClose}>
<div className="bg-white rounded-lg max-w-2xl w-full p-5 shadow-xl my-8" onClick={e => e.stopPropagation()}>
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
<div className="text-base font-medium mb-4">{clarification.question}</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Zugewiesen an</label>
<input
type="text"
value={assignedTo}
onChange={e => setAssignedTo(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="z.B. anlagenbauer@fanuc.de"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
<input
type="text"
value={answeredBy}
onChange={e => setAnsweredBy(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="Name oder Kürzel"
/>
</div>
</div>
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
<div className="flex gap-1 mb-3 text-sm">
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
@@ -302,18 +397,53 @@ function AnswerModal({
value={reasoning}
onChange={e => setReasoning(e.target.value)}
rows={4}
className="w-full border rounded p-2 text-sm mb-3"
className="w-full border rounded p-2 text-sm mb-4"
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
/>
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
<input
type="text"
value={answeredBy}
onChange={e => setAnsweredBy(e.target.value)}
className="w-full border rounded p-2 text-sm mb-4"
placeholder="Name oder Kürzel"
/>
{/* Comment Thread */}
<div className="border-t pt-3 mt-3 mb-3">
<div className="text-xs font-medium text-gray-700 mb-2">Diskussion ({comments.length})</div>
<div className="space-y-2 max-h-40 overflow-y-auto mb-2">
{comments.map(c => (
<div key={c.id} className="text-xs bg-gray-50 rounded p-2">
<div className="font-medium text-gray-700">{c.author || 'anonym'} <span className="text-gray-400 font-normal">· {c.created_at.slice(0, 16).replace('T', ' ')}</span></div>
<div className="text-gray-700 whitespace-pre-wrap">{c.body}</div>
</div>
))}
{comments.length === 0 && <div className="text-xs text-gray-400 italic">Noch keine Kommentare.</div>}
</div>
<div className="flex gap-1">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
placeholder="Kommentar hinzufügen..."
className="flex-1 border rounded px-2 py-1.5 text-xs"
onKeyDown={e => { if (e.key === 'Enter') postComment() }}
/>
<button
onClick={postComment}
disabled={postingComment || !newComment.trim()}
className="px-3 py-1 rounded bg-gray-700 text-white text-xs hover:bg-gray-800 disabled:opacity-50"
>Senden</button>
</div>
</div>
{history.length > 0 && (
<details className="mb-3 text-xs">
<summary className="cursor-pointer text-gray-600 hover:text-gray-800">Verlauf ({history.length})</summary>
<div className="mt-1 space-y-1 text-gray-600">
{history.map((h, i) => (
<div key={i} className="border-l-2 border-gray-200 pl-2">
<span className="text-gray-400">{h.created_at.slice(0, 16).replace('T', ' ')}</span> ·
<strong> {h.actor || 'unbekannt'}</strong>: {h.from_status} {h.to_status}
{h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'}${h.to_answer || '—'})`}
</div>
))}
</div>
</details>
)}
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}
@@ -110,6 +110,12 @@ func appendUnique(slice []string, s string) []string {
// 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 {
@@ -124,7 +130,32 @@ func (h *IACEHandler) ListClarifications(c *gin.Context) {
}
hazards, _ := h.store.ListHazards(ctx, projectID)
answers, _ := readClarificationAnswers(project.Metadata)
// 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)
@@ -164,11 +195,18 @@ type AnswerClarificationRequest struct {
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.
// Stores the answer in project.metadata.clarification_answers — no schema
// change required.
// 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 {
@@ -199,27 +237,123 @@ func (h *IACEHandler) AnswerClarification(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
answers, root := readClarificationAnswers(project.Metadata)
answers[cid] = iace.ClarificationAnswer{
Status: req.Status,
Answer: req.Answer,
Reasoning: req.Reasoning,
AnsweredBy: req.AnsweredBy,
AnsweredAt: time.Now().UTC().Format(time.RFC3339),
tenantID, terr := getTenantID(c)
if terr != nil {
tenantID = project.TenantID
}
answersJSON, _ := json.Marshal(answers)
root[clarificationAnswersKey] = answersJSON
merged, _ := json.Marshal(root)
if err := h.store.UpdateProjectMetadata(ctx, projectID, merged); err != nil {
// 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,
"answer": answers[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
@@ -280,3 +414,153 @@ func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) {
}
w.Flush()
}
// 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, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<title>Klaerungen — %s</title>
<style>
@page { size: A4; margin: 18mm 15mm; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 10pt; color: #111; line-height: 1.4; }
h1 { font-size: 16pt; margin: 0 0 4px 0; }
.sub { font-size: 9pt; color: #555; margin-bottom: 16px; }
.meta { font-size: 9pt; color: #444; margin-bottom: 12px; }
.bar { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8pt; margin-right: 4px; }
.open { background: #fed7aa; color: #7c2d12; }
.done { background: #bbf7d0; color: #14532d; }
.gray { background: #e5e7eb; color: #374151; }
section { page-break-inside: avoid; margin-bottom: 14px; border: 1px solid #d1d5db; border-radius: 6px; padding: 10px 12px; }
section h2 { font-size: 10pt; margin: 0 0 2px 0; }
section .src { font-size: 8pt; color: #6b7280; margin-bottom: 6px; }
.q { font-weight: 600; font-size: 10.5pt; margin: 4px 0; }
.norm { font-size: 8pt; color: #555; }
.affected { font-size: 8pt; color: #555; margin: 4px 0; }
.answer { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 6px 8px; border-radius: 4px; font-size: 9pt; margin-top: 6px; }
.signrow { margin-top: 30px; display: flex; gap: 40px; }
.signrow div { flex: 1; border-top: 1px solid #6b7280; padding-top: 4px; font-size: 8pt; color: #6b7280; }
@media print { .noprint { display: none; } }
.noprint { background: #fef9c3; border: 1px solid #fde047; padding: 6px 10px; border-radius: 4px; margin-bottom: 12px; font-size: 9pt; }
</style></head><body>
<div class="noprint">Tipp: Mit <kbd>Strg+P</kbd> / <kbd>Cmd+P</kbd> als PDF speichern.</div>
<h1>Klaerungsliste — %s</h1>
<div class="sub">Projekt-ID %s · Stand %s</div>
<div class="meta">
<span class="bar open">%d offen</span>
<span class="bar done">%d beantwortet</span>
<span class="bar gray">%d gesamt</span>
</div>
`,
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, `<section><div class="src">%s · <span class="bar %s">%s</span></div>
<h2>%s</h2>
`,
htmlEscape(cl.Source), statusCls, statusLabel,
htmlEscape(cl.Question),
)
if len(cl.NormReferences) > 0 {
fmt.Fprintf(w, `<div class="norm">Normen: %s</div>`, htmlEscape(strings.Join(cl.NormReferences, " | ")))
}
if len(cl.AffectedHazardNames) > 0 {
fmt.Fprintf(w, `<div class="affected">Betrifft %d Gefaehrdung(en): %s</div>`,
len(cl.AffectedHazardIDs),
htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")),
)
}
if cl.Status == "answered" || cl.Status == "not_relevant" {
fmt.Fprintf(w, `<div class="answer"><strong>Antwort (%s):</strong> %s`,
htmlEscape(cl.Answer),
htmlEscape(cl.Reasoning),
)
if cl.AnsweredBy != "" {
ts := cl.AnsweredAt
if len(ts) > 10 {
ts = ts[:10]
}
fmt.Fprintf(w, ` <em>— %s, %s</em>`, htmlEscape(cl.AnsweredBy), htmlEscape(ts))
}
fmt.Fprintf(w, `</div>`)
}
fmt.Fprintf(w, `</section>`)
}
fmt.Fprintf(w, `<div class="signrow"><div>Anlagenbauer · Datum · Unterschrift</div><div>Bediener · Datum · Unterschrift</div></div>`)
fmt.Fprintf(w, `</body></html>`)
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", `'`, "&#39;")
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
}
+3
View File
@@ -455,7 +455,10 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
// Clarifications — aggregated open questions per project
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
}
}
@@ -33,6 +33,7 @@ type Clarification struct {
Reasoning string `json:"reasoning,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
}
// ClarificationAnswer is the persisted shape (one entry in
@@ -43,6 +44,7 @@ type ClarificationAnswer struct {
Reasoning string `json:"reasoning,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
}
// BuildProjectClarifications walks the project's current hazards and returns
@@ -154,6 +156,7 @@ func BuildProjectClarifications(
b.Reasoning = ans.Reasoning
b.AnsweredBy = ans.AnsweredBy
b.AnsweredAt = ans.AnsweredAt
b.AssignedTo = ans.AssignedTo
}
// dedup hazard IDs (multiple patterns can target the same hazard)
b.AffectedHazardIDs = dedupUUIDs(b.AffectedHazardIDs)
@@ -0,0 +1,241 @@
package iace
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ClarificationRow is the persisted shape (one row per [project, clarification_key]).
type ClarificationRow struct {
ID uuid.UUID
TenantID uuid.UUID
ProjectID uuid.UUID
ClarificationKey string
Question string
Source string
Category string
NormReferences []string
Status string
Answer string
Reasoning string
AssignedTo string
AnsweredBy string
AnsweredAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// ClarificationComment is a single comment on a clarification.
type ClarificationComment struct {
ID uuid.UUID
ClarificationID uuid.UUID
Author string
Body string
CreatedAt time.Time
}
// ClarificationHistoryEntry logs status/answer transitions.
type ClarificationHistoryEntry struct {
ID uuid.UUID
ClarificationID uuid.UUID
Actor string
FromStatus string
ToStatus string
FromAnswer string
ToAnswer string
Note string
CreatedAt time.Time
}
// UpsertClarification creates or updates a clarification row by
// (project_id, clarification_key) and logs the status/answer transition
// in iace_clarification_history when something changes.
func (s *Store) UpsertClarification(ctx context.Context, in ClarificationRow) (*ClarificationRow, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin upsert clarification: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Fetch existing for history diff
var prev ClarificationRow
prevErr := tx.QueryRow(ctx, `
SELECT id, status, answer
FROM iace_clarifications
WHERE project_id = $1 AND clarification_key = $2
`, in.ProjectID, in.ClarificationKey).Scan(&prev.ID, &prev.Status, &prev.Answer)
hadPrev := prevErr == nil
if prevErr != nil && !errors.Is(prevErr, pgx.ErrNoRows) && !errors.Is(prevErr, sql.ErrNoRows) {
return nil, fmt.Errorf("lookup existing clarification: %w", prevErr)
}
var row ClarificationRow
err = tx.QueryRow(ctx, `
INSERT INTO iace_clarifications (
tenant_id, project_id, clarification_key, question, source, category,
norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (project_id, clarification_key) DO UPDATE SET
question = EXCLUDED.question,
source = EXCLUDED.source,
category = EXCLUDED.category,
norm_references = EXCLUDED.norm_references,
status = EXCLUDED.status,
answer = EXCLUDED.answer,
reasoning = EXCLUDED.reasoning,
assigned_to = EXCLUDED.assigned_to,
answered_by = EXCLUDED.answered_by,
answered_at = EXCLUDED.answered_at
RETURNING id, tenant_id, project_id, clarification_key, question, source, category,
norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at,
created_at, updated_at
`,
in.TenantID, in.ProjectID, in.ClarificationKey, in.Question, in.Source, in.Category,
in.NormReferences, in.Status, in.Answer, in.Reasoning, in.AssignedTo, in.AnsweredBy, in.AnsweredAt,
).Scan(
&row.ID, &row.TenantID, &row.ProjectID, &row.ClarificationKey, &row.Question, &row.Source, &row.Category,
&row.NormReferences, &row.Status, &row.Answer, &row.Reasoning, &row.AssignedTo, &row.AnsweredBy, &row.AnsweredAt,
&row.CreatedAt, &row.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("upsert clarification: %w", err)
}
// Log transition iff something changed
if hadPrev && (prev.Status != row.Status || prev.Answer != row.Answer) {
_, err = tx.Exec(ctx, `
INSERT INTO iace_clarification_history (clarification_id, actor, from_status, to_status, from_answer, to_answer, note)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, row.ID, in.AnsweredBy, prev.Status, row.Status, prev.Answer, row.Answer, "")
if err != nil {
return nil, fmt.Errorf("write history: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit upsert clarification: %w", err)
}
return &row, nil
}
// ListClarificationsForProject returns all clarification rows for a project.
func (s *Store) ListClarificationsForProject(ctx context.Context, projectID uuid.UUID) ([]ClarificationRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, project_id, clarification_key, question, source, category,
norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at,
created_at, updated_at
FROM iace_clarifications
WHERE project_id = $1
ORDER BY status, source, created_at
`, projectID)
if err != nil {
return nil, fmt.Errorf("list clarifications: %w", err)
}
defer rows.Close()
out := []ClarificationRow{}
for rows.Next() {
var r ClarificationRow
if err := rows.Scan(
&r.ID, &r.TenantID, &r.ProjectID, &r.ClarificationKey, &r.Question, &r.Source, &r.Category,
&r.NormReferences, &r.Status, &r.Answer, &r.Reasoning, &r.AssignedTo, &r.AnsweredBy, &r.AnsweredAt,
&r.CreatedAt, &r.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// GetClarificationByKey fetches a clarification by project + key.
func (s *Store) GetClarificationByKey(ctx context.Context, projectID uuid.UUID, key string) (*ClarificationRow, error) {
var r ClarificationRow
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, project_id, clarification_key, question, source, category,
norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at,
created_at, updated_at
FROM iace_clarifications
WHERE project_id = $1 AND clarification_key = $2
`, projectID, key).Scan(
&r.ID, &r.TenantID, &r.ProjectID, &r.ClarificationKey, &r.Question, &r.Source, &r.Category,
&r.NormReferences, &r.Status, &r.Answer, &r.Reasoning, &r.AssignedTo, &r.AnsweredBy, &r.AnsweredAt,
&r.CreatedAt, &r.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get clarification: %w", err)
}
return &r, nil
}
// AddClarificationComment appends a comment to the thread.
func (s *Store) AddClarificationComment(ctx context.Context, clarID uuid.UUID, author, body string) (*ClarificationComment, error) {
var c ClarificationComment
err := s.pool.QueryRow(ctx, `
INSERT INTO iace_clarification_comments (clarification_id, author, body)
VALUES ($1, $2, $3)
RETURNING id, clarification_id, author, body, created_at
`, clarID, author, body).Scan(&c.ID, &c.ClarificationID, &c.Author, &c.Body, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("add clarification comment: %w", err)
}
return &c, nil
}
// ListClarificationComments returns the comment thread, oldest first.
func (s *Store) ListClarificationComments(ctx context.Context, clarID uuid.UUID) ([]ClarificationComment, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, clarification_id, author, body, created_at
FROM iace_clarification_comments
WHERE clarification_id = $1
ORDER BY created_at
`, clarID)
if err != nil {
return nil, fmt.Errorf("list clarification comments: %w", err)
}
defer rows.Close()
out := []ClarificationComment{}
for rows.Next() {
var c ClarificationComment
if err := rows.Scan(&c.ID, &c.ClarificationID, &c.Author, &c.Body, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ListClarificationHistory returns the audit trail entries for a clarification.
func (s *Store) ListClarificationHistory(ctx context.Context, clarID uuid.UUID) ([]ClarificationHistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, clarification_id, actor, from_status, to_status, from_answer, to_answer, note, created_at
FROM iace_clarification_history
WHERE clarification_id = $1
ORDER BY created_at DESC
`, clarID)
if err != nil {
return nil, fmt.Errorf("list clarification history: %w", err)
}
defer rows.Close()
out := []ClarificationHistoryEntry{}
for rows.Next() {
var h ClarificationHistoryEntry
var fromStatus, toStatus, fromAnswer, toAnswer sql.NullString
if err := rows.Scan(&h.ID, &h.ClarificationID, &h.Actor, &fromStatus, &toStatus, &fromAnswer, &toAnswer, &h.Note, &h.CreatedAt); err != nil {
return nil, err
}
h.FromStatus = fromStatus.String
h.ToStatus = toStatus.String
h.FromAnswer = fromAnswer.String
h.ToAnswer = toAnswer.String
out = append(out, h)
}
return out, rows.Err()
}
@@ -0,0 +1,88 @@
-- Migration 028: IACE Clarifications — Multi-User-Workflow
-- ==========================================================================
-- Up to Phase 2 the Klaerungen feature persisted answers in
-- iace_projects.metadata.clarification_answers (JSONB). That works for a
-- single user but cannot model assigned_to, comment threads, status
-- history or audit trail. Phase 3 introduces a proper relational table.
--
-- The previous JSONB blob remains read by the engine as fallback for any
-- project whose clarifications have not yet been migrated, so this is a
-- non-breaking add-on. A separate one-shot upcopy script migrates the
-- existing JSONB answers into rows.
-- ==========================================================================
-- 1. Main table: one row per (project, clarification_id)
CREATE TABLE IF NOT EXISTS iace_clarifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
project_id UUID NOT NULL REFERENCES iace_projects(id) ON DELETE CASCADE,
-- Deterministic clarification ID generated by the engine
-- (e.g. "pattern:HP1640:0", "manuf:fanuc:dual-check-safety-dcs:1").
-- Stable across re-inits so persisted answers survive.
clarification_key TEXT NOT NULL,
-- The verbatim question + source as known at last engine run.
-- Kept in this table so the audit trail does not break if the
-- pattern library is later updated.
question TEXT NOT NULL,
source TEXT NOT NULL,
category TEXT NOT NULL,
norm_references TEXT[] DEFAULT '{}',
-- Lifecycle state
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'answered', 'not_relevant')),
answer TEXT DEFAULT '' CHECK (answer IN ('', 'ja', 'nein', 'teilweise')),
reasoning TEXT DEFAULT '',
-- Multi-User workflow
assigned_to TEXT DEFAULT '', -- user id / kuerzel — free text for now
answered_by TEXT DEFAULT '',
answered_at TIMESTAMPTZ,
-- Common audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, clarification_key)
);
CREATE INDEX IF NOT EXISTS idx_iace_clar_project ON iace_clarifications(project_id);
CREATE INDEX IF NOT EXISTS idx_iace_clar_tenant ON iace_clarifications(tenant_id);
CREATE INDEX IF NOT EXISTS idx_iace_clar_status ON iace_clarifications(project_id, status);
CREATE INDEX IF NOT EXISTS idx_iace_clar_assignee ON iace_clarifications(assigned_to) WHERE assigned_to <> '';
-- 2. Comment thread: one row per comment, ordered by created_at
CREATE TABLE IF NOT EXISTS iace_clarification_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clarification_id UUID NOT NULL REFERENCES iace_clarifications(id) ON DELETE CASCADE,
author TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_iace_clar_comments_clar ON iace_clarification_comments(clarification_id, created_at);
-- 3. Status history — every status / answer change is logged.
CREATE TABLE IF NOT EXISTS iace_clarification_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clarification_id UUID NOT NULL REFERENCES iace_clarifications(id) ON DELETE CASCADE,
actor TEXT NOT NULL DEFAULT '',
from_status TEXT,
to_status TEXT,
from_answer TEXT,
to_answer TEXT,
note TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_iace_clar_history_clar ON iace_clarification_history(clarification_id, created_at);
-- 4. Trigger to bump updated_at on the main table
CREATE OR REPLACE FUNCTION iace_clarifications_touch_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_iace_clarifications_updated_at ON iace_clarifications;
CREATE TRIGGER trg_iace_clarifications_updated_at
BEFORE UPDATE ON iace_clarifications
FOR EACH ROW EXECUTE FUNCTION iace_clarifications_touch_updated_at();