package matching import ( "context" "encoding/csv" "encoding/json" "errors" "strconv" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/csvsafe" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // ListSlates returns the slates generated for one requirement, newest first, with a member count // (slateMATCH slate list). It reads the stored slates, it does not rescore. func (s *Service) ListSlates(ctx context.Context, requirementID uuid.UUID) ([]SlateSummaryView, error) { if s.db == nil { return nil, ErrNoDatabase } var out []SlateSummaryView err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) slates, e := q.ListSlatesForRequirement(ctx, requirementID) if e != nil { return e } out = make([]SlateSummaryView, 0, len(slates)) for _, sl := range slates { members, e := q.ListSlateMembers(ctx, sl.ID) if e != nil { return e } out = append(out, SlateSummaryView{ SlateID: sl.ID.String(), Name: sl.Name, Status: sl.Status, MemberCount: len(members), CreatedAt: sl.CreatedAt.Format("2006-01-02"), }) } return nil }) return out, err } // SlateDetail returns one slate with its ranked members (read from the persisted slate_members, never // rescored) and the proposal readiness rollup. ErrNotFound when the slate is not in the caller's org. func (s *Service) SlateDetail(ctx context.Context, slateID uuid.UUID) (SlateDetailView, error) { if s.db == nil { return SlateDetailView{}, ErrNoDatabase } var view SlateDetailView err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) sl, e := q.GetSlate(ctx, slateID) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } members, e := q.ListSlateMembersWithNames(ctx, slateID) if e != nil { return e } title := "" if rq, e := q.GetRequirement(ctx, sl.RequirementID); e == nil { title = rq.Title } else if !errors.Is(e, pgx.ErrNoRows) { // A missing requirement leaves the title blank; a real error must not be swallowed into a // silently blank Requirement line on a branded export. return e } view = SlateDetailView{ SlateID: sl.ID.String(), RequirementID: sl.RequirementID.String(), RequirementTitle: title, Name: sl.Name, Status: sl.Status, Members: make([]SlateMemberDetailView, len(members)), Readiness: computeReadiness(sl.ID.String(), sl.RequirementID.String(), members), } for i, m := range members { view.Members[i] = slateMemberDetailView(m) } return nil }) return view, err } // ProposalReadiness returns the readiness rollup for one slate (GET /proposal-readiness): the band and // human decision mix across its members, computed from the stored slate and never by rescoring. func (s *Service) ProposalReadiness(ctx context.Context, slateID uuid.UUID) (ProposalReadinessView, error) { if s.db == nil { return ProposalReadinessView{}, ErrNoDatabase } var out ProposalReadinessView err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) sl, e := q.GetSlate(ctx, slateID) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } members, e := q.ListSlateMembersWithNames(ctx, slateID) if e != nil { return e } out = computeReadiness(sl.ID.String(), sl.RequirementID.String(), members) return nil }) return out, err } // ExportSlate renders a slate as a branded CSV (the proposal candidate slate a capture team submits). // It reads the persisted member scores and decisions, so the export always matches the frozen slate. func (s *Service) ExportSlate(ctx context.Context, slateID uuid.UUID) (string, error) { if s.db == nil { return "", ErrNoDatabase } var out string err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) sl, e := q.GetSlate(ctx, slateID) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } members, e := q.ListSlateMembersWithNames(ctx, slateID) if e != nil { return e } title := "" if rq, e := q.GetRequirement(ctx, sl.RequirementID); e == nil { title = rq.Title } else if !errors.Is(e, pgx.ErrNoRows) { // A missing requirement leaves the title blank; a real error must not be swallowed into a // silently blank Requirement line on a branded export. return e } out = renderSlateCSV(sl, title, members) // The export is a bulk egress of candidate PII (names, scores, decisions). Record it in the // append-only audit with the slate and the actor only, NEVER the reviewer note text or scores. payload, _ := json.Marshal(map[string]any{"members": len(members)}) return s.auditOnTx(ctx, tx, audit.Event{Action: "aedt.slate.exported", EntityType: "slate", EntityID: sl.ID.String(), NewValues: payload}) }) return out, err } // computeReadiness rolls a slate's persisted members into the proposal readiness view: the band mix, // the human decision mix, the approved and proposal ready count, and an overall label. Pure. func computeReadiness(slateID, requirementID string, members []dbgen.ListSlateMembersWithNamesRow) ProposalReadinessView { r := ProposalReadinessView{SlateID: slateID, RequirementID: requirementID, TotalMembers: len(members)} for _, m := range members { switch m.Decision { case "approved": r.Approved++ case "rejected": r.Rejected++ default: r.Pending++ } switch m.Band { case "green": r.ProposalReady++ case "yellow": r.Potential++ default: r.Recruiting++ } if m.Decision == "approved" && m.Band == "green" { r.ApprovedReady++ } } r.ReadinessPct = "0" if r.TotalMembers > 0 { // Approved share as a percent, decimal so the rollup carries no float64. pct := decimal.NewFromInt(int64(r.Approved)). Div(decimal.NewFromInt(int64(r.TotalMembers))). Mul(decimal.NewFromInt(100)) r.ReadinessPct = pct.StringFixed(0) } r.Status = readinessStatus(r) return r } func readinessStatus(r ProposalReadinessView) string { switch { case r.TotalMembers == 0: return "Empty" case r.Pending > 0: return "In Review" case r.Approved == 0: return "Needs Recruiting" default: return "Proposal Ready" } } func slateMemberDetailView(m dbgen.ListSlateMembersWithNamesRow) SlateMemberDetailView { rank := 0 if m.Rank != nil { rank = int(*m.Rank) } note := "" if m.DecisionNote != nil { note = *m.DecisionNote } var gaps []string if len(m.GapSummary) > 0 { _ = json.Unmarshal(m.GapSummary, &gaps) } return SlateMemberDetailView{ CandidateID: m.CandidateID.String(), FullName: m.FullName, Rank: rank, Total: m.TotalScore.StringFixed(2), Band: m.Band, BandLabel: bandLabel(m.Band), Components: map[string]string{ "Clearance": m.ClearanceScore.StringFixed(2), "Skills": m.SkillsScore.StringFixed(2), "Certifications": m.CertsScore.StringFixed(2), "CustomerExperience": m.CustomerScore.StringFixed(2), "Experience": m.ExperienceScore.StringFixed(2), "LocationAvailability": m.LocationScore.StringFixed(2), }, Explanation: m.Explanation, GapSummary: gaps, Decision: m.Decision, DecisionNote: note, } } // bandLabel maps a stored band code to its human label, defaulting safely on an unknown code. func bandLabel(code string) string { if b, err := engine.ParseBand(code); err == nil { return b.Label() } return code } // renderSlateCSV writes a branded proposal candidate slate. The header carries the product and slate // identity and the standing clearance disclaimer; the rows are the ranked, frozen members with their // human decisions. encoding/csv quotes any name or note that contains a comma. func renderSlateCSV(sl dbgen.Slate, requirementTitle string, members []dbgen.ListSlateMembersWithNamesRow) string { var b strings.Builder b.WriteString("missionMATCH slateMATCH Proposal Candidate Slate\n") // csvsafe.Cell, not csvField. csvField only adds RFC 4180 quoting when a value contains a comma, a // quote, or a newline; it does not neutralise the =+-@ and tab and carriage return prefixes that make a // spreadsheet treat a cell as a FORMULA. The data rows below were given csvsafe treatment and these two // header lines were missed, which left the exploit fully open: a slate named =cmd|'/c calc.exe'!A1 // contains no comma, so csvField passed it through verbatim, and this file is emailed to a government // customer by the module's own threat model. The payload runs when they open it. b.WriteString("Slate," + csvField(csvsafe.Cell(sl.Name)) + "\n") b.WriteString("Requirement," + csvField(csvsafe.Cell(requirementTitle)) + "\n") b.WriteString("Clearance shown is a self reported indicator, never verified.\n") b.WriteString("\n") w := csv.NewWriter(&b) _ = w.Write([]string{"Rank", "Candidate", "Total Score", "Band", "Decision", "Reviewer Note", "Explanation"}) for _, m := range members { rank := "" if m.Rank != nil { rank = strconv.Itoa(int(*m.Rank)) } note := "" if m.DecisionNote != nil { note = *m.DecisionNote } // Every cell passes through the shared guard. A candidate name is parsed from a resume, so it is // attacker controlled, and this file is emailed to a client who opens it in a spreadsheet. _ = w.Write(csvsafe.Row([]string{ rank, m.FullName, m.TotalScore.StringFixed(2), bandLabel(m.Band), m.Decision, note, m.Explanation, })) } w.Flush() return b.String() } // csvField quotes a single header value that may contain a comma (the csv writer handles the rows). func csvField(s string) string { if strings.ContainsAny(s, ",\"\n") { return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\"" } return s }