package oppintel import ( "context" "errors" "strings" "time" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when no pool is configured (the handler maps it to 503). var ErrNoDatabase = errors.New("oppintel: no database configured") const ( defaultLimit = 100 maxLimit = 500 ) // planningNote is the standing label on every forecast record, so the UI and any export state plainly // that the row is planning data, not a posted solicitation. const planningNote = "Planning data subject to change" // Service reads the global opportunity_records catalog. Like the SAM opportunities reader it reads // straight off the pool (the catalog is public reference data outside RLS), never a tenant transaction. type Service struct{ db *tenant.DB } // NewService builds the service. db may be nil (then every call returns ErrNoDatabase, a 503). func NewService(db *tenant.DB) *Service { return &Service{db: db} } // Filters are the optional read filters. Empty slices mean no filter on that dimension. type Filters struct { Kinds []string NAICS []string Agencies []string States []string Limit int } // List returns normalized records matching the filters, soonest closing first. func (s *Service) List(ctx context.Context, f Filters) ([]View, error) { if s.db == nil || s.db.Pool == nil { return nil, ErrNoDatabase } limit := f.Limit if limit <= 0 { limit = defaultLimit } if limit > maxLimit { limit = maxLimit } rows, err := dbgen.New(s.db.Pool).ListOpportunityRecords(ctx, dbgen.ListOpportunityRecordsParams{ Kinds: nilIfEmpty(f.Kinds), Naics: nilIfEmpty(f.NAICS), Agencies: upperAll(nilIfEmpty(f.Agencies)), States: upperAll(nilIfEmpty(f.States)), Lim: int32(limit), }) if err != nil { return nil, err } out := make([]View, 0, len(rows)) for _, r := range rows { out = append(out, toView(r)) } return out, nil } func toView(r dbgen.OpportunityRecord) View { v := View{ ID: r.ID.String(), Source: r.SourceName, SourceRecordID: r.SourceRecordID, Kind: r.RecordKind, IsPlanning: r.IsPlanning, Title: r.Title, Agency: r.Agency, SubAgency: r.SubAgency, NAICS: r.Naics, PSC: r.Psc, PlaceOfPerformance: placeStr(r.PopCity, r.PopState, r.PopCountry), SetAside: r.SetAside, ContractVehicle: r.ContractVehicle, ValueSource: r.ValueSource, IncumbentName: r.IncumbentName, UILink: r.UiLink, SyncedAt: r.SyncedAt.Format(time.RFC3339), } if r.IsPlanning { v.PlanningNote = planningNote } if r.EstimatedValue != nil { v.EstimatedValue = r.EstimatedValue.StringFixed(2) } if r.PostedDate.Valid { v.PostedDate = r.PostedDate.Time.Format("2006-01-02") } if r.ResponseDeadline != nil { v.ResponseDeadline = r.ResponseDeadline.Format(time.RFC3339) } if r.EstimatedAwardDate.Valid { v.EstimatedAwardDate = r.EstimatedAwardDate.Time.Format("2006-01-02") } return v } func placeStr(city, state, country string) string { parts := make([]string, 0, 3) for _, p := range []string{strings.TrimSpace(city), strings.TrimSpace(state), strings.TrimSpace(country)} { if p != "" { parts = append(parts, p) } } return strings.Join(parts, ", ") } func nilIfEmpty(s []string) []string { if len(s) == 0 { return nil } return s } func upperAll(s []string) []string { if s == nil { return nil } out := make([]string, len(s)) for i, v := range s { out[i] = strings.ToUpper(v) } return out }