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.
449 lines
15 KiB
Go
449 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"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"
|
|
)
|
|
|
|
// markIndex maps the name of each mark that can be set and cleared, as named
|
|
// in URLs, to the index of its field in types.OpportunityMarks. markNames
|
|
// lists the names in field order. Both are read from the struct (see
|
|
// findMarks), so a mark added to it can be changed over the API.
|
|
var markIndex, markNames = findMarks()
|
|
|
|
// OpportunityPage is a page of opportunities, as GET /opportunities returns
|
|
// it. HasMore reports that another page follows, at Offset+Limit.
|
|
//
|
|
// Batch is the hash of the newest change batch when the page was read, or
|
|
// "" if none has been recorded: given as changed_after, it gets the
|
|
// opportunities changed since. A page of changed opportunities is all of
|
|
// them, so Limit is their number and HasMore is false.
|
|
type OpportunityPage struct {
|
|
Opportunities []types.Opportunity `json:"opportunities"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
HasMore bool `json:"has_more"`
|
|
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 {
|
|
store *store.Store
|
|
events *events.Bus // nil: nothing is published
|
|
|
|
// marksMu makes changing one mark (read the marks, change one, write
|
|
// them all back) atomic, so concurrent changes to different marks of an
|
|
// opportunity don't undo each other.
|
|
marksMu sync.Mutex
|
|
}
|
|
|
|
// newOpportunityRoutes returns the opportunity handlers for
|
|
// opportunityStore, publishing mark changes to bus if it isn't nil.
|
|
func newOpportunityRoutes(
|
|
opportunityStore *store.Store, bus *events.Bus,
|
|
) *opportunityRoutes {
|
|
return &opportunityRoutes{
|
|
store: opportunityStore,
|
|
events: bus,
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// list serves GET /opportunities: a page of opportunities, newest release
|
|
// first, with their classifications and marks but not their releases. The
|
|
// page is set by the offset and limit (default 50, at most 100) query
|
|
// parameters, and released_from leaves out opportunities whose latest release
|
|
// is before it. With changed_after, it serves the opportunities changed since
|
|
// that batch instead (see listChanged).
|
|
//
|
|
// @Summary List opportunities
|
|
// @Description A page of opportunities, newest release first, with their
|
|
// @Description classifications and marks but not their releases. has_more
|
|
// @Description says whether another page follows, at offset+limit. batch
|
|
// @Description is the newest change batch when the page was read.
|
|
// @Description With changed_after (a batch from a page or an
|
|
// @Description OpportunitiesUpdated event), the page is instead every
|
|
// @Description opportunity changed since that batch (new, given new
|
|
// @Description releases or with changed marks), and batch is the newest
|
|
// @Description batch they include. offset and limit can't be given with
|
|
// @Description changed_after. An unknown batch is 410 Gone: reload the
|
|
// @Description whole list. released_from (RFC 3339, inclusive) leaves out
|
|
// @Description opportunities whose latest release is before it, in a page
|
|
// @Description or with changed_after.
|
|
// @Tags opportunities
|
|
// @Produce json
|
|
// @Param offset query int false "Opportunities to skip" minimum(0) default(0)
|
|
// @Param limit query int false "Page size" minimum(1) maximum(100) default(50)
|
|
// @Param changed_after query string false "List the opportunities changed after this batch instead"
|
|
// @Param released_from query string false "Only opportunities whose latest release is at or after this time" format(date-time)
|
|
// @Success 200 {object} OpportunityPage
|
|
// @Failure 400 {object} httpx.Problem "Invalid offset, limit or released_from, or offset or limit with changed_after"
|
|
// @Failure 410 {object} httpx.Problem "Unknown batch"
|
|
// @Failure 500 {object} httpx.Problem
|
|
// @Router /opportunities [get]
|
|
func (h *opportunityRoutes) list(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
|
|
}
|
|
|
|
if query.Has("changed_after") {
|
|
h.listChanged(w, r, releasedFrom)
|
|
return
|
|
}
|
|
|
|
offset, offsetErr := intParam(query.Get("offset"), 0, 0, 1<<30)
|
|
limit, limitErr := intParam(query.Get("limit"), 50, 1, 100)
|
|
err = errors.Join(offsetErr, limitErr)
|
|
if err != nil {
|
|
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Read first, so nothing changed while the page is read is missed by
|
|
// asking for the changes after it.
|
|
batch, err := h.store.LatestChangeBatch(r.Context())
|
|
if err != nil {
|
|
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
|
|
return
|
|
}
|
|
|
|
// One more than the page, to tell whether another page follows.
|
|
opportunities, err := h.store.ListOpportunities(
|
|
r.Context(),
|
|
store.OpportunityFilter{
|
|
Limit: limit + 1,
|
|
Offset: offset,
|
|
ReleasedFrom: releasedFrom,
|
|
},
|
|
)
|
|
if err != nil {
|
|
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
|
|
return
|
|
}
|
|
|
|
page := OpportunityPage{
|
|
Opportunities: opportunities,
|
|
Offset: offset,
|
|
Limit: limit,
|
|
HasMore: len(opportunities) > limit,
|
|
Batch: batch,
|
|
}
|
|
if page.HasMore {
|
|
page.Opportunities = opportunities[:limit]
|
|
}
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, page)
|
|
}
|
|
|
|
// listChanged serves GET /opportunities?changed_after=batch: every
|
|
// opportunity changed after the change batch, as one page, with the newest
|
|
// batch it includes, leaving out those whose latest release is before
|
|
// releasedFrom unless it is zero. An unknown batch, such as one from another
|
|
// database, is 410 Gone, as the client can't catch up from it.
|
|
func (h *opportunityRoutes) listChanged(
|
|
w http.ResponseWriter, r *http.Request, releasedFrom time.Time,
|
|
) {
|
|
query := r.URL.Query()
|
|
if query.Has("offset") || query.Has("limit") {
|
|
httpx.WriteProblem(
|
|
w,
|
|
r,
|
|
http.StatusBadRequest,
|
|
"offset and limit can't be used with changed_after",
|
|
)
|
|
return
|
|
}
|
|
|
|
opportunities, batch, err := h.store.ListChangedOpportunities(
|
|
r.Context(), query.Get("changed_after"), releasedFrom,
|
|
)
|
|
if errors.Is(err, store.ErrUnknownBatch) {
|
|
httpx.WriteProblem(
|
|
w,
|
|
r,
|
|
http.StatusGone,
|
|
"unknown batch: reload the whole list",
|
|
)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
|
|
return
|
|
}
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, OpportunityPage{
|
|
Opportunities: opportunities,
|
|
Limit: len(opportunities),
|
|
Batch: batch,
|
|
})
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// @Summary Get an opportunity
|
|
// @Description One opportunity with its classifications, marks and all of its
|
|
// @Description releases.
|
|
// @Tags opportunities
|
|
// @Produce json
|
|
// @Param uuid path string true "The opportunity's UUID (its OCID)"
|
|
// @Success 200 {object} types.Opportunity
|
|
// @Failure 404 {object} httpx.Problem "No such opportunity"
|
|
// @Failure 500 {object} httpx.Problem
|
|
// @Router /opportunities/{uuid} [get]
|
|
func (h *opportunityRoutes) get(w http.ResponseWriter, r *http.Request) {
|
|
opportunity, err := h.store.GetOpportunity(
|
|
r.Context(), chi.URLParam(r, "uuid"),
|
|
)
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, opportunity)
|
|
}
|
|
|
|
// setMark serves PUT /opportunities/{uuid}/marks/{mark}, setting the mark.
|
|
//
|
|
// @Summary Set a mark
|
|
// @Description Sets one of the opportunity's marks, keeping the others, and
|
|
// @Description returns all of them. Setting a mark that is already set
|
|
// @Description changes nothing. won and lost exclude each other.
|
|
// @Tags marks
|
|
// @Produce json
|
|
// @Security CSRF
|
|
// @Param uuid path string true "The opportunity's UUID (its OCID)"
|
|
// @Param mark path string true "The mark" Enums(hidden, skipped, interested, bid_submitted, won, lost)
|
|
// @Success 200 {object} types.OpportunityMarks
|
|
// @Failure 403 {object} httpx.Problem "No valid CSRF token"
|
|
// @Failure 404 {object} httpx.Problem "No such opportunity or mark"
|
|
// @Failure 409 {object} httpx.Problem "won and lost together"
|
|
// @Failure 500 {object} httpx.Problem
|
|
// @Router /opportunities/{uuid}/marks/{mark} [put]
|
|
func (h *opportunityRoutes) setMark(w http.ResponseWriter, r *http.Request) {
|
|
h.changeMark(w, r, true)
|
|
}
|
|
|
|
// clearMark serves DELETE /opportunities/{uuid}/marks/{mark}, clearing the
|
|
// mark.
|
|
//
|
|
// @Summary Clear a mark
|
|
// @Description Clears one of the opportunity's marks, keeping the others, and
|
|
// @Description returns all of them. Clearing a mark that isn't set changes
|
|
// @Description nothing.
|
|
// @Tags marks
|
|
// @Produce json
|
|
// @Security CSRF
|
|
// @Param uuid path string true "The opportunity's UUID (its OCID)"
|
|
// @Param mark path string true "The mark" Enums(hidden, skipped, interested, bid_submitted, won, lost)
|
|
// @Success 200 {object} types.OpportunityMarks
|
|
// @Failure 403 {object} httpx.Problem "No valid CSRF token"
|
|
// @Failure 404 {object} httpx.Problem "No such opportunity or mark"
|
|
// @Failure 500 {object} httpx.Problem
|
|
// @Router /opportunities/{uuid}/marks/{mark} [delete]
|
|
func (h *opportunityRoutes) clearMark(w http.ResponseWriter, r *http.Request) {
|
|
h.changeMark(w, r, false)
|
|
}
|
|
|
|
// changeMark sets the mark named in the URL to value, keeping the
|
|
// opportunity's other marks, and responds with all of its marks. Setting won
|
|
// while lost is set, or lost while won is, is refused with 409 Conflict. A
|
|
// change is published as an events.OpportunityMarksUpdated event; one that
|
|
// changes nothing is neither stored nor published.
|
|
func (h *opportunityRoutes) changeMark(
|
|
w http.ResponseWriter, r *http.Request, value bool,
|
|
) {
|
|
uuid, name := chi.URLParam(r, "uuid"), chi.URLParam(r, "mark")
|
|
|
|
h.marksMu.Lock()
|
|
defer h.marksMu.Unlock()
|
|
|
|
opportunity, err := h.store.GetOpportunity(r.Context(), uuid)
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
|
|
marks := opportunity.Marks
|
|
marks.UUID = uuid
|
|
before := marks
|
|
if !setMark(&marks, name, value) {
|
|
httpx.WriteProblem(
|
|
w,
|
|
r,
|
|
http.StatusNotFound,
|
|
fmt.Sprintf("no mark %q: marks are %v", name, markNames),
|
|
)
|
|
return
|
|
}
|
|
if marks == before {
|
|
httpx.WriteJSON(w, http.StatusOK, marks)
|
|
return
|
|
}
|
|
|
|
err = h.store.SetMarks(r.Context(), marks)
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, marks)
|
|
|
|
// Still under marksMu, so events are published in the order the
|
|
// changes were made.
|
|
if h.events != nil {
|
|
h.events.Publish(events.Event{
|
|
Type: events.OpportunityMarksUpdated,
|
|
Payload: marks,
|
|
})
|
|
}
|
|
}
|
|
|
|
// findMarks returns the marks of types.OpportunityMarks: every bool field,
|
|
// named by its JSON tag, mapped to the field's index, and the names in field
|
|
// order.
|
|
func findMarks() (map[string]int, []string) {
|
|
index := map[string]int{}
|
|
var names []string
|
|
marksType := reflect.TypeFor[types.OpportunityMarks]()
|
|
for i := range marksType.NumField() {
|
|
field := marksType.Field(i)
|
|
if field.Type.Kind() != reflect.Bool {
|
|
continue
|
|
}
|
|
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
|
index[name] = i
|
|
names = append(names, name)
|
|
}
|
|
|
|
return index, names
|
|
}
|
|
|
|
// setMark sets the mark of marks named name (see markIndex) to value, and
|
|
// reports whether name is a mark.
|
|
func setMark(marks *types.OpportunityMarks, name string, value bool) bool {
|
|
i, ok := markIndex[name]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
reflect.ValueOf(marks).Elem().Field(i).SetBool(value)
|
|
return true
|
|
}
|
|
|
|
// fail writes the problem response for a store error.
|
|
func (h *opportunityRoutes) fail(
|
|
w http.ResponseWriter, r *http.Request, err error,
|
|
) {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
httpx.WriteProblem(w, r, http.StatusNotFound, "opportunity not found")
|
|
case errors.Is(err, types.ErrWonAndLost):
|
|
httpx.WriteProblem(
|
|
w,
|
|
r,
|
|
http.StatusConflict,
|
|
"an opportunity cannot be both won and lost",
|
|
)
|
|
default:
|
|
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
|
|
}
|
|
}
|