Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/opportunities/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/opportunities/service.go |
| Original SHA-256 | c49a23b501c9df8509ac2d39227cf9da89aac31f5cca715a5774278d86b28c57 |
| Git blob SHA | 88d8c9d43d0e6fd7316b5cc27cdbdca61a9130cd |
| Reading-copy SHA-256 | c49a23b501c9df8509ac2d39227cf9da89aac31f5cca715a5774278d86b28c57 |
Recovered source transcript
Download complete source text ↓172 lines · 4.8 KB
package opportunities
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/database/dbgen"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// ErrNoDatabase is returned when no pool is configured.
var ErrNoDatabase = errors.New("opportunities: no database configured")
// ErrInvalidInput is returned when a capture request is missing a required field or carries a value
// outside the closed vocabulary. The handler maps it to 400.
var ErrInvalidInput = errors.New("opportunities: invalid input")
const (
defaultLimit = 100
maxLimit = 500
)
// Service reads the cached SAM opportunities catalog AND owns the tenant tracked capture layer above it.
// The read path over the global cache needs no tenant tx (see pool.go); the capture path is tenant data,
// so it goes through RunInTenantTx and audits every write through aud.
type Service struct {
db *tenant.DB
aud *audit.Writer
}
// NewService builds the service. db may be nil (then every call returns ErrNoDatabase -> 503). aud writes
// the audit trail for the tenant capture path and may be nil only in tests that exercise the read path.
func NewService(db *tenant.DB, aud *audit.Writer) *Service { return &Service{db: db, aud: aud} }
// Filters are the optional read-API filters. Empty slices / empty Q mean "no filter".
type Filters struct {
NAICS []string
SetAsides []string
States []string
Departments []string
SubAgencies []string
Q string
ActiveOnly bool
Limit int
}
// List returns cached opportunities matching the filters. It reads the global catalog directly off
// the pool (no tenant tx, sam_opportunities is public and outside RLS).
func (s *Service) List(ctx context.Context, f Filters) ([]Opportunity, error) {
if !s.db.Ready() {
return nil, ErrNoDatabase
}
limit := f.Limit
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
var q *string
if v := strings.TrimSpace(f.Q); v != "" {
q = &v
}
// The text-equality filters match upper(column); upper-case the params so the comparison is
// case-insensitive [review COR-3]. NAICS is a numeric code, left as-is.
pool, release, perr := s.acquirePool(ctx)
if perr != nil {
return nil, perr
}
defer release()
rows, err := dbgen.New(pool).ListSamOpportunities(ctx, dbgen.ListSamOpportunitiesParams{
Naics: nilIfEmpty(f.NAICS),
SetAsides: upperAll(nilIfEmpty(f.SetAsides)),
States: upperAll(nilIfEmpty(f.States)),
Departments: upperAll(nilIfEmpty(f.Departments)),
SubAgencies: upperAll(nilIfEmpty(f.SubAgencies)),
Q: q,
ActiveOnly: f.ActiveOnly,
Lim: int32(limit),
})
if err != nil {
return nil, err
}
out := make([]Opportunity, 0, len(rows))
for _, r := range rows {
out = append(out, toView(r))
}
return out, nil
}
// Get returns one cached opportunity by notice id. A missing row surfaces pgx.ErrNoRows for the
// handler to map to 404.
func (s *Service) Get(ctx context.Context, noticeID string) (Opportunity, error) {
if !s.db.Ready() {
return Opportunity{}, ErrNoDatabase
}
pool, release, perr := s.acquirePool(ctx)
if perr != nil {
return Opportunity{}, perr
}
defer release()
r, err := dbgen.New(pool).GetSamOpportunity(ctx, noticeID)
if err != nil {
return Opportunity{}, err
}
return toView(r), nil
}
func toView(r dbgen.SamOpportunity) Opportunity {
posted := ""
if r.PostedDate.Valid {
posted = r.PostedDate.Time.Format("2006-01-02")
}
deadline := ""
if r.ResponseDeadline != nil {
deadline = r.ResponseDeadline.Format(time.RFC3339)
}
return Opportunity{
NoticeID: r.NoticeID,
Title: r.Title,
SolicitationNumber: r.SolicitationNumber,
Department: r.Department,
SubAgency: r.SubAgency,
FullAgencyPath: r.FullParentPath,
NaicsCode: r.NaicsCode,
PscCode: r.PscCode,
SetAsideCode: r.SetAsideCode,
SetAside: r.SetAside,
Type: r.NoticeType,
PostedDate: posted,
ResponseDeadline: deadline,
Active: r.Active,
PlaceOfPerformance: Place{City: r.PopCity, State: r.PopState, Zip: r.PopZip},
UILink: r.UiLink,
DescriptionURL: r.DescriptionUrl,
ResourceLinks: jsonOrEmpty(r.ResourceLinks),
PointOfContact: jsonOrEmpty(r.Poc),
Source: r.Source,
SyncedAt: r.SyncedAt.Format(time.RFC3339),
}
}
func jsonOrEmpty(b []byte) json.RawMessage {
if len(b) == 0 {
return json.RawMessage("[]")
}
return json.RawMessage(b)
}
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
}