ADR 0030: Saved search filters are per-user server state, and the compensation filter leaves the UI¶
- Status: Accepted
- Date: 2026-08-07
- Deciders: jobhub-architect (story #523, ticket #525)
- Affects: JobHub-ui (
src/screens/JobSearch.jsx,src/api/mappers.js,src/components/FilterComponents.jsx). Read-only for api-contracts and job-service. - Schema impact: none. No
db/initmigration, no migration range assigned.
Context¶
Story #523 reports two defects on the job-search screen.
- Saved filter presets are shared across users.
JobSearchScreenpersists presets in a single globallocalStoragekey,jobhub_saved_filters(JobHub-ui/src/screens/JobSearch.jsx, currently line 394). The key is browser-scoped, not user-scoped, so every account that logs in on the same browser sees, applies and deletes the same five presets. The reporter's words: "the filters seems to be shared for everyone? NOOOO, it should be per user". - The compensation range filter is not wanted in the UI. Crawled postings very often carry no salary at all, so a range slider that silently drops unpriced postings is a bad default control. The reporter asked for the control to go, and stated explicitly that the backend parameters should stay: "as it's flexible it's ok to maintain for the future".
The relevant constraint is that this is not a backend gap. job-service already ships the correct, user-scoped surface:
GET/POST /jobs/filters/savedandPATCH/DELETE /jobs/filters/saved/{id}inapi-contracts/src/main/resources/openapi/job-service.yaml(schemasSavedFilterRequest,SavedFilterPatchRequest,SavedFilterResponse,FilterValues).JobResourceguards all four operations with@RolesAllowed("user")and derives the owner from the Bearer token viauserId(), never from a request field.SavedFilterServiceenforces the 5-preset ceiling per user;SavedFilterPanacheRepositoryscopes every read, update and delete byuserId(listByUser,countByUser,findByIdAndUser).crawler.saved_filteralready exists withuser_id UUID NOT NULLandidx_saved_filter_user(db/init/010-crawler.sql), granted tojob_user.JobHub-ui/src/api/jobs.jsalready exposeslistSavedFilters,createSavedFilter,updateSavedFilter,deleteSavedFilteragainst those endpoints, withauth: true.
Everything exists except the wiring: the screen never calls the client. This ADR records the two rulings that are durable beyond the story, because both have consequences the next change must respect (user data location, and an intentionally unused backend capability).
Decision¶
Decision 1. The API is the single source of truth for saved filter presets when the user is
authenticated and USE_API is true. JobSearchScreen loads presets with listSavedFilters()
on mount and on every authed transition, and mutates them exclusively through
createSavedFilter / deleteSavedFilter. React state is a render cache of the server list, never
a second store.
Decision 2. The legacy localStorage key jobhub_saved_filters is dropped, not migrated, and
is actively removed on first mount. Migrating it would take presets that may belong to a
different person who used this browser and attribute them to whoever logs in next, which is
exactly the privacy defect being fixed. There is no per-user attribution stored alongside the
legacy payload, so a correct migration is not even possible. The one-time
localStorage.removeItem("jobhub_saved_filters") is a cleanup of leaked shared state, not a
feature.
Decision 3. Presets are an authenticated-only feature. Anonymous visitors see neither the
"Saved filters" dropdown nor the "Save filter" button (the existing authed gate stays, and is now
the only gate). No anonymous fallback store is introduced, in any form.
Decision 4. In mock/demo mode (USE_API=false) presets live in component state for the session
only. The controls stay functional so the demo keeps its shape, and everything resets on reload.
No browser-persistent store is used, because mock mode has a fabricated account with no identity
to scope by, and reintroducing any shared browser bucket would reintroduce the bug.
Decision 5. The compensation range control leaves the UI; the contract and the backend do not
change. GET /jobs and GET /jobs/facets keep compensationMin/compensationMax,
FilterValues keeps both fields, JobFacets keeps its comp bounds, and
buildSearchParams/buildFacetsParams in src/api/jobs.js keep their pass-through support. The
screen simply stops passing them. The contract is frozen unchanged for this story: zero diff in
api-contracts.
Decision 6. Compensation as displayed data and salary as a sort both survive. The comp
string on JobRow and in the job detail drawer, and the "Salary: high to low" option
(SORT_MAP.salary to salary-desc, backed by SortOption.SALARY_DESC in
JobPostPanacheRepository) are unaffected. Only the range filter is removed. Sorting never hides
a posting; filtering does, and that was the objection.
Decision 7. Presets persist filter dimensions only: no sort, no compensation. The UI writes
a FilterValues body with keyword, company, location, employmentType, careerLevel,
language, postedWithin. It ignores compensationMin, compensationMax and sort when reading
a preset back, so any row written by an out-of-band client stays harmless.
Consequences¶
- Positive: presets follow the account across browsers and devices, and are invisible to any other account on the same machine. The bug class disappears at the storage layer rather than being patched with a user-suffixed key.
- Positive: a backend capability that already existed, was tested and was dead is now exercised; no new endpoint, no new schema, no new migration.
- Negative: presets now require a network round-trip and can fail. The screen grows explicit
loading and failure handling that a synchronous
localStorageread did not need. - Negative: anyone who had presets in the old key loses them, silently and irreversibly. Accepted: the ceiling is 5 named filters, they are cheap to recreate, and the alternative leaks data between accounts.
- Cost:
FilterValues.compensationMin/MaxandPATCH /jobs/filters/saved/{id}become contract surface the UI does not use. That is deliberate (the reporter asked for the flexibility to be kept) and must not be read as dead code to prune. - Follow-ups: renaming a preset (the
PATCHoperation) has no UI affordance and is out of scope for #523.SavedSettings.jsx's static "Hide jobs without compensation" toggle is unrelated mock chrome and is not touched here.
Alternatives considered¶
- Per-user
localStoragekey (jobhub_saved_filters:<userId>). Rejected: it keeps user data in a store any other tab or account can enumerate, it does not survive a device change, and it duplicates a server feature that already exists and is already user-scoped and tested. - Migrate the legacy key into the API on first authenticated load. Rejected: unattributable data, see Decision 2. It would also have to reconcile against the 5-preset ceiling and strip comp fields, all for presets nobody can prove belong to the person logging in.
- Read-only fallback (show legacy presets until the first server write). Rejected: it keeps the reported symptom alive (another account's presets rendered under your session) for an unbounded period, in exchange for a small convenience.
- Remove
compensationMin/compensationMaxfrom the contract too. Rejected explicitly by the reporter. Removing them would also break the facet comp bounds and the salary sort's data source for no gain. - Keep the slider but only apply it when the posting has a salary. Rejected: it makes the control's effect depend on invisible data quality, which is harder to reason about than not having the control.