c4be077c5d
[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>
242 lines
8.4 KiB
Go
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()
|
|
}
|