Replaced the Title filter with a general search, run by the backend

GET /api/opportunities/search?q= returns the UUIDs of the matching
opportunities, and the frontend filters its list by them. Words without
a prompt search the title and description; title:, buyer:, buyer_id:
and cpv: search only their own field, and a leading ! leaves out what a
term matches. Repeats of one prompt are OR, everything else AND. CPV
terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72
that doesn't start 7221.

Unknown prompts are reported, and every response carries the help that
the search box's "?" panel shows. Migration 00011 adds the description
and buyer name to the full-text index, and indexes CPV descriptions.
This commit is contained in:
bwilliams committed 2026-10-22 13:10:25 +01:00
1 parent 0a777792d8
commit b0230e6847
29 files changed
+1913 -43

No files matched your search

+72
View File
@@ -13,6 +13,7 @@ import (
"git.avsdev.uk/Go/job-search/internal/events"
"git.avsdev.uk/Go/job-search/internal/httpx"
"git.avsdev.uk/Go/job-search/internal/search"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
)
@@ -38,6 +39,21 @@ type OpportunityPage struct {
Batch string `json:"batch"`
}
// OpportunitySearch is the result of a general search, as GET
// /opportunities/search returns it: the UUIDs of the opportunities that
// match, in no particular order. MatchesAll reports a search with nothing to
// search for (blank, or only unknown prompts), which matches every
// opportunity; UUIDs is then empty. UnknownPrompts are the prompts in the
// search that aren't known, as typed, without their colon. Prompts and
// Syntax are the help for the search box.
type OpportunitySearch struct {
UUIDs []string `json:"uuids"`
MatchesAll bool `json:"matches_all"`
UnknownPrompts []string `json:"unknown_prompts"`
Prompts []search.Prompt `json:"prompts"`
Syntax []search.Example `json:"syntax"`
}
// opportunityRoutes wires HTTP handlers to the opportunity store.
// Opportunities are read-only; only their marks can be changed.
type opportunityRoutes struct {
@@ -64,6 +80,7 @@ func newOpportunityRoutes(
// Routes registers the opportunity routes on router (mounted under /api).
func (h *opportunityRoutes) Routes(router chi.Router) {
router.Get("/opportunities", h.list)
router.Get("/opportunities/search", h.search)
router.Get("/opportunities/{uuid}", h.get)
router.Put("/opportunities/{uuid}/marks/{mark}", h.setMark)
router.Delete("/opportunities/{uuid}/marks/{mark}", h.clearMark)
@@ -203,6 +220,61 @@ func (h *opportunityRoutes) listChanged(
})
}
// search serves GET /opportunities/search: the UUIDs of the opportunities
// matching the general search in the q query parameter (see package
// search), with the help for the search box. released_from bounds it as it
// bounds list.
//
// @Summary Search opportunities
// @Description The UUIDs of the opportunities matching a general search,
// @Description in no particular order. q is words and "quoted phrases",
// @Description each optionally with a prompt (title:, buyer:, buyer_id:,
// @Description cpv:) and a leading ! to leave out what it matches; prompts
// @Description and syntax explain it. A search with nothing to search for
// @Description (blank, or only unknown prompts) has matches_all set and no
// @Description uuids. unknown_prompts lists the prompts that aren't known,
// @Description as typed; their terms are ignored. released_from (RFC 3339,
// @Description inclusive) leaves out opportunities whose latest release is
// @Description before it.
// @Tags opportunities
// @Produce json
// @Param q query string false "The search"
// @Param released_from query string false "Only opportunities whose latest release is at or after this time" format(date-time)
// @Success 200 {object} OpportunitySearch
// @Failure 400 {object} httpx.Problem "Invalid released_from"
// @Failure 500 {object} httpx.Problem
// @Router /opportunities/search [get]
func (h *opportunityRoutes) search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
releasedFrom, err := timeParam(
"released_from", query.Get("released_from"),
)
if err != nil {
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
return
}
parsed := search.Parse(query.Get("q"))
result := OpportunitySearch{
UUIDs: []string{},
MatchesAll: parsed.MatchesAll(),
UnknownPrompts: parsed.UnknownPrompts,
Prompts: search.Prompts,
Syntax: search.Syntax,
}
if !result.MatchesAll {
result.UUIDs, err = h.store.SearchOpportunities(
r.Context(), parsed, releasedFrom,
)
if err != nil {
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
return
}
}
httpx.WriteJSON(w, http.StatusOK, result)
}
// get serves GET /opportunities/{uuid}: one opportunity with its
// classifications, marks and releases.
//
+52
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
@@ -483,3 +484,54 @@ func TestChangeMarkPublishes(t *testing.T) {
}
}
}
// GET /opportunities/search returns the matching UUIDs, bounded by
// released_from, with the help; a search with nothing to search for matches
// all, and unknown prompts are listed.
func TestSearchOpportunities(t *testing.T) {
url := opportunitiesServer(t, 5) + "/opportunities/search"
from3 := "&released_from=2026-10-03T00:00:00Z"
cases := []struct {
query string
uuids []string
matchesAll bool
unknown []string
}{
{"", []string{}, true, []string{}},
{"?q=", []string{}, true, []string{}},
{"?q=cpv:72", []string{"ocds-1", "ocds-3", "ocds-5"}, false, []string{}},
{"?q=cpv:72" + from3, []string{"ocds-3", "ocds-5"}, false, []string{}},
{"?q=opportunity%20!cpv:72", []string{"ocds-2", "ocds-4"}, false, []string{}},
{"?q=nothing", []string{}, false, []string{}},
{"?q=foo:x%20Bar:y", []string{}, true, []string{"foo", "Bar"}},
{"?q=foo:x%20title:3", []string{"ocds-3"}, false, []string{"foo"}},
}
for _, tc := range cases {
var got OpportunitySearch
status := request(t, http.MethodGet, url+tc.query, &got)
slices.Sort(got.UUIDs)
if status != http.StatusOK || !slices.Equal(got.UUIDs, tc.uuids) ||
got.MatchesAll != tc.matchesAll ||
!slices.Equal(got.UnknownPrompts, tc.unknown) {
t.Errorf("%s: status %d, %+v; want %v, matches_all %v, unknown %v",
tc.query, status, got, tc.uuids, tc.matchesAll, tc.unknown)
}
if len(got.Prompts) == 0 || len(got.Syntax) == 0 {
t.Errorf("%s: no help: %+v", tc.query, got)
}
}
// Empty lists are [], never null.
var raw map[string]json.RawMessage
request(t, http.MethodGet, url+"?q=nothing", &raw)
for _, field := range []string{"uuids", "unknown_prompts"} {
if string(raw[field]) != "[]" {
t.Errorf("%s = %s; want []", field, raw[field])
}
}
status := request(t, http.MethodGet, url+"?q=x&released_from=x", nil)
if status != http.StatusBadRequest {
t.Errorf("bad released_from: status %d; want 400", status)
}
}