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