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:
@@ -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