Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/store_clarifications.go
T
Benjamin Admin c4be077c5d 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>
2026-05-17 01:39:17 +02:00

242 lines
8.4 KiB
Go

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()
}