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 }