package bidoutcomes import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when no pool is configured. ErrNotFound when an update matches nothing in the // caller's org. ErrInvalidResult when the result enum is outside the allowed set. ErrInvalidID when a // supplied link or row id is not a UUID. var ( ErrNoDatabase = errors.New("bidoutcomes: no database configured") ErrNotFound = errors.New("bidoutcomes: bid outcome not found") ErrInvalidResult = errors.New("bidoutcomes: result must be one of win, loss, no_bid, pending") ErrInvalidID = errors.New("bidoutcomes: an id field must be a uuid") ) // Service records and updates bid outcomes under RLS, and writes an append only AEDT audit event with NO // PII (the org from the validated session, the result, and which of opportunity, requirement, and slate // were set, never the notes text and never the awardee). type Service struct { db *tenant.DB aud *audit.Writer } // NewService builds the service. db may be nil (then calls return ErrNoDatabase). The audit writer records // the recorded and updated events on the same tenant transaction as the work. func NewService(db *tenant.DB, aud *audit.Writer) *Service { return &Service{db: db, aud: aud} } // RecordBidOutcome inserts a new bid outcome, or upserts an existing one when input.ID is set, under the // caller's tenant transaction (RLS). It validates the result enum (defaulting an empty result to pending), // parses the optional uuid links and the optional timestamps, and on success writes an append only // aedt.bid_outcome.recorded audit event carrying NO PII. RLS plus the composite FKs guarantee a link can // only ever point at the caller's own opportunity, requirement, or slate. func (s *Service) RecordBidOutcome(ctx context.Context, in RecordInput) (BidOutcome, error) { // Validate the input BEFORE touching the database so a bad result enum, a malformed link uuid, or a bad // timestamp is a clean typed error regardless of whether a pool is configured. result := in.Result if result == "" { result = ResultPending } if !IsValidResult(result) { return BidOutcome{}, ErrInvalidResult } rowID, oppID, reqID, slateID, err := parseLinks(in.ID, in.OpportunityID, in.RequirementID, in.SlateID) if err != nil { return BidOutcome{}, err } submittedAt, err := parseTime(in.SubmittedAt) if err != nil { return BidOutcome{}, err } awardDate, err := parseDate(in.AwardDate) if err != nil { return BidOutcome{}, err } if !s.db.Ready() { return BidOutcome{}, ErrNoDatabase } var out BidOutcome err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // An UPSERT keyed on the row id when provided; otherwise a plain insert with a generated id. The // ON CONFLICT path only ever updates a row already visible to this tenant under RLS, so an id from // another org cannot be overwritten (the WITH CHECK and the composite FKs both hold). row := tx.QueryRow(ctx, insertOrUpsertOutcomeSQL, rowID, oppID, reqID, slateID, submittedAt, result, awardDate, in.AwardeeUEI, in.Notes) scanned, scanErr := scanOutcome(row) if scanErr != nil { return scanErr } out = scanned return s.audit(ctx, tx, "aedt.bid_outcome.recorded", out) }) if err != nil { return BidOutcome{}, err } return out, nil } // UpdateBidOutcome updates the result and award fields of an existing outcome in the caller's org once they // are known. It validates the result enum, parses the optional timestamps, and on a row that this tenant // can see (RLS) writes an append only aedt.bid_outcome.recorded audit event with NO PII. A row id that this // tenant cannot see returns ErrNotFound, never another org's row. func (s *Service) UpdateBidOutcome(ctx context.Context, id uuid.UUID, in UpdateInput) (BidOutcome, error) { // Validate the input BEFORE touching the database so a bad result enum or a bad timestamp is a clean // typed error regardless of whether a pool is configured. result := in.Result if result == "" { result = ResultPending } if !IsValidResult(result) { return BidOutcome{}, ErrInvalidResult } submittedAt, err := parseTime(in.SubmittedAt) if err != nil { return BidOutcome{}, err } awardDate, err := parseDate(in.AwardDate) if err != nil { return BidOutcome{}, err } if !s.db.Ready() { return BidOutcome{}, ErrNoDatabase } var out BidOutcome err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // COALESCE keeps the existing submitted_at and award_date when the update omits them (a null bind), // so a partial update never wipes a known timestamp. The UPDATE is RLS scoped, so a row in another // org is invisible and matches zero rows. row := tx.QueryRow(ctx, updateOutcomeSQL, id, result, submittedAt, awardDate, in.AwardeeUEI, in.Notes) scanned, scanErr := scanOutcome(row) if errors.Is(scanErr, pgx.ErrNoRows) { return ErrNotFound } if scanErr != nil { return scanErr } out = scanned return s.audit(ctx, tx, "aedt.bid_outcome.recorded", out) }) if err != nil { return BidOutcome{}, err } return out, nil } // audit writes one append only AEDT event for a bid outcome write, carrying NO PII: the result and which of // opportunity, requirement, and slate were set, never the notes, the awardee UEI, or any link id value. The // actor is taken from the validated session, never a caller argument. func (s *Service) audit(ctx context.Context, tx pgx.Tx, action string, out BidOutcome) error { if s.aud == nil { return nil } payload, _ := json.Marshal(map[string]any{ "result": out.Result, "has_opportunity": out.OpportunityID != "", "has_requirement": out.RequirementID != "", "has_slate": out.SlateID != "", "submitted": out.SubmittedAt != "", "award_decision_set": out.AwardDate != "", }) e := audit.Event{Action: action, EntityType: "bid_outcome", EntityID: out.ID, NewValues: payload} if p, ok := tenant.UserFromContext(ctx); ok { if uid, perr := uuid.Parse(p.UserID); perr == nil { e.ActorUserID = &uid } } return s.aud.OnTx(ctx, tx, e) } // scanOutcome scans a returned bid_outcomes row into the DTO, mapping nullable links and timestamps to // empty strings so the JSON omits them. func scanOutcome(row pgx.Row) (BidOutcome, error) { var ( id uuid.UUID oppID *uuid.UUID reqID *uuid.UUID slateID *uuid.UUID submittedAt *time.Time result string awardDate *time.Time awardeeUEI string notes string createdAt time.Time updatedAt time.Time ) if err := row.Scan(&id, &oppID, &reqID, &slateID, &submittedAt, &result, &awardDate, &awardeeUEI, ¬es, &createdAt, &updatedAt); err != nil { return BidOutcome{}, err } return BidOutcome{ ID: id.String(), OpportunityID: uuidStr(oppID), RequirementID: uuidStr(reqID), SlateID: uuidStr(slateID), SubmittedAt: timeStr(submittedAt), Result: result, AwardDate: dateStr(awardDate), AwardeeUEI: awardeeUEI, Notes: notes, CreatedAt: createdAt.Format(time.RFC3339), UpdatedAt: updatedAt.Format(time.RFC3339), }, nil } // parseLinks parses the optional row id and the three optional link ids, returning typed uuid pointers that // bind to NULL when empty. A non empty value that is not a uuid is rejected with ErrInvalidID so a bad link // is a clean 422, never a raw bind error. func parseLinks(rowID, oppID, reqID, slateID string) (row, opp, req, slate *uuid.UUID, err error) { if row, err = parseOptUUID(rowID); err != nil { return } if opp, err = parseOptUUID(oppID); err != nil { return } if req, err = parseOptUUID(reqID); err != nil { return } slate, err = parseOptUUID(slateID) return } // parseOptUUID parses an optional uuid: an empty string is a nil pointer (NULL), a valid uuid is its // pointer, and anything else is ErrInvalidID. func parseOptUUID(s string) (*uuid.UUID, error) { if s == "" { return nil, nil } id, err := uuid.Parse(s) if err != nil { return nil, ErrInvalidID } return &id, nil } // parseTime parses an optional RFC3339 timestamp; empty is a nil pointer (NULL). func parseTime(s string) (*time.Time, error) { if s == "" { return nil, nil } t, err := time.Parse(time.RFC3339, s) if err != nil { return nil, err } return &t, nil } // parseDate parses an optional award date, accepting either a bare date (2006-01-02) or a full RFC3339 // timestamp; empty is a nil pointer (NULL). The DB column is a date, so a parsed time is truncated to its // day at bind time by pgx. func parseDate(s string) (*time.Time, error) { if s == "" { return nil, nil } if t, err := time.Parse("2006-01-02", s); err == nil { return &t, nil } t, err := time.Parse(time.RFC3339, s) if err != nil { return nil, err } return &t, nil } func uuidStr(id *uuid.UUID) string { if id == nil { return "" } return id.String() } func timeStr(t *time.Time) string { if t == nil { return "" } return t.Format(time.RFC3339) } func dateStr(t *time.Time) string { if t == nil { return "" } return t.Format("2006-01-02") }