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