package opportunities import ( "context" "encoding/json" "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" ) // The scope review queue, directive Section 6 screen 4. A reviewer sees what extraction proposed, with the // citation that grounds it, and accepts, edits, rejects, or adds an item manually. Nothing here can reach // the scoring engine: a scope item becomes a requirement only through a requirement draft and an approval by // a named human who holds requirement.approve. // ScopeItem is the app facing view of one reviewable scope item. type ScopeItem struct { ID string `json:"id"` OpportunityID string `json:"opportunity_id"` ScopeType string `json:"scope_type"` Title string `json:"title"` Description string `json:"description"` SourceDocumentID string `json:"source_document_id"` SourcePageNumber int `json:"source_page_number"` SourceSectionLabel string `json:"source_section_label"` SourceQuoteHash string `json:"source_quote_hash"` ExtractionMethod string `json:"extraction_method"` Confidence float64 `json:"extraction_confidence"` ReviewStatus string `json:"review_status"` DisplayOrder int `json:"display_order"` CreatedAt string `json:"created_at"` } // ScopeReview is a reviewer's decision on one item. Title and Description are optional edits applied with // the decision, which is what "edit and accept" means in one call. type ScopeReview struct { ReviewStatus string `json:"review_status"` Title *string `json:"title"` Description *string `json:"description"` ScopeType *string `json:"scope_type"` } // ManualScopeInput adds an item a human authored. It needs no citation, because the human IS the source; // the schema permits exactly that for review_status manual and for nothing else. type ManualScopeInput struct { ScopeType string `json:"scope_type"` Title string `json:"title"` Description string `json:"description"` } const scopeCols = `id::text, opportunity_id::text, scope_type, title, description, COALESCE(source_document_id::text,''), COALESCE(source_page_number,0), source_section_label, source_quote_hash, extraction_method, extraction_confidence, review_status, display_order, created_at` // The review status vocabulary. 'split' was added by migration 0084 with the split and merge operations; // values are EXTENDED here and never renamed or removed, because a status already written to a row is a // historical fact and renaming it rewrites the record. var validScopeReviewStatuses = map[string]bool{ "extracted": true, "accepted": true, "edited": true, "rejected": true, "merged": true, "manual": true, "split": true, } // ListScope returns one opportunity's scope items in review order, optionally filtered by status. func (s *Service) ListScope(ctx context.Context, opportunityID, statusFilter string) ([]ScopeItem, error) { if !s.db.Ready() { return nil, ErrNoDatabase } oid, err := uuid.Parse(strings.TrimSpace(opportunityID)) if err != nil { return nil, fmt.Errorf("%w: opportunity id must be a uuid", ErrInvalidInput) } out := make([]ScopeItem, 0) err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var rows pgx.Rows var qErr error if sf := strings.TrimSpace(statusFilter); sf != "" { rows, qErr = tx.Query(ctx, `SELECT `+scopeCols+` FROM opportunity_scope_items WHERE opportunity_id = $1 AND review_status = $2 ORDER BY display_order, created_at`, oid, sf) } else { rows, qErr = tx.Query(ctx, `SELECT `+scopeCols+` FROM opportunity_scope_items WHERE opportunity_id = $1 ORDER BY display_order, created_at`, oid) } if qErr != nil { return qErr } defer rows.Close() for rows.Next() { it, sErr := scanScope(rows) if sErr != nil { return sErr } out = append(out, it) } return rows.Err() }) if err != nil { return nil, err } return out, nil } // ReviewScopeItem records a reviewer's decision, optionally with an edit applied in the same write. An edit // that changes the text moves the status to edited rather than accepted, so a later reader can tell what the // machine said from what a human rewrote. func (s *Service) ReviewScopeItem(ctx context.Context, itemID string, r ScopeReview) (ScopeItem, error) { if !s.db.Ready() { return ScopeItem{}, ErrNoDatabase } iid, err := uuid.Parse(strings.TrimSpace(itemID)) if err != nil { return ScopeItem{}, fmt.Errorf("%w: item id must be a uuid", ErrInvalidInput) } status := strings.TrimSpace(r.ReviewStatus) if !validScopeReviewStatuses[status] { return ScopeItem{}, fmt.Errorf("%w: review_status %q is not valid", ErrInvalidInput, status) } if r.ScopeType != nil && !scopeTypes[strings.TrimSpace(*r.ScopeType)] { return ScopeItem{}, fmt.Errorf("%w: scope_type %q is not valid", ErrInvalidInput, *r.ScopeType) } actor := actorPtr(ctx) if actor == nil { return ScopeItem{}, tenant.ErrNoTenant } sets := []string{"review_status = $1", "reviewed_by_user_id = $2", "reviewed_at = now()", "updated_at = now()"} args := []any{status, actor} add := func(col string, v any) { args = append(args, v) sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) } if r.Title != nil { add("title", strings.TrimSpace(*r.Title)) } if r.Description != nil { add("description", *r.Description) } if r.ScopeType != nil { add("scope_type", strings.TrimSpace(*r.ScopeType)) } args = append(args, iid) query := `UPDATE opportunity_scope_items SET ` + strings.Join(sets, ", ") + fmt.Sprintf(" WHERE id = $%d RETURNING ", len(args)) + scopeCols var out ScopeItem err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { row := tx.QueryRow(ctx, query, args...) var sErr error if out, sErr = scanScope(row); sErr != nil { return sErr } nv, _ := json.Marshal(map[string]any{"scope_item_id": out.ID, "review_status": out.ReviewStatus}) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "opportunity.scope.reviewed", ActorUserID: actor, EntityType: "opportunity_scope_item", EntityID: out.ID, NewValues: nv, }) }) if err != nil { return ScopeItem{}, err } return out, nil } // AddManualScope adds a human authored scope item. It is written as review_status manual, the only status // the citation CHECK permits without a source, because here the human is the source. func (s *Service) AddManualScope(ctx context.Context, opportunityID string, in ManualScopeInput) (ScopeItem, error) { if !s.db.Ready() { return ScopeItem{}, ErrNoDatabase } oid, err := uuid.Parse(strings.TrimSpace(opportunityID)) if err != nil { return ScopeItem{}, fmt.Errorf("%w: opportunity id must be a uuid", ErrInvalidInput) } if !scopeTypes[strings.TrimSpace(in.ScopeType)] { return ScopeItem{}, fmt.Errorf("%w: scope_type %q is not valid", ErrInvalidInput, in.ScopeType) } if strings.TrimSpace(in.Title) == "" { return ScopeItem{}, fmt.Errorf("%w: title is required", ErrInvalidInput) } actor := actorPtr(ctx) if actor == nil { return ScopeItem{}, tenant.ErrNoTenant } var out ScopeItem err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { row := tx.QueryRow(ctx, ` INSERT INTO opportunity_scope_items ( organization_id, opportunity_id, scope_type, title, description, review_status, extraction_method, reviewed_by_user_id, reviewed_at) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, $4, 'manual', 'human', $5, now()) RETURNING `+scopeCols, oid, strings.TrimSpace(in.ScopeType), strings.TrimSpace(in.Title), in.Description, actor) var sErr error if out, sErr = scanScope(row); sErr != nil { return sErr } nv, _ := json.Marshal(map[string]any{"scope_item_id": out.ID, "scope_type": out.ScopeType}) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "opportunity.scope.manual_added", ActorUserID: actor, EntityType: "opportunity_scope_item", EntityID: out.ID, NewValues: nv, }) }) if err != nil { return ScopeItem{}, err } return out, nil } // ListDrafts returns the requirement drafts for one opportunity, so the build requirements screen can show // what is waiting on a decision and what has already been promoted. func (s *Service) ListDrafts(ctx context.Context, opportunityID string) ([]map[string]any, error) { if !s.db.Ready() { return nil, ErrNoDatabase } oid, err := uuid.Parse(strings.TrimSpace(opportunityID)) if err != nil { return nil, fmt.Errorf("%w: opportunity id must be a uuid", ErrInvalidInput) } out := make([]map[string]any, 0) err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, qErr := tx.Query(ctx, ` SELECT id::text, labor_category, quantity, clearance_level, polygraph, minimum_years_experience, work_location, remote_allowed, required_skills, review_status, COALESCE(approved_requirement_id::text,''), derivation_method FROM requirement_drafts WHERE opportunity_id = $1 ORDER BY created_at`, oid) if qErr != nil { return qErr } defer rows.Close() for rows.Next() { var id, lc, cl, poly, loc, status, reqID, method string var qty, years int var remote bool var skills []string if err := rows.Scan(&id, &lc, &qty, &cl, &poly, &years, &loc, &remote, &skills, &status, &reqID, &method); err != nil { return err } out = append(out, map[string]any{ "id": id, "labor_category": lc, "quantity": qty, "clearance_level": cl, "polygraph": poly, "minimum_years_experience": years, "work_location": loc, "remote_allowed": remote, "required_skills": skills, "review_status": status, "approved_requirement_id": reqID, "derivation_method": method, }) } return rows.Err() }) if err != nil { return nil, err } return out, nil } func scanScope(row pgx.Row) (ScopeItem, error) { var it ScopeItem var createdAt interface{ String() string } var created any if err := row.Scan(&it.ID, &it.OpportunityID, &it.ScopeType, &it.Title, &it.Description, &it.SourceDocumentID, &it.SourcePageNumber, &it.SourceSectionLabel, &it.SourceQuoteHash, &it.ExtractionMethod, &it.Confidence, &it.ReviewStatus, &it.DisplayOrder, &created); err != nil { return ScopeItem{}, err } _ = createdAt if t, ok := created.(interface{ Format(string) string }); ok { it.CreatedAt = t.Format("2006-01-02T15:04:05Z07:00") } return it, nil }