Files
paliad/internal/services/rule_editor_orphans.go
mAi 1c45c93570 feat(t-paliad-192): admin orphan list/resolve endpoints
Slice 11b backend addition for the orphan-resolution flow in the
/admin/rules UI. The Slice 10 fuzzy-match backfill (mig 089) staged
legacy paliad.deadlines rows the matcher could not bind to a unique
deadline_rule into paliad.deadline_rule_backfill_orphans. This adds
the two endpoints the editor needs to surface and resolve them:

  GET  /admin/api/orphans              — unresolved staging rows,
                                         hydrated with the candidate
                                         rule rows in one round-trip.
  POST /admin/api/orphans/{id}/resolve — picks a rule_id from the
                                         candidate set, writes it onto
                                         the deadline, and flips
                                         resolved_at + resolved_rule_id
                                         on the staging row in a single
                                         tx.

The methods live on RuleEditorService because they share the same admin
surface and audit semantics; resolved_rule_id + resolved_at on the
staging row is the audit trail (mig 089 COMMENT). reason is captured
into paliad.audit_reason in the same tx so any future audit trigger on
paliad.deadlines picks it up automatically.

Typed errors:
  ErrOrphanAlreadyResolved   → 409 in handler
  ErrOrphanCandidateMismatch → 400 in handler

Route ordering matches Slice 11a's pattern: the static path is
registered alongside the existing /admin/api/rules family inside the
adminGate block in handlers.go.
2026-05-15 02:09:10 +02:00

238 lines
7.9 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
// Slice 11b orphan-resolution flow (t-paliad-192).
//
// Slice 10 (mig 089) staged the legacy paliad.deadlines rows that the
// fuzzy-match backfill couldn't bind uniquely to a deadline_rule. This
// file surfaces those rows to the admin rule-editor UI so a human can
// pick the right rule from the candidate list and write rule_id back
// onto the deadline.
//
// The methods sit on RuleEditorService because the orphan flow is part
// of the same admin surface and shares the same audit semantics — the
// resolved_rule_id + resolved_at pair on the staging row IS the audit
// trail. No new DB trigger needed; the staging table doubles as the
// log of the legal-review pass per mig 089's COMMENT.
// ErrOrphanAlreadyResolved is returned when a resolve call hits a row
// whose resolved_at is already non-NULL. 409 Conflict in the handler so
// the editor can re-fetch and show the picker the other admin made.
var ErrOrphanAlreadyResolved = errors.New("orphan already resolved")
// ErrOrphanCandidateMismatch is returned when the editor picks a rule
// that is not in the staging row's candidate_rule_ids set. The list of
// candidates is the matcher's output and the only legal choice — to
// pick anything else, an admin should patch the deadline directly.
var ErrOrphanCandidateMismatch = errors.New("rule_id not in candidate set")
// OrphanCandidate is one suggested rule from the fuzzy matcher with the
// fields the editor needs to render the pick chip.
type OrphanCandidate struct {
ID uuid.UUID `db:"id" json:"id"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
}
// Orphan is one row from paliad.deadline_rule_backfill_orphans hydrated
// with its candidate rule rows (joined from paliad.deadline_rules so
// the UI doesn't need a second round-trip per row).
type Orphan struct {
ID uuid.UUID `json:"id"`
DeadlineID uuid.UUID `json:"deadline_id"`
Title string `json:"title"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProceedingCode *string `json:"proceeding_code,omitempty"`
Reason string `json:"reason"`
CandidateCount int `json:"candidate_count"`
CandidateIDs []uuid.UUID `json:"candidate_ids"`
Candidates []OrphanCandidate `json:"candidates"`
CreatedAt time.Time `json:"created_at"`
ProjectTitle *string `json:"project_title,omitempty"`
}
// ListOrphans returns unresolved staging rows newest-first. The fuzzy
// matcher inserted at most ~25 rows so a flat list is fine; pagination
// can be added later if the table ever grows past a screen.
func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
type row struct {
ID uuid.UUID `db:"id"`
DeadlineID uuid.UUID `db:"deadline_id"`
Title string `db:"title"`
ProjectID *uuid.UUID `db:"project_id"`
ProceedingCode *string `db:"proceeding_code"`
Reason string `db:"reason"`
CandidateCount int `db:"candidate_count"`
CandidateIDs pq.StringArray `db:"candidate_rule_ids"`
CreatedAt time.Time `db:"created_at"`
ProjectTitle *string `db:"project_title"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, `
SELECT o.id, o.deadline_id, o.title, o.project_id, o.proceeding_code,
o.reason, o.candidate_count, o.candidate_rule_ids, o.created_at,
p.title AS project_title
FROM paliad.deadline_rule_backfill_orphans o
LEFT JOIN paliad.projects p ON p.id = o.project_id
WHERE o.resolved_at IS NULL
ORDER BY o.created_at DESC`); err != nil {
return nil, fmt.Errorf("list orphans: %w", err)
}
// Collect every candidate UUID, fetch the rule rows in one shot, then
// fan back out per orphan. Avoids N+1 SELECTs when the matcher
// produced ambiguous (≥ 2 candidates) hits.
idSet := map[uuid.UUID]bool{}
for _, r := range rows {
for _, sid := range r.CandidateIDs {
id, err := uuid.Parse(sid)
if err != nil {
continue
}
idSet[id] = true
}
}
candidateByID := map[uuid.UUID]OrphanCandidate{}
if len(idSet) > 0 {
ids := make([]uuid.UUID, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var cs []OrphanCandidate
uuidStrs := make([]string, len(ids))
for i, id := range ids {
uuidStrs[i] = id.String()
}
if err := s.db.SelectContext(ctx, &cs, `
SELECT id, rule_code, name, name_en
FROM paliad.deadline_rules
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
}
for _, c := range cs {
candidateByID[c.ID] = c
}
}
out := make([]Orphan, 0, len(rows))
for _, r := range rows {
cids := make([]uuid.UUID, 0, len(r.CandidateIDs))
cs := make([]OrphanCandidate, 0, len(r.CandidateIDs))
for _, sid := range r.CandidateIDs {
id, err := uuid.Parse(sid)
if err != nil {
continue
}
cids = append(cids, id)
if c, ok := candidateByID[id]; ok {
cs = append(cs, c)
}
}
out = append(out, Orphan{
ID: r.ID,
DeadlineID: r.DeadlineID,
Title: r.Title,
ProjectID: r.ProjectID,
ProceedingCode: r.ProceedingCode,
Reason: r.Reason,
CandidateCount: r.CandidateCount,
CandidateIDs: cids,
Candidates: cs,
CreatedAt: r.CreatedAt,
ProjectTitle: r.ProjectTitle,
})
}
return out, nil
}
// ResolveOrphan binds the orphan's deadline to the picked rule_id and
// flips resolved_at + resolved_rule_id on the staging row. Both writes
// land in the same tx; if either fails, the orphan stays open so the
// editor can retry.
//
// reason is captured into paliad.audit_reason so any future audit trigger
// on paliad.deadlines picks it up. As of Slice 11b there is no trigger
// on deadlines (see mig 089 COMMENT), but the session setting is cheap
// to maintain and future-proofs the call site.
func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUID, ruleID uuid.UUID, reason string) error {
if strings.TrimSpace(reason) == "" {
return ErrAuditReasonRequired
}
type orphanCheck struct {
DeadlineID uuid.UUID `db:"deadline_id"`
ResolvedAt *time.Time `db:"resolved_at"`
CandidateIDs pq.StringArray `db:"candidate_rule_ids"`
}
var oc orphanCheck
err := s.db.GetContext(ctx, &oc,
`SELECT deadline_id, resolved_at, candidate_rule_ids
FROM paliad.deadline_rule_backfill_orphans
WHERE id = $1`, orphanID)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: orphan %s", ErrRuleNotFound, orphanID)
}
if err != nil {
return fmt.Errorf("load orphan %s: %w", orphanID, err)
}
if oc.ResolvedAt != nil {
return ErrOrphanAlreadyResolved
}
inSet := false
for _, sid := range oc.CandidateIDs {
id, parseErr := uuid.Parse(sid)
if parseErr == nil && id == ruleID {
inSet = true
break
}
}
if !inSet {
return ErrOrphanCandidateMismatch
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
return err
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET rule_id = $1,
updated_at = $2
WHERE id = $3`,
ruleID, now, oc.DeadlineID,
); err != nil {
return fmt.Errorf("set deadline rule_id: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rule_backfill_orphans
SET resolved_at = $1,
resolved_rule_id = $2
WHERE id = $3 AND resolved_at IS NULL`,
now, ruleID, orphanID,
); err != nil {
return fmt.Errorf("mark orphan resolved: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit resolve: %w", err)
}
return nil
}