Files
job-search/internal/api/opportunities.go
T
bwilliams 155b84caea Added change batches, so clients can catch up on changed opportunities
Every save that stores new opportunities or new releases records them in a
change batch (migration 00008: opportunity_change_batches and
opportunity_change_batch_opportunities; the opportunities table is
unchanged), as does every change of marks. A batch is named by a random
SHA-256 hash, so it can't be guessed, and ordered by its created_at, which
is kept strictly increasing even if the clock goes back.

GET /api/opportunities pages now carry the newest batch, read before the
page, and GET /api/opportunities?changed_after=<batch> returns every
opportunity changed since that batch, unpaged, with the newest batch it
includes. Offset and limit can't be combined with it, and an unknown batch
is 410 Gone, telling the client to reload the whole list.

OpportunitiesUpdated now carries its save's batch, and a "large" flag: over
50 opportunities, new and updated are left empty and only the summary
counts them, so a big catch-up no longer sends tens of thousands of UUIDs.
2026-10-16 15:25:48 +01:00

362 lines
12 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"sync"
"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/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"`
}
// 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/{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. 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.
// @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"
// @Success 200 {object} OpportunityPage
// @Failure 400 {object} httpx.Problem "Invalid offset or limit, or either 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()
if query.Has("changed_after") {
h.listChanged(w, r)
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,
},
)
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. 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,
) {
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"),
)
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,
})
}
// 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, "")
}
}