Commit Graph
98 Commits
Author SHA1 Message Date
bwilliams b0230e6847 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.
v1.1.0
2026-10-22 13:10:25 +01:00
bwilliams 0a777792d8 A CPV code is now stored once, keeping its longest description 2026-10-22 09:56:01 +01:00
bwilliams b0f93fd437 config init now leaves out the test sources unless given --all
A section tagged init:"hidden" isn't written to the file config init
generates; fts_mock and sleeper are. config init --all writes them too.
They still load, and the config commands read and set them as before.
2026-10-21 16:22:45 +01:00
bwilliams f467c6c7da The release tarball's files are now owned by root, not the user who built it 2026-10-21 16:07:21 +01:00
bwilliams 71eca2f194 Terminal logging is now coloured only on an interactive terminal
The terminal logger always coloured its text, so output piped or
redirected to a file, and the systemd journal, got escape codes. It
now colours only an interactive terminal.

The service's logs can therefore go to the journal: logging.file is
now optional for it, and the unit's comments and the README say so.
v1.0.0
2026-10-21 15:39:36 +01:00
bwilliams 2aeb2993b0 Added a systemd unit and logrotate configuration, packaged in the release 2026-10-21 15:31:35 +01:00
bwilliams 1e3acaeff3 A .env file in the working directory is now optional 2026-10-21 15:28:52 +01:00
bwilliams 0b1952bd69 Halved the table cells' horizontal padding 2026-10-21 15:19:49 +01:00
bwilliams 7543d85de4 Shortened the table's "Values" with k, m and bn suffixes 2026-10-21 15:14:57 +01:00
bwilliams 3bb2711276 Added the closing date to the table, and narrowed its columns to fit
The backend's new closing_date, the bid deadline, is a Closing column
before the start and end dates, which are now the contract's: the
details dialog calls them Contract start and Contract end. Closing
sorts soonest first, with no date last, and shows the time it closes
in the cell's tooltip and in the dialog. The API types are regenerated
for it.

To make room, the SME suitable column is now an SME badge beside the
status, shown only when it's suitable, so the Status column sorts by
status alone and a stored SME sort goes back to the default. The Mark
column is narrower. An opportunity without a status is shown as
Unknown, in the colours of the statuses without their own, such as
Cancelled and Withdrawn, rather than as a blank badge or a dash.
2026-10-21 14:47:56 +01:00
bwilliams c678b02662 The fetcher's "saved opportunities" and "fetched opportunities" lines now name the source 2026-10-21 14:36:22 +01:00
bwilliams 50ee510520 Start and end dates are now the contract's, with a new closing date
Find a Tender never sends tender.tenderPeriod.startDate, so an
opportunity's start date was always empty, and its end date was the
bid deadline. Start and end are now the contract period: from the
contracts' periods, else the awards' contract periods, else the lots'
contract periods, each date on its own, taking the earliest start and
the latest end of several.

The bid deadline (tenderPeriod.endDate) moves to a new closing_date
column (migration 00009), in the API as closing_date. EU Supply's
pages give no contract period, so its start and end are left empty
and its response deadline is the closing date.
2026-10-21 14:31:12 +01:00
bwilliams 43f9ef3223 Labelled an opportunity's ID as an OCID only when it is one 2026-10-21 14:15:04 +01:00
bwilliams 64886a2f1e Added links to EU Supply tenders, as there are to Find a Tender notices 2026-10-21 14:14:47 +01:00
bwilliams 3a06132071 Added an EU Supply source, scraping the UK portal's public tenders
internal/eusupply replaces the old Node scraper (eu-supply.js) for
uk.eu-supply.com. The site has no API, so it posts the public search
form, which now needs the form's current defaults and reads the HTML
with golang.org/x/net/html. Each tender not stored yet has its own
page read too, for its description, contract type, CPV codes and
buyer address. Tenders are stored with the source "EU Supply - UK"
as eusupply-uk-<tender id>, with one release holding the tender as
read.

The site publishes no rate limit, so the client sets its own: one
request at a time, with a random wait between
sources.eu_supply.request_min_gap and request_max_gap (2s to 10s).
The source has the same 7-day backfill as Find a Tender's.

Most of its tenders are also on Find a Tender, so a new one is stored
Hidden and Skipped when another source's opportunity released within
30 days has a title at least 90% alike, and its buyer or description
matches too (80% of the shorter one's significant words).
2026-10-21 13:57:54 +01:00
bwilliams 5c62376b6e Made the table's columns sortable
Every column's header is a button that sorts the table by it, and
clicking it again sorts the other way. A column starts with the most
useful first: titles A to Z, the latest dates and highest values, Yes
before No, hidden first, and marks in the menu's order. Statuses sort
in the filters' order rather than alphabetically, and titles by their
words and numbers, ignoring case and punctuation. Rows with no value in
the column (no date, no value, no marks) go last either way, and rows
that tie are newest release first.

The sort is remembered with the other preferences, and is newest
release first until another is chosen, as before. Sorting has moved
out of filterOpportunities, which now keeps the order it's given.
2026-10-21 12:47:25 +01:00
bwilliams c09c30ae71 Added a backfill in 7-day windows, and fetches now keep their data on a failure
Backfilling a long window (an early --since, or a long time offline)
hit the API's rate limit, which could still be exceeded after waiting
once, and then everything fetched so far was lost.

Before each Find a Tender source's worker starts, a start point more
than 7 days ago is now backfilled in 7-day windows bounded on both
sides, oldest first, each saved before the next is fetched. A window
that fails is saved as far as it got and fetched again from its start
after the source's interval. The worker then carries on from the end
of the last window.

A 429 is now waited out up to sources.<source>.rate_limit_retries
times (default 5) rather than once; fts-once --ignore-rate-limit uses
0. FetchAll returns what it fetched before an error along with it, and
the workers save it, so a failed run no longer loses its data.
findatender's FetchAll and FetchOnce take RequestOptions, which gains
Until (updatedTo), and RateLimitRetries replaces AllowRateLimit.
2026-10-21 12:40:39 +01:00
bwilliams ff2aa316b5 Made the classification picker a tree, where ticking a code selects its whole branch
The classifications are grouped by kind (CPV, then Other) and, within
CPV, nested by the CPV hierarchy: each code sits under its nearest
ancestor in the list, so one whose parent isn't in use isn't lost. The
other codes have no hierarchy, so they're listed flat. Branches start
closed; a search shows the matching codes with their ancestors, opened
when no more than 150 match.

Ticking a code selects it and every code beneath it, and ticking a
whole selected branch clears it. The checkboxes are tri-state, showing
a dash when only part of a branch is selected, and a closed branch that
is partly selected says how many codes beneath it are. The selection
above the tree shows a whole selected branch as one chip, with the
number of codes beneath it, and removing that chip clears the branch.

The filter is unchanged: a set of classification ids, so a branch is
the codes in the picker when it's ticked.
2026-10-21 12:39:22 +01:00
bwilliams 4db268550b Fixed releases with 1e9999 ("no limit") being skipped as unreadable 2026-10-21 09:34:44 +01:00
bwilliams 79efcb5e91 Avoid printing the whole JSON response if it is over 1024 characters in length (print the length instead). This was resulting in huge log files. In addition, log any failing unmarshalls of releases (at debug level) as well as the error (at warn level) 2026-10-21 09:34:01 +01:00
bwilliams 0543762330 Added skip rules, which mark new opportunities Skipped when they're fetched
sources.skip_rules_file names a JSON file of filters, each with a
comment, an active flag, a logic mode ("or" by default, or "and") and
rules by buyer, title words or CPV codes. The fetcher applies them
before saving, so only new opportunities are marked; the marks of
stored ones are never touched. CPV rules match when the opportunity's
codes are all in the list, or any of them with exclusive false, and
by prefix with exact false.

The file is read again when its time or size changes: an invalid edit
is logged and the last good rules kept, and a deleted file means no
rules until it's back. fetcher service and serve refuse to start if it
is define but missing or invalid.

job-search skip-rules check validates a rules file, and skip-rules
apply marks the stored opportunities that match Skipped, or only lists
them with --dry-run.
2026-10-20 08:55:19 +01:00
bwilliams 2e6c21f9dd Swapped the order of the details and "Find a Tender" link buttons 2026-10-19 10:40:27 +01:00
bwilliams f8648d765a Added a dialog with each opportunity's details and releases
Every row has a details icon under the Find a Tender link. It opens a
dialog with the opportunity's details from the list, and fetches the
opportunity with its releases, shown as tabs, newest first. Each tab
summarises the release's tender, awards and contracts, links to its
Find a Tender notice where it is one, and has the release's JSON,
pretty-printed and highlighted, in a section laid out only when it's
opened.

The JSON is highlighted by splitting it into tokens shown as text, not
HTML, as releases come from outside sources. The README's saved
preferences now include the filters.
2026-10-19 10:37:27 +01:00
bwilliams 6fbf7e56b2 The classification filter now offers only the loaded opportunities' classifications
It lists the classifications the opportunities in the days window have,
not every one stored, plus any already chosen, so a selection stays
visible and can be removed when nothing in the window has it. The other
filters don't narrow it, so choosing a status doesn't make
classifications vanish.
2026-10-19 10:19:34 +01:00
bwilliams 0819fb442b The filters are now remembered in localStorage
Every filter, the days and title included, is stored under
job_search.filters when it changes and read back when the page loads.
A stored field that isn't valid (missing, from an older version or
edited by hand) falls back to its default, keeping the rest. Clear
filters still goes back to the defaults.
2026-10-19 10:11:19 +01:00
bwilliams 746fa0a9c1 Added a "Published in the last X days" filter, applied by the backend
GET /api/opportunities takes released_from, an RFC 3339 time, and lists
only the opportunities whose latest release is at or after it. It works
with paging and with changed_after, which keeps the same batch either
way; an invalid value is 400.

The frontend's filter defaults to 7 days, from midnight UK time, and
sends it with every load and catch-up, so a year of opportunities isn't
downloaded to show a week's. Changing the days loads the list again;
Clear filters goes back to 7.
2026-10-19 10:05:45 +01:00
bwilliams b8244da2e5 Added a "Hide all visible" button
It hides the opportunities the filters show that aren't hidden already,
after asking for confirmation with their count. Those the filters leave
out keep their marks, so clearing the filters shows them as before.

The backend has no bulk route, so each opportunity is hidden with its
own request. The rows go at once; every mark change is now saved at
most six at a time (lib/concurrency.ts), so hundreds of requests aren't
sent together. Any that fail are shown again, with one toast for them
all.
2026-10-19 09:47:49 +01:00
bwilliams b1cc9745ba Code Conventions change: A new package now goes in the same change as the code that needs it 2026-10-19 09:47:21 +01:00
bwilliams e98f18b790 Added a link from each opportunity to its latest Find a Tender notice
Opportunities from Find a Tender have a link icon before their title.
Clicking it fetches the opportunity with its releases (the list leaves
them out) and opens its newest release's notice,
www.find-tender.service.gov.uk/notice/<release_id>, in a new tab. The
tab is opened on the click and pointed at the notice when the request
returns, so it isn't blocked as a popup; if the request fails, or there
are no releases, it's closed and a toast says why. Opportunities from
other sources have no link for now, but keep its space so the titles
line up.

The icon is lucide-solid's, added for it at 1.52.0.
2026-10-19 09:38:14 +01:00
bwilliams a6630f112f Added tests for format and storage, run in UK time 2026-10-16 16:46:08 +01:00
bwilliams 13232bb15c Added tests for the opportunities' catch-up and the toasts 2026-10-16 16:41:10 +01:00
bwilliams f9064511b0 Added tests for web/src/api 2026-10-16 16:37:58 +01:00
bwilliams 60643546f6 Added tests for web/src/lib 2026-10-16 16:29:08 +01:00
bwilliams 67e2c96ca7 Added an SME suitable filter 2026-10-16 16:26:32 +01:00
bwilliams 49dd11be56 Errors now show as toasts in the bottom right corner 2026-10-16 16:22:19 +01:00
bwilliams 115ee29a85 Added Vitest to web/, ready for tests 2026-10-16 16:16:27 +01:00
bwilliams 65e5bc6b26 Added database.change_batch_retention, deleting old change batches
Each save that records a batch also deletes, in the same
transaction, the batches older than database.change_batch_retention
(30 days by default; 0 or less keeps them for ever), with their
records of opportunities. A client still holding a deleted batch
gets 410 Gone from ?changed_after= and reloads the whole list, as
for any unknown batch.
2026-10-16 16:10:32 +01:00
bwilliams 1988d07a29 Ran the installed ocdsgen, and added a script that installs the dev tools
go generate ran ocdsgen with go run, from go.mod, but go-ocds had been
dropped from go.mod (nothing imports it, so go mod tidy removes it), which
broke go generate and so scripts/build.sh. It now runs the installed
ocdsgen from PATH, as it does swag.

scripts/install-dev-tools.sh installs the Go tools (ocdsgen v1.0.0 and swag
v2.0.0-rc6 pinned, as other versions could generate different code;
golangci-lint, goose and cobra-cli at their latest), checks for Node.js 24
or later and runs npm ci in web/. The README's dev tools section now points
to it and lists what each tool is for.
2026-10-16 16:03:48 +01:00
bwilliams 5e6c011ecb Embedded the frontend in the binary, and built it in the build script
`npm run sync` in web/ copies the build output (web/dist, from npm run build)
to internal/frontend/dist, which the binary embeds. Go can't embed from
outside the package's directory (web/ is also outside the Go module), hence
the copy. Only dist/.gitkeep is committed, so a fresh clone still compiles,
and a binary built without syncing first serves no frontend.

job-search serve serves server.frontend.dir if it is set, or else the
embedded build, or warns that there is no frontend. scripts/build.sh builds
and syncs the frontend before building, running npm ci first for a release
or when web/node_modules is missing.
2026-10-16 16:03:15 +01:00
bwilliams 1832ea7437 Added the frontend in web/, following its code conventions, with linting and generated backend types
web/CODE_CONVENTIONS.md covers the Vite, SolidJS, Tailwind and TypeScript
frontend: tooling, files, imports, exports, naming, TypeScript, formatting,
Solid's reactivity, styling, the backend's types, API calls, errors,
security, formatting values, comments, accessibility, storage,
dependencies and tests. npm run check (strict tsc -b, ESLint and Prettier)
passes, and npm run build builds it.

ESLint (typescript-eslint with type information, eslint-plugin-solid,
simple-import-sort, eslint-comments, and rules for the naming, type import,
export type and promise conventions) and Prettier (with the Tailwind class
plugin) check what they can. Tool versions are pinned exactly. Imports
between folders use @/, which works in Vite as well as TypeScript.

The backend's types are generated from its OpenAPI description into
src/types/api.ts by npm run generate:api, which turns swag's x-nullable
into null types first; src/types/Opportunities.ts names them for the app.

The app works against the backend's API: it fetches every page of
/api/opportunities with each opportunity's marks, the classifications to
filter by from /api/classifications, sets and clears marks (hidden among
them) with the CSRF token, and shows the OCDS tender statuses. Its lists'
requests are aborted when reloaded or no longer needed. The dev server
proxies /api and /ws to job-search serve, by default at localhost:9393.
web/go.mod keeps web/ out of the Go module, as an npm package in
node_modules ships Go code.

The page stays up to date over the backend's WebSocket (src/api/events.ts,
with hand-written event types in src/types/Events.ts), reconnecting with a
growing delay. ClassificationsUpdated changes the filter's classifications
in place and OpportunityMarksUpdated sets an opportunity's marks (unless
this page is still saving its own change). OpportunitiesUpdated catches the
list up: it asks for the opportunities changed after the last change batch
it has seen (changed_after) and merges them in, reloading the whole list
only when the backend doesn't know the batch. A reconnect catches up the
same way and reloads the classifications.
2026-10-16 16:01:38 +01:00
bwilliams 9905ffb4cb Served the frontend's build output from server.frontend.dir
internal/frontend serves a Vite build from an fs.FS: hashed files under
/assets/ are cached for a year, HTML and other files get no-cache, ETags are
computed (and cached for a build that can't change), precompressed variants
are served when the client accepts them, and a path without an extension
falls back to index.html for client-side routes. Only GET and HEAD are
allowed.

server.WithFrontend serves it for every path outside /api and /ws, behind
compression and the frontend group's hooks. job-search serve passes the
directory in server.frontend.dir, checked for an index.html at start; empty
means no frontend. Its option building moved into serverOptions, with the
access log.
2026-10-16 16:00:34 +01:00
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
bwilliams 51d6470649 Added a ClassificationsUpdated event
SaveOpportunities now reports the classification codes a save brings into
use (AddedClassifications, in full) and takes out of use
(RemovedClassificationIDs), by comparing the codes in use before and after
the save inside its transaction. The fetcher publishes them as a
ClassificationsUpdated event, {"added":[codes],"removed":[ids]}, never null,
before the save's OpportunitiesUpdated, so clients can keep their list of
classifications without asking the API again.
2026-10-16 15:24:37 +01:00
bwilliams aa06b94870 Removed the message size limit from ws-test dial 2026-10-16 15:23:49 +01:00
bwilliams 0e27d2b2f3 Added api/classifications endpoint, requiring a new query 2026-10-16 14:44:12 +01:00
bwilliams 378ac59f41 Fixed some lint and code convention violations 2026-10-16 14:43:37 +01:00
bwilliams af7880ceac Backend API updated to indicate nullable fields 2026-10-16 14:38:48 +01:00
bwilliams e32e270db0 Added a convention that sentinel errors go near the top of the file, and applied it 2026-10-16 13:23:47 +01:00
bwilliams 848edc73e2 Published an OpportunityMarksUpdated event when an opportunity's marks change over the API 2026-10-16 13:19:58 +01:00
bwilliams 70d62f280e Added the opportunities REST API, read-only apart from their marks, described in OpenAPI 3.1 with swag
GET /api/opportunities lists the stored opportunities, newest release first,
a page at a time (offset, and limit up to 100, default 50); has_more says
whether another page follows. GET /api/opportunities/{uuid} returns one with
its releases. PUT and DELETE /api/opportunities/{uuid}/marks/{mark} set and
clear one mark (hidden, skipped, interested, bid_submitted, won, lost) and
return them all; won and lost exclude each other (409). The API uses the
types in internal/types, and changing a mark is made atomic, as the store
replaces all of an opportunity's marks at once. The marks are found by
reflection: every bool field of types.OpportunityMarks, by its JSON name, so
a new mark needs no API change.

The API has one entry point, api.New, with its dependencies as options
(api.WithStore), and the server only mounts it under /api, keeping the
middleware. New resources are added inside internal/api. job-search serve
passes server.WithStore when database.file is set, opening the store before
the server; without one there are no opportunity routes.

The API is described by swag v2 annotations on the handlers. go generate
writes the OpenAPI 3.1 description (git-ignored, like the OCDS types), which
the server embeds and serves at /api/openapi.json, with Swagger UI at
/api/docs. A test checks the description and the routes match. swag is in
the README's dev tools, pinned to v2.0.0-rc6, and the build now needs it.
2026-10-16 13:13:44 +01:00