package samgov import ( "encoding/json" "strings" "time" ) // Normalized is the flat, app-facing shape of a SAM opportunity. It uses only Go-native types (no // DB/pgtype dependency) — the sync bridges it to the dbgen upsert params. Optional timestamps are // pointers (nil = absent); text is empty-string when absent. type Normalized struct { NoticeID string Title string SolicitationNumber string Department string // first segment of fullParentPathName FullParentPath string SubAgency string // normalized: AIR FORCE / SPACE FORCE / second segment NaicsCode string PSCCode string // classificationCode SetAsideCode string // typeOfSetAside SetAside string // typeOfSetAsideDescription NoticeType string // type BaseType string PostedDate *time.Time ResponseDeadline *time.Time Active bool POPCity string POPState string POPZip string UILink string DescriptionURL string // description (a URL) ResourceLinks json.RawMessage // json array, never nil ("[]") POC json.RawMessage // pointOfContact json array, never nil ("[]") Raw json.RawMessage // the full SAM notice JSON Source string } // Normalize maps a fetched SAM record into a Normalized row. func Normalize(rec Record) Normalized { o := rec.Op n := Normalized{ NoticeID: strings.TrimSpace(o.NoticeID), Title: strings.TrimSpace(o.Title), SolicitationNumber: strings.TrimSpace(o.SolicitationNumber), Department: firstSegment(o.FullParentPathName), FullParentPath: strings.TrimSpace(o.FullParentPathName), SubAgency: subAgency(o.FullParentPathName), NaicsCode: strings.TrimSpace(o.NaicsCode), PSCCode: strings.TrimSpace(o.ClassificationCode), SetAsideCode: strings.TrimSpace(o.TypeOfSetAside), SetAside: strings.TrimSpace(o.TypeOfSetAsideDescription), NoticeType: strings.TrimSpace(o.Type), BaseType: strings.TrimSpace(o.BaseType), PostedDate: parseDate(o.PostedDate), ResponseDeadline: parseDeadline(o.ResponseDeadLine), Active: strings.EqualFold(strings.TrimSpace(o.Active), "Yes"), POPCity: strings.TrimSpace(o.PlaceOfPerformance.City.Name), POPState: strings.TrimSpace(o.PlaceOfPerformance.State.Code), POPZip: strings.TrimSpace(o.PlaceOfPerformance.Zip), UILink: strings.TrimSpace(o.UILink), DescriptionURL: strings.TrimSpace(o.Description), ResourceLinks: jsonArray(o.ResourceLinks), POC: jsonArray(o.PointOfContact), Raw: rawOrNull(rec.Raw), Source: "SAM.gov", } return n } // firstSegment returns the first dotted segment of the agency hierarchy (the department). func firstSegment(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if i := strings.IndexByte(path, '.'); i >= 0 { return strings.TrimSpace(path[:i]) } return path } // subAgency normalizes the customer below the department. The Galapagos demo cares about Air Force // and Space Force specifically (SAM spells these inconsistently), so detect them by substring; else // fall back to the second dotted segment. func subAgency(path string) string { up := strings.ToUpper(path) switch { case strings.Contains(up, "SPACE FORCE"): return "SPACE FORCE" case strings.Contains(up, "AIR FORCE"): return "AIR FORCE" } parts := strings.Split(path, ".") if len(parts) >= 2 { return strings.TrimSpace(parts[1]) } return "" } // parseDate parses SAM's postedDate ("2006-01-02", sometimes with a time) to a civil date. func parseDate(s string) *time.Time { s = strings.TrimSpace(s) if s == "" { return nil } for _, layout := range []string{"2006-01-02", time.RFC3339, "2006-01-02T15:04:05", "2006-01-02-07:00"} { if t, err := time.Parse(layout, s); err == nil { d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return &d } } return nil } // parseDeadline parses responseDeadLine, which carries a time and often an offset. func parseDeadline(s string) *time.Time { s = strings.TrimSpace(s) if s == "" { return nil } for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-07:00", "2006-01-02T15:04:05", "2006-01-02"} { if t, err := time.Parse(layout, s); err == nil { return &t } } return nil } // jsonArray marshals v to a JSON array, never returning nil (defaults to "[]" so the jsonb column is // always valid and non-null). func jsonArray(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil || len(b) == 0 || string(b) == "null" { return json.RawMessage("[]") } return b } func rawOrNull(raw json.RawMessage) json.RawMessage { if len(raw) == 0 { return json.RawMessage("{}") } return raw }