diff --git a/internal/findATender/typeConvert.go b/internal/findATender/typeConvert.go index 782ab41..0c832c0 100644 --- a/internal/findATender/typeConvert.go +++ b/internal/findATender/typeConvert.go @@ -144,6 +144,7 @@ func convertOne(release *ocds.Release) *types.Opportunity { UUIDType: "ocid", BuyerId: ocdsGetBuyerId(release), BuyerName: ocdsGetBuyerName(release), + Source: "FindATender", } if release.Tender != nil { diff --git a/internal/findATender/typeConvert_test.go b/internal/findATender/typeConvert_test.go index 72c214c..50f1043 100644 --- a/internal/findATender/typeConvert_test.go +++ b/internal/findATender/typeConvert_test.go @@ -328,3 +328,28 @@ func TestConvertSameDate(t *testing.T) { t.Fatalf("got %+v, want one opportunity titled %q", got, "second") } } + +// Every converted opportunity records that it came from Find a Tender, +// whether or not its release has a tender. +func TestConvertSetsSource(t *testing.T) { + const want = "FindATender" + + for name, release := range map[string]*ocds.Release{ + "with tender": {OCID: "ocds-a", ID: "1", Tender: &ocds.Tender{Title: "A"}}, + "nil tender": {OCID: "ocds-b", ID: "1"}, + } { + if got := convertOne(release).Source; got != want { + t.Errorf("convertOne (%s): Source = %q, want %q", name, got, want) + } + } + + for _, o := range Convert([]ocds.Release{ + {OCID: "ocds-a", ID: "1", Tender: &ocds.Tender{Title: "A"}}, + {OCID: "ocds-a", ID: "2", Tender: &ocds.Tender{Title: "A amended"}}, + {OCID: "ocds-b", ID: "1"}, + }) { + if o.Source != want { + t.Errorf("Convert: %s Source = %q, want %q", o.UUID, o.Source, want) + } + } +} diff --git a/internal/mock/findATender/catalog.go b/internal/mock/findATender/catalog.go index 6759398..93af809 100644 --- a/internal/mock/findATender/catalog.go +++ b/internal/mock/findATender/catalog.go @@ -185,7 +185,7 @@ func clone[T any](v T) T { var out T b, err := json.Marshal(v) if err == nil { - _ = json.Unmarshal(b, &out) + json.Unmarshal(b, &out) //nolint:errcheck,gosec // b was just marshalled from a T } return out } diff --git a/internal/mock/findATender/generator.go b/internal/mock/findATender/generator.go index ed1cb10..f6dd7bd 100644 --- a/internal/mock/findATender/generator.go +++ b/internal/mock/findATender/generator.go @@ -287,6 +287,6 @@ func ensurePartyID(p *ocds.Organization) { return } h := fnv.New32a() - _, _ = h.Write([]byte(p.Name)) // never fails + h.Write([]byte(p.Name)) //nolint:gosec // never fails p.ID = fmt.Sprintf("GB-FTS-%d", h.Sum32()%1000000) } diff --git a/internal/mock/findATender/handler.go b/internal/mock/findATender/handler.go index 78c65a2..3517c4a 100644 --- a/internal/mock/findATender/handler.go +++ b/internal/mock/findATender/handler.go @@ -65,7 +65,8 @@ func decodeCursor(c string) (int, error) { func badRequest(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) // the client has gone + body := map[string]string{"error": msg} + json.NewEncoder(w).Encode(body) //nolint:errcheck,gosec // the client may have gone; nothing to do } // parseTime reads a query timestamp. Values without an offset (the documented diff --git a/internal/mock/findATender/ratelimit.go b/internal/mock/findATender/ratelimit.go index 21ee41d..da7f8d7 100644 --- a/internal/mock/findATender/ratelimit.go +++ b/internal/mock/findATender/ratelimit.go @@ -114,7 +114,7 @@ func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc { w.Header().Set("Retry-After", strconv.Itoa(retry)) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusTooManyRequests) - _, _ = fmt.Fprintf( + fmt.Fprintf( //nolint:errcheck // the client may have gone; nothing to do w, "Rate limit of %d exceeded. Please retry after %d seconds.", rl.limit, retry, ) diff --git a/internal/types/opportunities.go b/internal/types/opportunities.go index 59c1739..f2585b9 100644 --- a/internal/types/opportunities.go +++ b/internal/types/opportunities.go @@ -1,9 +1,14 @@ package types import ( + "errors" "time" ) +// Opportunity is a procurement opportunity (an OCDS contracting process, +// identified by UUID) as of its latest release, with its classifications, +// releases and the user's marks. Fields tagged db:"-" are stored in their +// own tables. type Opportunity struct { UUID string `db:"uuid" json:"uuid"` UUIDType string `db:"uuid_type" json:"uuid_type"` @@ -22,8 +27,10 @@ type Opportunity struct { 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"` + Source string `db:"source" json:"source"` LatestReleaseDate time.Time `db:"latest_release_date" json:"latest_release_date"` Releases []OpportunityRelease `db:"-" json:"releases"` + Marks OpportunityMarks `db:"-" json:"marks"` } // OpportunityRelease is one OCDS release of an opportunity. JSON holds the @@ -43,3 +50,30 @@ type OpportunityReleaseTag struct { ReleaseID string `db:"release_id" json:"release_id"` Tag string `db:"tag" json:"tag"` } + +// ErrWonAndLost is returned by OpportunityMarks.Validate when both Won and +// Lost are set. +var ErrWonAndLost = errors.New("an opportunity cannot be both won and lost") + +// OpportunityMarks are the user's own marks on an opportunity, kept apart +// from the fetched data so saving a newer release never changes them. The +// marks are independent except that Won and Lost exclude each other (see +// Validate). +type OpportunityMarks struct { + UUID string `db:"uuid" json:"uuid"` + Hidden bool `db:"hidden" json:"hidden"` + Skipped bool `db:"skipped" json:"skipped"` + Interested bool `db:"interested" json:"interested"` + BidSubmitted bool `db:"bid_submitted" json:"bid_submitted"` + Won bool `db:"won" json:"won"` + Lost bool `db:"lost" json:"lost"` +} + +// Validate reports whether the marks can be stored together: Won and Lost +// cannot both be set. +func (m OpportunityMarks) Validate() error { + if m.Won && m.Lost { + return ErrWonAndLost + } + return nil +}