Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/opportunities/gate.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/opportunities/gate.go |
| Original SHA-256 | 7ed703d8958545f31ad6d11d086699abeda692934f96f134d0a6298c77d59845 |
| Git blob SHA | 5798e1ea0999eedbf414be356c8f5f82dce95e96 |
| Reading-copy SHA-256 | 7ed703d8958545f31ad6d11d086699abeda692934f96f134d0a6298c77d59845 |
Recovered source transcript
Download complete source text ↓321 lines · 12.9 KB
package opportunities
import (
"context"
"errors"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/shopspring/decimal"
)
// THE CAPTURE GATE.
//
// A bid or no bid was a STATUS. Moving a pursuit to no_bid recorded the outcome and nothing else, so the
// three questions anybody asks about a declined pursuit six months later, who decided, when, and against
// what, had no answer anywhere in the product.
//
// The pursuits a firm walks away from are the cheapest lessons it will ever have. This is the record that
// stops throwing them away the moment somebody changes a dropdown.
// gateListLimit bounds a review board read. The fourth time this pattern appears in this build: the alerts
// feed, the key personnel list and the exception queue all shipped unbounded and all had to be capped after
// somebody measured them. Starting bounded is cheaper than measuring it later.
const gateListLimit = 500
// Gates in the order capture actually runs them. Named once, so the handler, the validator and the
// interface cannot drift apart.
var CaptureGates = []string{"qualify", "pursue", "bid"}
// GateDecision is one recorded go or no go.
type GateDecision struct {
ID string `json:"id"`
OpportunityID string `json:"opportunity_id"`
Gate string `json:"gate"`
Decision string `json:"decision"`
DecidedBy string `json:"decided_by,omitempty"`
DecidedByName string `json:"decided_by_name,omitempty"`
DecidedAt string `json:"decided_at"`
Rationale string `json:"rationale"`
// The advisory numbers AS THEY STOOD at the decision. Pointers, because absent and zero are different
// facts and a capture review that reads a missing model as a zero percent chance of winning is worse
// than one that says nothing.
ReadinessScore *decimal.Decimal `json:"readiness_score,omitempty"`
ReadinessBand string `json:"readiness_band,omitempty"`
WinProbability *decimal.Decimal `json:"win_probability,omitempty"`
}
// ReviewRow is one pursuit on the capture review board.
type ReviewRow struct {
OpportunityID string `json:"opportunity_id"`
Title string `json:"title"`
Agency string `json:"agency,omitempty"`
Status string `json:"status"`
// The LATEST decision at each gate, so a reviewer sees how far a pursuit has been taken and where it
// stopped, rather than one flattened verdict that loses the shape of the pipeline.
Latest map[string]*GateDecision `json:"latest"`
// LapsedCommitments is the count of key personnel commitments on this pursuit that are not signed. It
// comes from the teaming work and is here because it is the single most common reason a bid that looked
// ready turns out not to be: the people were promised and never committed on paper.
LapsedCommitments int `json:"lapsed_commitments"`
// UndecidedGates names the gates this pursuit has never been through. A pursuit being worked with no
// recorded gate decision at all is the case this whole phase exists to make visible.
UndecidedGates []string `json:"undecided_gates"`
}
// ReviewBoard is the capture review, already scoped to the caller's organization by RLS.
type ReviewBoard struct {
Pursuits []ReviewRow `json:"pursuits"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
// Undecided counts pursuits being actively worked with no gate decision recorded at any gate. It is the
// headline number of this screen.
Undecided int `json:"undecided"`
}
// RecordDecision writes one gate decision.
//
// The advisory numbers are passed IN by the caller rather than recomputed here, because the number that
// belongs in the record is the one the decider was looking at. Recomputing at write time would store a
// number nobody saw, which defeats the entire purpose of snapshotting it.
func (s *Service) RecordDecision(ctx context.Context, opportunityID uuid.UUID, gate, decision, rationale string,
readiness *decimal.Decimal, band string, winProb *decimal.Decimal) (GateDecision, error) {
if s.db == nil {
return GateDecision{}, ErrNoDatabase
}
gate = strings.ToLower(strings.TrimSpace(gate))
decision = strings.ToLower(strings.TrimSpace(decision))
rationale = strings.TrimSpace(rationale)
if !inList(CaptureGates, gate) {
return GateDecision{}, ErrGateUnknown
}
if decision != "go" && decision != "no_go" {
return GateDecision{}, ErrDecisionUnknown
}
// A GO OR NO GO WITH NO STATED REASON TEACHES NOBODY ANYTHING, which is the state this table exists to
// prevent, so the refusal happens here in a sentence rather than reaching the user as a CHECK violation.
if rationale == "" {
return GateDecision{}, ErrRationaleRequired
}
if len(rationale) > 4000 {
return GateDecision{}, ErrRationaleTooLong
}
if band != "" && band != "GO" && band != "REVIEW" && band != "NO_GO" {
return GateDecision{}, ErrBandUnknown
}
if winProb != nil && (winProb.IsNegative() || winProb.GreaterThan(decimal.NewFromInt(1))) {
return GateDecision{}, ErrWinProbRange
}
var out GateDecision
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// The pursuit must exist in THIS tenant. RLS scopes the lookup, so a pursuit id from another
// organization simply is not here.
var exists bool
if e := tx.QueryRow(ctx, `SELECT true FROM tracked_opportunities WHERE id = $1`, opportunityID).Scan(&exists); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
return translateTeaming(e)
}
actor := actorPtr(ctx)
var bandArg any
if band != "" {
bandArg = band
}
return tx.QueryRow(ctx, `
INSERT INTO capture_gate_decisions
(organization_id, opportunity_id, gate, decision, decided_by, rationale,
readiness_score, readiness_band, win_probability)
VALUES (current_setting('app.current_org_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id::text, decided_at::text`,
opportunityID, gate, decision, actor, rationale, readiness, bandArg, winProb).
Scan(&out.ID, &out.DecidedAt)
})
if err != nil {
return GateDecision{}, err
}
out.OpportunityID = opportunityID.String()
out.Gate, out.Decision, out.Rationale = gate, decision, rationale
out.ReadinessScore, out.ReadinessBand, out.WinProbability = readiness, band, winProb
return out, nil
}
// ListDecisions returns the full gate history for one pursuit, newest first.
//
// The HISTORY, not just the latest. A pursuit that went go at qualify, go at pursue, then no go at bid
// tells a different story from one that was declined on sight, and a board that only shows the last verdict
// cannot tell them apart.
func (s *Service) ListDecisions(ctx context.Context, opportunityID uuid.UUID) ([]GateDecision, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
out := []GateDecision{}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, e := tx.Query(ctx, `
SELECT d.id::text, d.opportunity_id::text, d.gate, d.decision,
coalesce(d.decided_by::text, ''), coalesce(u.full_name, ''),
d.decided_at::text, d.rationale,
d.readiness_score, coalesce(d.readiness_band, ''), d.win_probability
FROM capture_gate_decisions d
LEFT JOIN users u ON u.id = d.decided_by
WHERE d.opportunity_id = $1
ORDER BY d.decided_at DESC
LIMIT `+strconv.Itoa(gateListLimit), opportunityID)
if e != nil {
return translateTeaming(e)
}
defer rows.Close()
for rows.Next() {
var d GateDecision
if e := rows.Scan(&d.ID, &d.OpportunityID, &d.Gate, &d.Decision, &d.DecidedBy, &d.DecidedByName,
&d.DecidedAt, &d.Rationale, &d.ReadinessScore, &d.ReadinessBand, &d.WinProbability); e != nil {
return e
}
out = append(out, d)
}
return translateTeaming(rows.Err())
})
if err != nil {
return nil, err
}
return out, nil
}
// Review builds the capture review board.
//
// ONE QUERY FOR THE PURSUITS AND ONE FOR THE DECISIONS, joined in Go. The obvious alternative, a correlated
// subquery per gate per pursuit, is the exact shape that made the manager capacity read scan once per
// requisition earlier in this build. Two flat reads and a map is both faster and easier to reason about.
func (s *Service) Review(ctx context.Context, includeClosed bool) (ReviewBoard, error) {
if s.db == nil {
return ReviewBoard{}, ErrNoDatabase
}
board := ReviewBoard{Pursuits: []ReviewRow{}}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Closed pursuits are excluded by default. A review board is about live decisions; the won, lost and
// archived ones are history and would bury the pursuits that still need a decision.
statusFilter := `AND t.status NOT IN ('won','lost','cancelled','archived')`
if includeClosed {
statusFilter = ``
}
rows, e := tx.Query(ctx, `
SELECT t.id::text, coalesce(t.title,''), coalesce(t.agency_name,''), t.status,
-- THE SAME LINKAGE ListCommitments USES, not a second one invented here.
--
-- A commitment points at a REQUIREMENT, and a requirement reaches a pursuit only through
-- the proposal staffing link and its proposal. Assuming a requirement knows its own
-- opportunity is the exact wrong turn that made key personnel commitments save
-- successfully and appear nowhere earlier in this build, because the two opportunity
-- tables in this schema serve different purposes. Reusing the proven predicate is how
-- that stays fixed.
(SELECT count(*)::int
FROM key_personnel_commitments k
WHERE k.organization_id = t.organization_id
AND k.signed_on IS NULL
AND EXISTS (SELECT 1
FROM proposal_staffing_links psl
JOIN proposals pr
ON pr.organization_id = psl.organization_id AND pr.id = psl.proposal_id
WHERE psl.organization_id = k.organization_id
AND psl.requirement_id = k.requirement_id
AND pr.opportunity_id = t.id))
FROM tracked_opportunities t
WHERE true `+statusFilter+`
ORDER BY t.created_at DESC
LIMIT `+strconv.Itoa(gateListLimit+1))
if e != nil {
return translateTeaming(e)
}
defer rows.Close()
index := map[string]int{}
for rows.Next() {
var r ReviewRow
if e := rows.Scan(&r.OpportunityID, &r.Title, &r.Agency, &r.Status, &r.LapsedCommitments); e != nil {
return e
}
r.Latest = map[string]*GateDecision{}
index[r.OpportunityID] = len(board.Pursuits)
board.Pursuits = append(board.Pursuits, r)
}
if e := rows.Err(); e != nil {
return translateTeaming(e)
}
if len(board.Pursuits) > gateListLimit {
board.Pursuits = board.Pursuits[:gateListLimit]
board.Truncated = true
}
if len(board.Pursuits) == 0 {
return nil
}
// DISTINCT ON gives the latest decision per pursuit per gate in one pass.
drows, e := tx.Query(ctx, `
SELECT DISTINCT ON (d.opportunity_id, d.gate)
d.opportunity_id::text, d.gate, d.decision,
coalesce(d.decided_by::text,''), coalesce(u.full_name,''), d.decided_at::text, d.rationale,
d.readiness_score, coalesce(d.readiness_band,''), d.win_probability
FROM capture_gate_decisions d
LEFT JOIN users u ON u.id = d.decided_by
ORDER BY d.opportunity_id, d.gate, d.decided_at DESC`)
if e != nil {
return translateTeaming(e)
}
defer drows.Close()
for drows.Next() {
var d GateDecision
if e := drows.Scan(&d.OpportunityID, &d.Gate, &d.Decision, &d.DecidedBy, &d.DecidedByName,
&d.DecidedAt, &d.Rationale, &d.ReadinessScore, &d.ReadinessBand, &d.WinProbability); e != nil {
return e
}
if i, ok := index[d.OpportunityID]; ok {
copyOf := d
board.Pursuits[i].Latest[d.Gate] = ©Of
}
}
return translateTeaming(drows.Err())
})
if err != nil {
return ReviewBoard{}, err
}
for i := range board.Pursuits {
p := &board.Pursuits[i]
for _, g := range CaptureGates {
if p.Latest[g] == nil {
p.UndecidedGates = append(p.UndecidedGates, g)
}
}
if len(p.UndecidedGates) == len(CaptureGates) {
board.Undecided++
}
}
board.Total = len(board.Pursuits)
return board, nil
}
func inList(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// The gate refusals, as named sentinels in the module's own idiom rather than a second error shape. Each
// one is a SENTENCE, because these reach a capture lead who is trying to record a decision, not a developer
// reading a constraint name.
var (
ErrGateUnknown = errors.New("a gate is qualify, pursue or bid")
ErrDecisionUnknown = errors.New("a gate decision is go or no_go, there is no maybe")
ErrRationaleRequired = errors.New("record why. A go or no go with no stated reason is the thing this replaces")
ErrRationaleTooLong = errors.New("the reason is longer than 4000 characters")
ErrBandUnknown = errors.New("a readiness band is GO, REVIEW or NO_GO")
ErrWinProbRange = errors.New("a win probability is between 0 and 1")
)