diff --git a/cmd/jobsearch/main.go b/cmd/jobsearch/main.go index 67085de..17b4da9 100644 --- a/cmd/jobsearch/main.go +++ b/cmd/jobsearch/main.go @@ -19,9 +19,8 @@ package main import ( "fmt" - "os" - "log/slog" + "os" "strings" "git.avsdev.uk/Go/job-search/internal/cli" diff --git a/internal/cli/fetch/once.go b/internal/cli/fetch/once.go index e88c5cb..21d4af1 100644 --- a/internal/cli/fetch/once.go +++ b/internal/cli/fetch/once.go @@ -9,17 +9,41 @@ import ( "git.avsdev.uk/Go/job-search/internal/cli" "git.avsdev.uk/Go/job-search/internal/findATender" - "git.avsdev.uk/Go/job-search/internal/findATender/ocds" + "git.avsdev.uk/Go/job-search/internal/types" "github.com/spf13/cobra" ) -func filter(reqs []ocds.Release, tag string) []ocds.Release { - var resp []ocds.Release - for _, req := range reqs { - if slices.Contains(req.Tag, tag) { - resp = append(resp, req) +// Release tags for each stage of a procedure, from the OCDS releaseTag +// codelist. Cancellations are left out, as a cancelled procedure is no longer +// at any stage. +var stageTags = map[string][]string{ + "planning": {"planning", "planningUpdate"}, + "opportunity": {"tender", "tenderAmendment", "tenderUpdate"}, + "award": { + "award", "awardUpdate", + "contract", "contractUpdate", "contractAmendment", + "implementation", "implementationUpdate", "contractTermination", + }, +} + +// filter keeps the opportunities whose latest release is at stage. Releases +// are oldest first, so the last one is the procedure's current stage. +func filter(opps []*types.Opportunity, stage string) []*types.Opportunity { + var resp []*types.Opportunity + + for _, opp := range opps { + if len(opp.Releases) == 0 { + continue + } + latest := opp.Releases[len(opp.Releases)-1] + for _, tag := range latest.Tags { + if slices.Contains(stageTags[stage], tag.Tag) { + resp = append(resp, opp) + break + } } } + return resp } @@ -60,14 +84,6 @@ func commandOnce(opts *cli.Options) *cobra.Command { opportunities, _ := cmd.Flags().GetBool("opportunities") awards, _ := cmd.Flags().GetBool("awards") - if (planning && (opportunities || awards)) || - (opportunities && (planning || awards)) || - (awards && (planning || opportunities)) { - return fmt.Errorf( - "--planning, --opportunities and --awards are exclusive, only one can be provided", - ) - } - filterTag := "" if planning { filterTag = "planning" @@ -112,5 +128,6 @@ func commandOnce(opts *cli.Options) *cobra.Command { cmd.Flags().Bool( "ignore-rate-limit", false, "ignore the rate limit and fail if exceeded", ) + cmd.MarkFlagsMutuallyExclusive("planning", "opportunities", "awards") return cmd } diff --git a/internal/findATender/api.go b/internal/findATender/api.go index 27b568b..dd1168c 100644 --- a/internal/findATender/api.go +++ b/internal/findATender/api.go @@ -1,12 +1,16 @@ package findATender import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "log/slog" + "regexp" "strconv" + "strings" "time" "git.avsdev.uk/Go/job-search/internal/findATender/ocds" @@ -18,6 +22,7 @@ var ErrNoNext = errors.New("no next link") var ErrNoReleases = errors.New("no releases") var ErrRateLimitExceeded = errors.New("rate limit has been exceeded") var ErrFetchCancelled = errors.New("fetch cancelled") +var ErrUnexpectedStatus = errors.New("unexpected response status") type RequestOptions struct { Since time.Time @@ -25,6 +30,232 @@ type RequestOptions struct { AllowRateLimit bool } +// ---------- Utility methods for handling API irregularities ----------- +type safeReleasePackage struct { + URI string `json:"uri,omitempty"` + Version string `json:"version,omitempty"` + Extensions []string `json:"extensions,omitempty"` + PublishedDate *string `json:"publishedDate,omitempty"` + Releases []json.RawMessage `json:"releases,omitempty"` + Publisher *ocds.ReleasePackagePublisher `json:"publisher,omitempty"` + License string `json:"license,omitempty"` + PublicationPolicy string `json:"publicationPolicy,omitempty"` + Links *ocds.ReleasePackageLinks `json:"links,omitempty"` +} + +// dateOnly matches a calendar date with no time of day: "2006-10-02". +var dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +// unmarshalLenientDates decodes raw json into v like json.Unmarshal, after +// repairing date fields that time.Time cannot parse. +func unmarshalLenientDates(raw json.RawMessage, v any) error { + fixed, err := repairDates(raw) + if err != nil { + return err + } + return json.Unmarshal(fixed, v) +} + +// repairDates returns a copy of raw in which every date field holding a +// date-only value ("2006-10-02") is rewritten as midnight UTC +// ("2006-10-02T00:00:00Z"), and every date field holding an empty string is +// set to null. Values that already have a time, values in other fields, and +// dates that appear inside free text are left alone. If nothing needs +// repairing, raw is returned unchanged. +// +// A date field is one whose name is "date" or starts with "date", contains +// "Date", or ends with "Deadline". +func repairDates(raw json.RawMessage) (json.RawMessage, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() // keep numbers exactly as written, not as float64 + + var doc any + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("repairing dates: %w", err) + } + + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, errors.New( + "repairing dates: unexpected data after the JSON value", + ) + } + + if !recursiveRepairDates(doc) { + return raw, nil + } + + return json.Marshal(doc) +} + +// recursiveRepairDates fixes date fields in v in place and reports whether it +// changed anything. +func recursiveRepairDates(v any) bool { + changed := false + switch v := v.(type) { + case map[string]any: + for key, val := range v { + if s, ok := val.(string); ok && isDateField(key) { + switch { + case dateOnly.MatchString(s): + v[key] = s + "T00:00:00Z" + changed = true + case s == "": + v[key] = nil + changed = true + } + continue + } + if recursiveRepairDates(val) { + changed = true + } + } + case []any: + for _, el := range v { + if recursiveRepairDates(el) { + changed = true + } + } + } + return changed +} + +func isDateField(name string) bool { + return strings.HasPrefix(name, "date") || + strings.Contains(name, "Date") || + strings.HasSuffix(name, "Deadline") +} + +// ReleaseIDs reads just the ocid and the release id from a release, without +// decoding the rest of it. It reads the top-level fields in order and stops +// as soon as it has both, so an error later in the release (invalid JSON or a +// value that wouldn't fit the release struct) doesn't matter. Fields before +// them only need to be valid JSON. +func releaseIDs(raw json.RawMessage) (ocid, id string, err error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() // a numeric id keeps its exact digits + + if tok, err := dec.Token(); err != nil { + return "", "", fmt.Errorf("reading release: %w", err) + } else if tok != json.Delim('{') { + return "", "", errors.New("reading release: not a JSON object") + } + + var haveOCID, haveID bool + for dec.More() { + tok, err := dec.Token() + if err != nil { + return ocid, id, fmt.Errorf("reading release: %w", err) + } + switch key, _ := tok.(string); key { + case "ocid": + if err := dec.Decode(&ocid); err != nil { + return ocid, id, fmt.Errorf("reading release ocid: %w", err) + } + haveOCID = true + case "id": + if id, err = decodeReleaseID(dec); err != nil { + return ocid, id, err + } + haveID = true + default: + var skip json.RawMessage // must be valid JSON, but is otherwise ignored + if err := dec.Decode(&skip); err != nil { + return ocid, id, fmt.Errorf("reading release field %q: %w", key, err) + } + } + if haveOCID && haveID { + return ocid, id, nil + } + } + + return ocid, id, missingIDsError(haveOCID, haveID) +} + +// decodeReleaseID decodes a release id. OCDS ids are strings, but a number is +// tolerated and kept as written. +func decodeReleaseID(dec *json.Decoder) (string, error) { + var v any + if err := dec.Decode(&v); err != nil { + return "", fmt.Errorf("reading release id: %w", err) + } + switch v := v.(type) { + case string: + return v, nil + case json.Number: + return v.String(), nil + default: + return "", fmt.Errorf("reading release id: unexpected %T", v) + } +} + +// missingIDsError reports which of the ocid and release id were not found. +func missingIDsError(haveOCID, haveID bool) error { + switch { + case !haveOCID && !haveID: + return errors.New("reading release: no ocid or id") + case !haveOCID: + return errors.New("reading release: no ocid") + default: + return errors.New("reading release: no id") + } +} + +// Unpacks an API response into an OCDS ReleasePackage. Handles API +// irregularities (malformed dates, missing fields, invalid releases, etc). +// +// This method takes ownership of the response body and closes it when done. +func safeUnpackReleases(response *resty.Response) (*ocds.ReleasePackage, error) { + var pkg safeReleasePackage + + // Take ownership of the response body. + defer response.Body.Close() + + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&pkg); err != nil { + return nil, err + } + + out := &ocds.ReleasePackage{ + URI: pkg.URI, + Version: pkg.Version, + Extensions: pkg.Extensions, + Publisher: pkg.Publisher, + License: pkg.License, + PublicationPolicy: pkg.PublicationPolicy, + Links: pkg.Links, + } + + // Correct PublishedDate of "" and "2006-01-02" + if pkg.PublishedDate != nil && len(*pkg.PublishedDate) > 0 { + t, err := time.Parse(time.RFC3339, *pkg.PublishedDate) + if err != nil { + t, err = time.Parse(time.DateOnly, *pkg.PublishedDate) + } + if err == nil { + out.PublishedDate = &t + } + } + + for _, rawRel := range pkg.Releases { + var release ocds.Release + err := unmarshalLenientDates(rawRel, &release) + if err != nil { + ocid, release_id, _ := releaseIDs(rawRel) + slog.Warn(fmt.Sprintf( + "Skipping release (%s/%s) due to error: %s", ocid, release_id, err, + )) + continue + } + out.Releases = append(out.Releases, release) + } + + return out, nil +} + +// ---------- API interaction methods ----------- +// Sleep the fetch worker until either the context is cancelled (shutdown) or +// the duration has expired. The duration is usually provided by the API - a +// nil duration will return immediately. func rateSleep(ctx context.Context, duration time.Duration) error { timer := time.NewTimer(duration) defer timer.Stop() @@ -37,6 +268,14 @@ func rateSleep(ctx context.Context, duration time.Duration) error { } } +// Check and close the response body - avoids double close issues +func closeBody(res *resty.Response) { + if res.Body != nil { + res.Body.Close() + } +} + +// Actually perform a single fetch against the API func fetchURI(ft *FindATender, uri string) (*resty.Response, error) { withTrace := ft.config.Logging.LogLevel() <= slog.LevelDebug @@ -69,6 +308,11 @@ func fetchURI(ft *FindATender, uri string) (*resty.Response, error) { return res, err } +// Perform a fetch against the API. If the API returns with a rate limit +// reached response and allowRateLimit is true, then wait and retry like a good +// client. If allowRateLimit is false, return to the caller informing them of +// the rate limit exceeded error. If, after waiting, the API still thinks we +// have exceeded the rate limit, bail out anyway. func (ft *FindATender) fetchURI( uri string, allowRateLimit bool, @@ -85,6 +329,8 @@ func (ft *FindATender) fetchURI( } if res.StatusCode() == 429 { + closeBody(res) + if !allowRateLimit { return nil, ErrRateLimitExceeded } @@ -106,16 +352,26 @@ func (ft *FindATender) fetchURI( if err != nil { return nil, err } + if res.StatusCode() == 429 { + closeBody(res) + return nil, ErrRateLimitExceeded + } + } + + if res.StatusCode() != 200 { + closeBody(res) + return nil, fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status()) } return res, nil } +// Perform a fetch against the API with some sanitised default options. func (ft *FindATender) fetchWithOpts( opts RequestOptions, ) (*resty.Response, error) { if opts.Since.IsZero() { - opts.Since = time.Now().AddDate(0, 0, 1) + opts.Since = time.Now().AddDate(0, 0, -1) } if opts.Limit == 0 { opts.Limit = 100 @@ -131,8 +387,11 @@ func (ft *FindATender) fetchWithOpts( return ft.fetchURI(uri, opts.AllowRateLimit) } +// If the response provided a "Next" link, perform another API call to get the +// next page. Error if there is no "Next" link - the caller can decide if they +// have attempted to fetch next in error or not. func (ft *FindATender) fetchNext( - pkg ocds.ReleasePackage, + pkg *ocds.ReleasePackage, allowRateLimit bool, ) (*resty.Response, error) { if pkg.Links == nil || len(pkg.Links.Next) == 0 { @@ -141,26 +400,27 @@ func (ft *FindATender) fetchNext( return ft.fetchURI(pkg.Links.Next, allowRateLimit) } +// Make one call on the API. Do not follow any "Next" links. func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) { res, err := ft.fetchWithOpts(opts) if err != nil { return nil, err } - defer res.Body.Close() - var relPkg ocds.ReleasePackage - decoder := json.NewDecoder(NewOCDSStreamCleaner(res.Body)) - if err := decoder.Decode(&relPkg); err != nil { + pkg, err := safeUnpackReleases(res) + if err != nil { return nil, err } - if len(relPkg.Releases) == 0 { + if len(pkg.Releases) == 0 { return nil, ErrNoReleases } - return relPkg.Releases, nil + return pkg.Releases, nil } +// Make a call on the API. If there are any "Next" links, call them in +// succession until the end has been reached. func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) { var releases []ocds.Release @@ -168,30 +428,40 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) { if err != nil { return nil, err } - defer res.Body.Close() - var relPkg ocds.ReleasePackage - decoder := json.NewDecoder(NewOCDSStreamCleaner(res.Body)) - if err := decoder.Decode(&relPkg); err != nil { + pkg, err := safeUnpackReleases(res) + if err != nil { return nil, err } - releases = append(releases, relPkg.Releases...) + releases = append(releases, pkg.Releases...) - for true { - res, err := ft.fetchNext(relPkg, opts.AllowRateLimit) + // Next links already followed, so a page that links back to an earlier + // one cannot loop forever. + seen := make(map[string]bool) + for { + if pkg.Links != nil && len(pkg.Links.Next) > 0 { + if seen[pkg.Links.Next] { + slog.Warn(fmt.Sprintf( + "Stopping at repeated next link: %s", pkg.Links.Next, + )) + break + } + seen[pkg.Links.Next] = true + } + + res, err := ft.fetchNext(pkg, opts.AllowRateLimit) if err == ErrNoNext { break } if err != nil { return nil, err } - defer res.Body.Close() - decoder := json.NewDecoder(NewOCDSStreamCleaner(res.Body)) - if err := decoder.Decode(&relPkg); err != nil { + pkg, err = safeUnpackReleases(res) + if err != nil { return nil, err } - releases = append(releases, relPkg.Releases...) + releases = append(releases, pkg.Releases...) } if len(releases) == 0 { diff --git a/internal/findATender/dateRepair.go b/internal/findATender/dateRepair.go deleted file mode 100644 index 849e533..0000000 --- a/internal/findATender/dateRepair.go +++ /dev/null @@ -1,43 +0,0 @@ -package findATender - -import ( - "bufio" - "bytes" - "io" - "regexp" -) - -// Global regex to identify and catch date-only fields -var invalidDateRegex = regexp.MustCompile(`:\s*"(\d{4}-\d{2}-\d{2})"\s*([,}\s])`) -var invalidPublishedDateRegex = regexp.MustCompile(`"publishedDate":\s*""`) - -// The UK Government FindATender service doesn't strictly follow OCDS date -// format. Sometimes it provides dates only, sometimes it sends empty strings -// instead of nulls. -type FindATenderStreamCleaner struct { - reader *bufio.Reader - buffer bytes.Buffer -} - -func NewOCDSStreamCleaner(r io.Reader) *FindATenderStreamCleaner { - return &FindATenderStreamCleaner{reader: bufio.NewReader(r)} -} - -func (sc *FindATenderStreamCleaner) Read(p []byte) (int, error) { - if sc.buffer.Len() == 0 { - line, err := sc.reader.ReadBytes('\n') - if len(line) > 0 { - // Convert "2026-11-16" -> "2026-11-16T00:00:00Z" - line = invalidDateRegex.ReplaceAll(line, []byte(`:"$1T00:00:00Z"`)) - // Convert {publishedDate:""} -> "{publishedDate:null}" - line = invalidPublishedDateRegex.ReplaceAll(line, []byte(`"publishedDate":null`)) - sc.buffer.Write(line) - } - if err != nil { - if sc.buffer.Len() == 0 { - return 0, err - } - } - } - return sc.buffer.Read(p) -} diff --git a/internal/findATender/findATender.go b/internal/findATender/findATender.go index 2e31b53..f48e126 100644 --- a/internal/findATender/findATender.go +++ b/internal/findATender/findATender.go @@ -5,7 +5,7 @@ import ( "time" "git.avsdev.uk/Go/job-search/internal/config" - "git.avsdev.uk/Go/job-search/internal/findATender/ocds" + "git.avsdev.uk/Go/job-search/internal/types" resty "resty.dev/v3" ) @@ -29,7 +29,6 @@ func New(conf *config.JobSearchConfig, apiRoot string) FindATender { func (ft *FindATender) ReloadConfig(conf *config.JobSearchConfig) { ft.config = conf - // TODO: any reloading required for configuration change handling } func (ft *FindATender) With(ctx context.Context) *FindATender { @@ -49,27 +48,27 @@ func (ft *FindATender) Shutdown() { func (ft *FindATender) FetchOnce( since time.Time, allowRateLimit bool, -) ([]ocds.Release, error) { - reqs, err := ft.fetchOnce(RequestOptions{ +) ([]*types.Opportunity, error) { + releases, err := ft.fetchOnce(RequestOptions{ Since: since, AllowRateLimit: allowRateLimit, }) if err != nil { return nil, err } - return reqs, nil + return Convert(releases), nil } func (ft *FindATender) FetchAll( since time.Time, allowRateLimit bool, -) ([]ocds.Release, error) { - reqs, err := ft.fetchAll(RequestOptions{ +) ([]*types.Opportunity, error) { + releases, err := ft.fetchAll(RequestOptions{ Since: since, AllowRateLimit: allowRateLimit, }) if err != nil { return nil, err } - return reqs, nil + return Convert(releases), nil } diff --git a/internal/findATender/typeConvert.go b/internal/findATender/typeConvert.go new file mode 100644 index 0000000..3598cd0 --- /dev/null +++ b/internal/findATender/typeConvert.go @@ -0,0 +1,190 @@ +package findATender + +import ( + "encoding/json" + "slices" + "strings" + + "git.avsdev.uk/Go/job-search/internal/findATender/ocds" + "git.avsdev.uk/Go/job-search/internal/types" +) + +func ocdsGetBuyerId(release *ocds.Release) string { + if release.Buyer != nil { + return string(release.Buyer.ID) + } + return "" +} +func ocdsGetBuyerName(release *ocds.Release) string { + if release.Buyer != nil { + return release.Buyer.Name + } + return "" +} + +func ocdsConvertClassification( + classif *ocds.Classification, +) types.ClassificationCode { + return types.ClassificationCode{ + IsCPV: strings.Compare(classif.Scheme, "CPV") == 0, + Code: string(classif.ID), + Classification: classif.Description, + } +} + +func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode { + classifications := make([]types.ClassificationCode, 0) + if release.Tender == nil { + return classifications + } + add := func(classif *ocds.Classification) { + newClassif := ocdsConvertClassification(classif) + found := slices.ContainsFunc( + classifications, func(r types.ClassificationCode) bool { + return r.Code == newClassif.Code + }, + ) + if !found { + classifications = append(classifications, newClassif) + } + } + if release.Tender.Classification != nil { + add(release.Tender.Classification) + } + for _, val := range release.Tender.Items { + if val.Classification != nil { + add(val.Classification) + } + for _, classif := range val.AdditionalClassifications { + add(&classif) + } + } + return classifications +} + +func ocdsIsSMESuitable(release *ocds.Release) bool { + if release.Tender == nil { + return false + } + if len(release.Tender.Lots) == 0 { + return false + } + for _, lot := range release.Tender.Lots { + if lot.Suitability != nil { + if lot.Suitability.SME != nil && *lot.Suitability.SME { + return true + } + } + } + return false +} +func ocdsIsVCSESuitable(release *ocds.Release) bool { + if release.Tender == nil { + return false + } + if len(release.Tender.Lots) == 0 { + return false + } + for _, lot := range release.Tender.Lots { + if lot.Suitability != nil { + if lot.Suitability.Vcse != nil && *lot.Suitability.Vcse { + return true + } + } + } + return false +} + +func ocdsRelease(release *ocds.Release) types.OpportunityRelease { + tags := make([]types.OpportunityReleaseTag, 0) + for _, val := range release.Tag { + tags = append(tags, types.OpportunityReleaseTag{ + UUID: release.OCID, + ReleaseID: release.ID, + Tag: val, + }) + } + + rel := types.OpportunityRelease{ + UUID: release.OCID, + ReleaseID: release.ID, + Tags: tags, + ReleaseDate: release.Date, + } + + reencoded, err := json.Marshal(release) + if err == nil { + rel.JSON = string(reencoded) + } + + return rel +} + +func convertOne(release *ocds.Release) *types.Opportunity { + t := &types.Opportunity{ + UUID: release.OCID, + UUIDType: "ocid", + BuyerId: ocdsGetBuyerId(release), + BuyerName: ocdsGetBuyerName(release), + } + + if release.Tender != nil { + t.Title = release.Tender.Title + t.Status = release.Tender.Status + t.Description = release.Tender.Description + t.Classifications = ocdsGetClassifications(release) + t.MainCategory = release.Tender.MainProcurementCategory + t.ProcurementMethod = release.Tender.ProcurementMethod + t.SMESuitable = ocdsIsSMESuitable(release) + t.VCSESuitable = ocdsIsVCSESuitable(release) + if release.Tender.TenderPeriod != nil { + t.StartDate = release.Tender.TenderPeriod.StartDate + t.EndDate = release.Tender.TenderPeriod.EndDate + } + if release.Tender.Value != nil && release.Tender.Value.Amount != nil { + t.GrossValue = *release.Tender.Value.Amount + } + t.HasDocuments = len(release.Tender.Documents) > 0 + t.MultiLot = len(release.Tender.Lots) > 1 + } + + t.LatestReleaseDate = release.Date + t.Releases = append(t.Releases, ocdsRelease(release)) + + return t +} + +// Convert groups releases by ocid into opportunities, in the order each ocid +// first appears. Each opportunity is built from its latest release (by date, +// or the later one in releases on a tie) and lists all of its releases oldest +// first. +func Convert(releases []ocds.Release) []*types.Opportunity { + var order []string + byOCID := make(map[string][]*ocds.Release) + + for idx := range releases { + release := &releases[idx] + if _, found := byOCID[release.OCID]; !found { + order = append(order, release.OCID) + } + byOCID[release.OCID] = append(byOCID[release.OCID], release) + } + + opportunities := make([]*types.Opportunity, 0, len(order)) + for _, ocid := range order { + group := byOCID[ocid] + + slices.SortStableFunc(group, func(a, b *ocds.Release) int { + return a.Date.Compare(b.Date) + }) + + opportunity := convertOne(group[len(group)-1]) + opportunity.Releases = make([]types.OpportunityRelease, 0, len(group)) + for _, release := range group { + opportunity.Releases = append(opportunity.Releases, ocdsRelease(release)) + } + opportunities = append(opportunities, opportunity) + } + + return opportunities +} diff --git a/internal/types/classifications.go b/internal/types/classifications.go new file mode 100644 index 0000000..23531a7 --- /dev/null +++ b/internal/types/classifications.go @@ -0,0 +1,8 @@ +package types + +type ClassificationCode struct { + ID int64 `db:"id" json:"id"` + IsCPV bool `db:"is_cpv" json:"is_cpv"` + Code string `db:"code" json:"code"` + Classification string `db:"classification" json:"classification"` +} diff --git a/internal/types/opportunities.go b/internal/types/opportunities.go new file mode 100644 index 0000000..39ac0eb --- /dev/null +++ b/internal/types/opportunities.go @@ -0,0 +1,41 @@ +package types + +import ( + "time" +) + +type Opportunity struct { + UUID string `db:"uuid" json:"uuid"` + UUIDType string `db:"uuid_type" json:"uuid_type"` + Title string `db:"title" json:"title"` + Status string `db:"status" json:"status"` + Description string `db:"description" json:"description"` + BuyerName string `db:"buyer_name" json:"buyer_name"` + Classifications []ClassificationCode `db:"-" json:"classifications"` + BuyerId string `db:"buyer_id" json:"buyer_id"` + MainCategory string `db:"main_category" json:"main_category"` + ProcurementMethod string `db:"procurement_method" json:"procurement_method"` + MultiLot bool `db:"multi_lot" json:"multi_lot"` + SMESuitable bool `db:"sme_suitable" json:"sme_suitable"` + VCSESuitable bool `db:"vcse_suitable" json:"vcse_suitable"` + StartDate *time.Time `db:"start_date" json:"start_date"` + EndDate *time.Time `db:"end_date" json:"end_date"` + GrossValue float64 `db:"gross_value" json:"gross_value"` + HasDocuments bool `db:"has_documents" json:"has_documents"` + LatestReleaseDate time.Time `db:"latest_release_date" json:"latest_release_date"` + Releases []OpportunityRelease `db:"-" json:"releases"` +} + +type OpportunityRelease struct { + UUID string `db:"uuid" json:"uuid"` + ReleaseID string `db:"release_id" json:"release_id"` + ReleaseDate time.Time `db:"release_date" json:"release_date"` + Tags []OpportunityReleaseTag `db:"-" json:"release_tags"` + JSON string `db:"data" json:"data"` +} + +type OpportunityReleaseTag struct { + UUID string `db:"uuid" json:"uuid"` + ReleaseID string `db:"release_id" json:"release_id"` + Tag string `db:"tag" json:"tag"` +}