Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/bidoutcomes/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/bidoutcomes/service.go |
| Original SHA-256 | c5fb57ab9fef17447aa4a4a060238dbacceb4333d21f90146be8ef3be2a6fd68 |
| Git blob SHA | 8fc971cb2efb882a73cab23dca4aacb7873b1ccd |
| Reading-copy SHA-256 | c5fb57ab9fef17447aa4a4a060238dbacceb4333d21f90146be8ef3be2a6fd68 |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/bidoutcomes/service.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/modules/bidoutcomes/service.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓273 lines · 9.1 KB
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")
}