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.
238 lines
7.9 KiB
Go
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
|
|
}
|