package opportunities import ( "context" "encoding/json" "errors" "fmt" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrDraftRejected is returned when a promotion is attempted on a draft a reviewer already refused. A // rejected draft is a decision, not a queue item, so it is never silently revived by a later approval. var ErrDraftRejected = errors.New("opportunities: this requirement draft was rejected and cannot be promoted") // PromotedRequirement is what an approval produced: the live requirement id, and whether THIS call is the // one that created it. type PromotedRequirement struct { RequirementID string `json:"requirement_id"` DraftID string `json:"draft_id"` AlreadyDone bool `json:"already_promoted"` } // ApproveDraft is the ONE path by which anything the machine derived becomes visible to the scoring engine. // // It is the most safety critical write in the opportunity pipeline, so it is deliberately narrow: // // 1. It takes FOR UPDATE on the draft row FIRST. Eight racing approvals therefore serialize on that lock // and exactly one creates the requirement; the other seven read the winner's result and return it with // AlreadyDone set. This is the same lock discipline as the nine existing recruiting locks, and it is why // a double click or two reviewers pressing approve at once cannot produce two live requirements. // 2. It records the NAMED HUMAN who approved. The schema refuses an approved row without a reviewer, so an // automated caller cannot satisfy this write at all; the AEDT human in the loop rule is structural. // 3. It writes the requirement and flips the draft in the SAME transaction, so the draft can never claim an // approval whose requirement was rolled back, nor a requirement exist that no draft accounts for. // 4. It resolves skills against the EXISTING ontology only. A solicitation routinely names a skill the // ontology does not carry; those are left unlinked rather than inserted, because writing the global // ontology is superuser curation (curation.curate), never a side effect of approving one requirement. func (s *Service) ApproveDraft(ctx context.Context, draftID string) (PromotedRequirement, error) { if !s.db.Ready() { return PromotedRequirement{}, ErrNoDatabase } did, err := uuid.Parse(strings.TrimSpace(draftID)) if err != nil { return PromotedRequirement{}, fmt.Errorf("%w: draft id must be a uuid", ErrInvalidInput) } actor := actorPtr(ctx) if actor == nil { return PromotedRequirement{}, tenant.ErrNoTenant } var out PromotedRequirement out.DraftID = did.String() err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // THE LOCK. Every racing approval of this draft queues here. var status string var existing *string var laborCat, clearance, poly, workLoc string var years int var remote bool var skills []string var mandatory []bool var oppTitle string err := tx.QueryRow(ctx, ` SELECT d.review_status, d.approved_requirement_id::text, d.labor_category, d.clearance_level, d.polygraph, d.work_location, d.minimum_years_experience, d.remote_allowed, d.required_skills, d.mandatory_skill_flags, COALESCE(t.title,'') FROM requirement_drafts d JOIN tracked_opportunities t ON t.organization_id = d.organization_id AND t.id = d.opportunity_id WHERE d.id = $1 FOR UPDATE OF d`, did). Scan(&status, &existing, &laborCat, &clearance, &poly, &workLoc, &years, &remote, &skills, &mandatory, &oppTitle) if err != nil { return err } // The losers of the race land here: the winner already promoted, so return its result rather than // creating a second requirement. Idempotent by construction, not by retry. if status == "approved" && existing != nil { out.RequirementID = *existing out.AlreadyDone = true return nil } if status == "rejected" { return ErrDraftRejected } title := strings.TrimSpace(laborCat) if title == "" { title = "Requirement from " + oppTitle } var reqID uuid.UUID if err := tx.QueryRow(ctx, ` INSERT INTO requirements (organization_id, title, required_level, required_poly, required_years, work_location, remote_allowed) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, $4, $5, $6) RETURNING id`, title, clearance, poly, years, workLoc, remote).Scan(&reqID); err != nil { return fmt.Errorf("create requirement: %w", err) } // Link only the skills the ontology already knows. Unknown skill names stay on the draft as the record // of what the solicitation asked for; they are a curation input, never an automatic ontology write. if len(skills) > 0 { // unnest pairs each skill with its flag positionally, so the mandatory bit lands on the skill it // belongs to. The schema already guarantees the arrays are either equal length or the flags are // absent; padFlags makes the absent case explicit rather than relying on that guarantee holding at // the SQL layer, because a length mismatch here would mark the WRONG skill mandatory and a mandatory // skill drives a band cap in the frozen scoring engine. if _, err := tx.Exec(ctx, ` INSERT INTO required_skills (organization_id, requirement_id, skill_id, mandatory) SELECT NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, sk.id, s.mand FROM unnest($2::text[], $3::boolean[]) AS s(nm, mand) JOIN skills sk ON lower(sk.name) = s.nm ON CONFLICT DO NOTHING`, reqID, lowerAll(skills), padFlags(mandatory, len(skills))); err != nil { return fmt.Errorf("link required skills: %w", err) } } if _, err := tx.Exec(ctx, ` UPDATE requirement_drafts SET review_status = 'approved', approved_requirement_id = $1, reviewed_by_user_id = $2, reviewed_at = now(), updated_at = now() WHERE id = $3`, reqID, actor, did); err != nil { return fmt.Errorf("mark draft approved: %w", err) } out.RequirementID = reqID.String() nv, _ := json.Marshal(map[string]any{ "draft_id": did.String(), "requirement_id": reqID.String(), "labor_category": laborCat, "clearance_level": clearance, "skills_named": len(skills), }) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "requirement.promoted", ActorUserID: actor, EntityType: "requirement", EntityID: reqID.String(), NewValues: nv, }) }) if err != nil { return PromotedRequirement{}, err } return out, nil } // RejectDraft records a reviewer's refusal. A rejected draft keeps its contents as the record of what was // refused and by whom, so a later reader can see the decision rather than an absence. func (s *Service) RejectDraft(ctx context.Context, draftID string) error { if !s.db.Ready() { return ErrNoDatabase } did, err := uuid.Parse(strings.TrimSpace(draftID)) if err != nil { return fmt.Errorf("%w: draft id must be a uuid", ErrInvalidInput) } actor := actorPtr(ctx) if actor == nil { return tenant.ErrNoTenant } return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var status string if err := tx.QueryRow(ctx, `SELECT review_status FROM requirement_drafts WHERE id = $1 FOR UPDATE`, did).Scan(&status); err != nil { return err } if status == "approved" { // An approved draft already produced a live requirement. Rejecting it here would leave the // requirement in place with no draft accounting for it, so the refusal is explicit. return fmt.Errorf("%w: this draft was already approved and promoted", ErrInvalidInput) } if _, err := tx.Exec(ctx, ` UPDATE requirement_drafts SET review_status = 'rejected', reviewed_by_user_id = $1, reviewed_at = now(), updated_at = now() WHERE id = $2`, actor, did); err != nil { return err } nv, _ := json.Marshal(map[string]any{"draft_id": did.String()}) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "requirement.draft.rejected", ActorUserID: actor, EntityType: "requirement_draft", EntityID: did.String(), NewValues: nv, }) }) } func lowerAll(in []string) []string { out := make([]string, len(in)) for i, v := range in { out[i] = strings.ToLower(strings.TrimSpace(v)) } return out } // padFlags returns a mandatory flag per skill. A draft may legitimately carry no flags at all (nothing was // marked mandatory), and unnest over two arrays of different lengths would misalign the pairs, so the short // case is padded with false rather than left to chance. func padFlags(flags []bool, n int) []bool { if len(flags) == n { return flags } out := make([]bool, n) copy(out, flags) return out }