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

+48 -1
View File
@@ -107,7 +107,8 @@ LIMIT ? OFFSET ?`
)`
// filterOpportunityTitleMatch matches a full-text search of the title. The
// argument must come from ftsQuery, never raw user input.
// argument must be ftsQuery's, limited to the title column (see
// listOpportunitiesQuery), never raw user input.
filterOpportunityTitleMatch = `t.id IN (
SELECT rowid FROM opportunities_fts WHERE opportunities_fts MATCH ?
)`
@@ -150,6 +151,52 @@ LIMIT ? OFFSET ?`
)`
)
// SearchOpportunities is assembled by searchOpportunitiesQuery as:
//
// searchOpportunitiesSelect
// [WHERE clause AND clause ...]
//
// Each clause is a searchOpportunity* fragment, perhaps with NOT in front,
// or filterOpportunityReleasedFrom. searchOpportunityCPV's %s is filled
// with searchCPV* fragments joined by OR, AND and NOT. A full-text argument
// must be built from search.Term.Words, never raw user input.
const (
// searchOpportunitiesSelect selects every opportunity's UUID, as t.
searchOpportunitiesSelect = `
SELECT t.uuid FROM opportunities t`
// searchOpportunityText matches a full-text search of the title,
// description and buyer_name columns.
searchOpportunityText = `t.id IN (
SELECT rowid FROM opportunities_fts WHERE opportunities_fts MATCH ?
)`
// searchOpportunityBuyerID matches any of several buyer IDs, ignoring
// case. "IN (?)" is expanded by sqlx.In, which must be followed by
// Rebind.
searchOpportunityBuyerID = `t.buyer_id COLLATE NOCASE IN (?)`
// searchOpportunityCPV matches an opportunity with a CPV code meeting
// the condition put in place of %s, which reads the code as c.
searchOpportunityCPV = `EXISTS (
SELECT 1
FROM opportunity_classifications tc
JOIN classification_codes c ON c.id = tc.classification_id
WHERE tc.opportunity_uuid = t.uuid AND c.is_cpv AND %s
)`
// searchCPVCodePrefix matches a code by a LIKE pattern: its first
// digits followed by %.
searchCPVCodePrefix = `c.code LIKE ?`
// searchCPVDescription matches a full-text search of the code's
// description.
searchCPVDescription = `c.id IN (
SELECT rowid FROM classification_codes_fts
WHERE classification_codes_fts MATCH ?
)`
)
// selectOtherSourcesTitles lists the uuid, title and latest_release_date of
// the opportunities not from one source whose latest_release_date is in
// [from, before); it walks the latest_release_date index.