From 40ee90b79b51d07927ffbf865d11a4bd504aeb5c Mon Sep 17 00:00:00 2001 From: Andrew-devcoder Date: Fri, 23 Oct 2026 13:50:51 +0200 Subject: [PATCH] feat(jobs): add per-job location exclusions --- README.md | 4 + lib/FredyPipelineExecutioner.js | 35 ++- lib/api/routes/jobRouter.js | 5 + lib/mcp/README.md | 2 +- lib/mcp/jobDraftStore.js | 19 +- lib/mcp/mcpAdapter.js | 7 + lib/mcp/mcpNormalizer.js | 2 + lib/services/listings/priceHistoryService.js | Bin 6963 -> 7560 bytes lib/services/storage/jobStorage.js | 18 +- .../sql/54.job-excluded-locations.js | 21 ++ lib/types/job.js | 1 + lib/utils/excludedLocations.js | 57 +++++ test/api/jobBlacklist.test.js | 1 + test/api/jobChannelAuthorization.test.js | 1 + test/api/jobExcludedLocations.test.js | 92 +++++++ test/api/jobRouterTourGuard.test.js | 2 +- test/api/mcpJobDraftTools.test.js | 12 + test/pipeline_excludedLocations.test.js | 156 ++++++++++++ test/pipeline_priceChange.test.js | 36 ++- .../listings/priceHistoryService.test.js | 55 +++++ test/services/mcpJobDraft.test.js | 44 ++++ test/storage/jobExcludedLocations.test.js | 108 +++++++++ test/storage/jobStorageHydration.test.js | 2 +- test/ui/jobDraft.test.js | 4 +- test/ui/jobExcludedLocations.test.js | 229 ++++++++++++++++++ test/ui/jobSummary.test.js | 13 + test/utils/excludedLocations.test.js | 98 ++++++++ ui/src/locales/de.json | 4 + ui/src/locales/en.json | 4 + ui/src/locales/es.json | 4 + ui/src/locales/fr.json | 4 + ui/src/locales/it.json | 4 + ui/src/locales/tr.json | 4 + ui/src/services/jobs/jobDraft.js | 4 +- ui/src/services/jobs/jobSummary.js | 6 +- ui/src/views/jobs/mutation/JobMutation.jsx | 24 +- 36 files changed, 1061 insertions(+), 21 deletions(-) create mode 100644 lib/services/storage/migrations/sql/54.job-excluded-locations.js create mode 100644 lib/utils/excludedLocations.js create mode 100644 test/api/jobExcludedLocations.test.js create mode 100644 test/pipeline_excludedLocations.test.js create mode 100644 test/storage/jobExcludedLocations.test.js create mode 100644 test/ui/jobExcludedLocations.test.js create mode 100644 test/utils/excludedLocations.test.js diff --git a/README.md b/README.md index 9941fd1b7..d71123021 100755 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ Fredy is in the [Unraid](https://unraid.net/) community store. - 📎 **Document uploads**: attach the exposé, floor plans and photos to a listing, so they survive the portal taking the ad down. A listing with documents is never cleaned up automatically - 🖥️ **Web UI** in several languages, with configurable search intervals and working hours +- **Location exclusions per job**: enter district names or postal codes beside the job's blacklist + to reject new listings whose address contains them, ignoring case. Each search has its own list. + Addresses are checked before and after detail enrichment; unknown addresses remain allowed. + Existing listings are kept, but their matching price-change alerts are suppressed for that job. - 🤖 **[MCP server](lib/mcp/README.md)**: query your listings from Claude, ChatGPT or a local LLM - 🌍 Runs anywhere: Docker, Node.js, self-hosted, and uses the reverse engineered **ImmoScout Mobile API** diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js index 89d6f5f5d..331b2e44d 100755 --- a/lib/FredyPipelineExecutioner.js +++ b/lib/FredyPipelineExecutioner.js @@ -33,6 +33,7 @@ import { currencyForCountries, formatListing } from './utils/formatListing.js'; import { addressesWithBudget, exceedsCommuteBudget, normalizeCommuteFilter } from './utils/commuteBudget.js'; import { sleep } from './utils.js'; import { enqueuePreviewCapture } from './services/listings/previewCaptureQueue.js'; +import { compileExcludedLocations, matchesExcludedLocation } from './utils/excludedLocations.js'; /** The placeholder ImmoScout writes for a listing without an address; never worth a lookup. */ const NO_ADDRESS = 'NO ADDRESS FOUND'; @@ -79,12 +80,13 @@ function isLocated(listing) { * 2) Extract raw listings from the provider * 3) Normalize listings to the provider schema * 4) Filter out incomplete/blacklisted listings - * 5) Identify new listings (vs. previously stored hashes) - * 6) Filter out entries that do not match the job's specFilter + * 5) Identify new listings (vs. previously stored hashes) and record existing adverts' price changes + * 6) Filter out addresses excluded by the job and entries that do not match its specFilter * 7) Optionally enrich new listings via provider.fetchDetails * 8) Optionally re-apply the provider blacklist using the (now enriched) * description - only when the user opted in via * `blacklist_filter_on_provider_details` + * Always recheck the job's location exclusions against the enriched address too. * 9) Geocode * 10) Persist new listings * 11) Calculate distances to the user's addresses @@ -96,10 +98,9 @@ function isLocated(listing) { * 17) Apply the job's commute filter to the entries that are too far away * 18) Dispatch notifications * - * The ordering is deliberate in two places. The spec filter sits as early as it can - it needs - * nothing but the normalized price, size and rooms - so an excluded listing never pays for a - * detail fetch, a geocode or an insert. The similarity and area filters, by contrast, run after - * the insert and soft-delete what they reject: the stored row is what makes step 5 skip the + * The location and spec filters follow price-change tracking so existing adverts keep their price + * history, then reject new listings before a detail fetch, a geocode or an insert. The similarity + * and area filters run after the insert and soft-delete what they reject: the stored row makes step 5 skip the * listing next time, and without it an out-of-area listing would be geocoded again on every run. */ class FredyPipelineExecutioner { @@ -124,6 +125,8 @@ class FredyPipelineExecutioner { this._jobKey = job.id; /** @type {SpecFilter | null} */ this._jobSpecFilter = job.specFilter; + /** @type {RegExp[]} */ + this._excludedLocationMatchers = compileExcludedLocations(job.excludedLocations); /** @type {SpatialFilter | null} */ this._jobSpatialFilter = job.spatialFilter; /** @type {import('./utils/commuteBudget.js').CommuteFilter | null} */ @@ -156,12 +159,14 @@ class FredyPipelineExecutioner { .then(this._filter.bind(this)) .then(this._findNew.bind(this)) .then(this._splitOffPriceChanges.bind(this)) + .then(this._filterByExcludedLocations.bind(this)) // The spec filter runs here, before anything expensive. It only reads price, size and rooms, // which normalize() has already produced, so a listing the user excluded by price never // costs a detail page fetch, a Nominatim lookup or a database round trip. It used to run // last, after all three. .then(this._filterBySpecs.bind(this)) .then(this._fetchDetails.bind(this)) + .then(this._filterByExcludedLocations.bind(this)) .then(this._filterAfterDetails.bind(this)) // Again after the details: the first pass lets a listing without size or rooms through, and // the detail page is often where those arrive. Without it a 35 m² flat passed a 60 m² minimum. @@ -417,6 +422,24 @@ class FredyPipelineExecutioner { return listings.map((listing) => this._providerConfig.normalize(listing)); } + /** + * Apply this job's address exclusions to new listings after existing adverts' price changes are + * recorded, then again after details supply an address. Rejected entries never reach geocoding + * or storage. + * + * @param {ParsedListing[]} listings + * @returns {ParsedListing[]} + * @throws {NoNewListingsWarning} When every listing is excluded. + */ + _filterByExcludedLocations(listings) { + if (this._excludedLocationMatchers.length === 0) return listings; + const kept = listings.filter( + (listing) => !matchesExcludedLocation(listing.address, this._excludedLocationMatchers), + ); + if (kept.length === 0) throw new NoNewListingsWarning(); + return kept; + } + /** * Filter out listings that are missing required fields and those rejected by the * provider's blacklist/filter function. diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 57e467c3c..17e1e2e2f 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -213,6 +213,7 @@ export default async function jobPlugin(fastify) { notificationAdapter, name, blacklist = [], + excludedLocations, jobId, enabled, shareWithUsers = [], @@ -251,6 +252,9 @@ export default async function jobPlugin(fastify) { if (!isStringArray(blacklist) || !isStringArray(shareWithUsers)) { return reply.code(400).send({ error: 'blacklist and shareWithUsers must be arrays of strings.' }); } + if (excludedLocations !== undefined && !isStringArray(excludedLocations)) { + return reply.code(400).send({ error: 'excludedLocations must be an array of strings.' }); + } // Jobs now reference channels rather than carrying their configuration. Without this check // any authenticated user could attach an admin-only channel by putting its id in the body. @@ -282,6 +286,7 @@ export default async function jobPlugin(fastify) { enabled, name, blacklist, + excludedLocations, provider, notificationAdapter: channelRefs.map((configuredAdapterId) => ({ configuredAdapterId })), shareWithUsers, diff --git a/lib/mcp/README.md b/lib/mcp/README.md index 539f22739..f8b40b7b3 100644 --- a/lib/mcp/README.md +++ b/lib/mcp/README.md @@ -67,7 +67,7 @@ A search job needs four things a model cannot invent - a name, at least one sear 2. **providerUrls** - the portal is worked out from the URL's host, so the user is never asked to name it. A URL that is only the portal's homepage is refused: it saves cleanly, runs on schedule and quietly finds nothing. 3. **dealType** - renting or buying. The URLs usually give it away, and the draft offers that reading, but the question is still asked. 4. **channelIds** - one or more of the user's own channels. A user with none is told where to create one; the draft waits. -5. **refinements** - price, size, rooms, blacklist words, travel-time limits, sharing, and whether the job starts switched on. Asked once, and answerable with "skip". +5. **refinements** - price, size, rooms, blacklist words, excluded locations matched against listing addresses for this job, travel-time limits, sharing, and whether the job starts switched on. Asked once, and answerable with "skip". 6. **ready** - the summary is read back to the user, and only then does `create_job_from_draft` with `confirmed=true` create anything. Why the server keeps the state: putting the interview inside the model works with Claude and does not work with the local models this server is mostly pointed at. They send one guessed payload, or forget an answer given two turns ago. diff --git a/lib/mcp/jobDraftStore.js b/lib/mcp/jobDraftStore.js index 772e99151..1e2d8e07e 100644 --- a/lib/mcp/jobDraftStore.js +++ b/lib/mcp/jobDraftStore.js @@ -28,6 +28,7 @@ import { hostsOf, normalizeHost, resolveProviderForUrl, validateProviderUrl } from '../services/jobs/providerUrl.js'; import { DEAL_TYPES, normalizeDealType, detectDealTypeForJob } from '../services/dealType.js'; import { normalizeCommuteFilter } from '../utils/commuteBudget.js'; +import { normalizeExcludedLocations } from '../utils/excludedLocations.js'; /** * How long a draft outlives its last update. Long enough to go and create a notification channel in @@ -46,6 +47,7 @@ const MAX_NAME_LENGTH = 40; * @property {'rent'|'buy'|null} dealType * @property {string[]} channelIds * @property {string[]} blacklist + * @property {string[]} excludedLocations - Case-insensitive address terms excluded from this job. * @property {{maxPrice?: number, minSize?: number, minRooms?: number}|null} specFilter * @property {import('../utils/commuteBudget.js').CommuteFilter|null} commuteFilter * @property {string[]} shareWithUsers @@ -79,6 +81,7 @@ export function emptyDraft() { dealType: null, channelIds: [], blacklist: [], + excludedLocations: [], specFilter: null, commuteFilter: null, shareWithUsers: [], @@ -162,9 +165,9 @@ function positiveNumber(value) { * at once. Half-applying would leave the user's next answer landing on a draft that is not the one * the question was computed from. * - * Arrays replace rather than merge. `providerUrls`, `channelIds`, `blacklist` and `shareWithUsers` - * are always the complete desired list - an LLM asked to "add one more" reliably resends the whole - * list, and much less reliably works out a delta. + * Arrays replace rather than merge. `providerUrls`, `channelIds`, `blacklist`, `excludedLocations` + * and `shareWithUsers` are always the complete desired list - an LLM asked to "add one more" + * reliably resends the whole list, and much less reliably works out a delta. * * @param {JobDraft} draft * @param {object} patch @@ -179,6 +182,7 @@ export function applyDraftUpdate(draft, patch, ctx) { providers: [...draft.providers], channelIds: [...draft.channelIds], blacklist: [...draft.blacklist], + excludedLocations: [...(draft.excludedLocations ?? [])], shareWithUsers: [...draft.shareWithUsers], }; let touchedRefinement = patch.skipRefinements === true; @@ -264,6 +268,11 @@ export function applyDraftUpdate(draft, patch, ctx) { next.blacklist = words; } + if (patch.excludedLocations !== undefined) { + touchedRefinement = true; + next.excludedLocations = normalizeExcludedLocations(patch.excludedLocations); + } + if (patch.specFilter !== undefined) { touchedRefinement = true; if (patch.specFilter == null) { @@ -398,6 +407,7 @@ export function describeDraft(draft, ctx) { `- **Portals:** ${draft.providers.map((p) => `${p.name} (${p.url})`).join(', ') || '–'}`, `- **Notify via:** ${draft.channelIds.map(channelName).join(', ') || '–'}`, `- **Blacklist:** ${draft.blacklist.join(', ') || '–'}`, + `- **Excluded locations:** ${(draft.excludedLocations ?? []).join(', ') || '–'}`, `- **Filters:** ${ spec == null ? '–' @@ -479,7 +489,7 @@ export function nextStep(draft, ctx) { key: 'refinements', question: 'Optional refinements - answer with any of these, or say "skip": maximum price in EUR, minimum size in m², ' + - 'minimum number of rooms, words that should exclude a listing, ' + + 'minimum number of rooms, words that should exclude a listing, locations to exclude from listing addresses, ' + (addresses.length > 0 ? `travel-time limits in minutes for your saved addresses (${addresses.join(', ')}), ` : 'travel-time limits (you have no saved addresses yet - they are added in the web UI under Settings), ') + @@ -518,6 +528,7 @@ export function toUpsertPayload(draft, userId, jobId) { name: draft.name, enabled: draft.enabled, blacklist: draft.blacklist, + excludedLocations: normalizeExcludedLocations(draft.excludedLocations), provider: draft.providers.map(({ id, name, url, enabled }) => ({ id, name, url, enabled })), notificationAdapter: draft.channelIds.map((configuredAdapterId) => ({ configuredAdapterId })), shareWithUsers: draft.shareWithUsers, diff --git a/lib/mcp/mcpAdapter.js b/lib/mcp/mcpAdapter.js index d21cf17bd..8ce8350b2 100644 --- a/lib/mcp/mcpAdapter.js +++ b/lib/mcp/mcpAdapter.js @@ -748,6 +748,12 @@ export function createMcpServer() { .array(z.string()) .optional() .describe('Words that exclude a listing when they appear in it. The full list; an empty list clears it.'), + excludedLocations: z + .array(z.string()) + .optional() + .describe( + 'Locations that exclude a listing when its address contains them, ignoring case. Applies only to this job. The full list; an empty list clears it.', + ), specFilter: z .object({ maxPrice: z.number().positive().nullable().optional().describe('Maximum price in EUR'), @@ -909,6 +915,7 @@ function toDraftPatch(draft) { dealType: draft.dealType ?? undefined, channelIds: draft.channelIds, blacklist: draft.blacklist, + excludedLocations: draft.excludedLocations, specFilter: draft.specFilter, commuteFilter: draft.commuteFilter, shareWithUsers: draft.shareWithUsers, diff --git a/lib/mcp/mcpNormalizer.js b/lib/mcp/mcpNormalizer.js index 40e5c627b..dbb25bbaa 100644 --- a/lib/mcp/mcpNormalizer.js +++ b/lib/mcp/mcpNormalizer.js @@ -166,6 +166,7 @@ export function normalizeGetJob(job) { md += `- **Deal type:** ${dealTypeLabel(job.dealType)}\n`; md += `- **Providers:** ${providers.length > 0 ? providers.join(', ') : '–'}\n`; md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : '–'}\n`; + md += `- **Excluded locations:** ${(job.excludedLocations ?? []).join(', ') || '–'}\n`; return toMcpResponse(md); } @@ -537,6 +538,7 @@ export function normalizeJobCreated(job) { md += `- **Deal type:** ${dealTypeLabel(job.dealType)}\n`; md += `- **Portals:** ${providers.length > 0 ? providers.join(', ') : '–'}\n`; md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : '–'}\n`; + md += `- **Excluded locations:** ${(job.excludedLocations ?? []).join(', ') || '–'}\n`; md += `\n#### Next steps\n\n`; md += `- The job runs at the next scheduled interval; it is not run now.\n`; diff --git a/lib/services/listings/priceHistoryService.js b/lib/services/listings/priceHistoryService.js index 610d006beb964863df7d21c5f0aaa10ddf64b131..600ce1e2c2b7de8e0d6fc5ab45d19145dec4e662 100644 GIT binary patch delta 591 zcmZ`$%Sr<=6s0S{ACT^@t4^V0R=O!wP~0hqUtmn`bfTHu;U?p>h!l73{S6mFzs;2( zW*&-G8nQ`p&OMK;51*%RAA@UCl*9oZKv7W|i)RaED~&jw0u~c`+4W?`qAxB05 zSLjPrp#@XzjGMzP-Hbe_iWyzlg?2@SR@--;jcZ~w;EWhnx$%M6C}~0Js2^gi+Ju~{ z02(cZ1{v)b2Bz9W52fRF(ZvWX1;Mq-Ovms>O|Ab_DxdKB>c8qOh@n81h(1tr>mHziH+>#dy*y X{FpNtB!8{FJPj8+{nh$&crf?^adgsM delta 27 jcmeCM-fXtvH{<3+re)%jzeq>0rWfUx7HkfdF=Ye*q{Ira diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js index b3f61d62f..b5650afc5 100644 --- a/lib/services/storage/jobStorage.js +++ b/lib/services/storage/jobStorage.js @@ -10,6 +10,7 @@ import { toJson, fromJson } from '../../utils.js'; import { DEAL_TYPES } from '../dealType.js'; import { getAllChannels } from './configuredAdapterStorage.js'; import { SHARED_WITH_USER_SQL } from '../security/access.js'; +import { normalizeExcludedLocations } from '../../utils/excludedLocations.js'; /** * Every channel on the instance, keyed by id. @@ -62,6 +63,8 @@ const hydrateNotificationAdapter = (raw, channelMap) => { * @param {string} [params.jobId] - Existing job id to update; omit to insert a new job. * @param {string} [params.name] - Job display name. * @param {Array} [params.blacklist] - Blacklist entries; defaults to empty array. + * @param {string[]} [params.excludedLocations] - Address substrings excluded by this job. Omitted + * on an update, the stored list is kept; omitted on an insert, it defaults to []. An empty list clears it. * @param {boolean} [params.enabled] - Whether the job is enabled; defaults to true. * @param {Array} params.provider - Provider configuration list. * @param {Array<{configuredAdapterId: string}>} params.notificationAdapter - References into @@ -77,6 +80,7 @@ export const upsertJob = ({ jobId, name, blacklist = [], + excludedLocations, enabled = true, provider, notificationAdapter, @@ -96,6 +100,7 @@ export const upsertJob = ({ SET enabled = @enabled, name = @name, blacklist = @blacklist, + excluded_locations = COALESCE(@excludedLocations, excluded_locations), provider = @provider, notification_adapter = @notification_adapter, shared_with_user = @shareWithUsers, @@ -111,6 +116,8 @@ export const upsertJob = ({ enabled: enabled ? 1 : 0, name: name ?? null, blacklist: toJson(blacklist ?? []), + excludedLocations: + excludedLocations === undefined ? null : toJson(normalizeExcludedLocations(excludedLocations)), shareWithUsers: toJson(shareWithUsers ?? []), provider: toJson(provider ?? []), notification_adapter: toJson(notificationAdapter ?? []), @@ -122,14 +129,15 @@ export const upsertJob = ({ ); } else { SqliteConnection.execute( - `INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter, commute_filter, deal_type) - VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter, @commuteFilter, @dealType)`, + `INSERT INTO jobs (id, user_id, enabled, name, blacklist, excluded_locations, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter, commute_filter, deal_type) + VALUES (@id, @user_id, @enabled, @name, @blacklist, @excludedLocations, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter, @commuteFilter, @dealType)`, { id, user_id: ownerId, enabled: enabled ? 1 : 0, name: name ?? null, blacklist: toJson(blacklist ?? []), + excludedLocations: toJson(normalizeExcludedLocations(excludedLocations)), provider: toJson(provider ?? []), shareWithUsers: toJson(shareWithUsers ?? []), notification_adapter: toJson(notificationAdapter ?? []), @@ -154,6 +162,7 @@ export const getJob = (jobId) => { j.enabled, j.name, j.blacklist, + j.excluded_locations AS excludedLocations, j.provider, j.shared_with_user, j.notification_adapter AS notificationAdapter, @@ -174,6 +183,7 @@ export const getJob = (jobId) => { ...row, enabled: !!row.enabled, blacklist: fromJson(row.blacklist, []), + excludedLocations: normalizeExcludedLocations(fromJson(row.excludedLocations, [])), provider: fromJson(row.provider, []), shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: hydrateNotificationAdapter(row.notificationAdapter, channelMap), @@ -306,6 +316,7 @@ export const getJobs = ({ includeDisabled = false, visibleTo = null } = {}) => { j.enabled, j.name, j.blacklist, + j.excluded_locations AS excludedLocations, j.provider, j.shared_with_user, j.notification_adapter AS notificationAdapter, @@ -325,6 +336,7 @@ export const getJobs = ({ includeDisabled = false, visibleTo = null } = {}) => { ...row, enabled: !!row.enabled, blacklist: fromJson(row.blacklist, []), + excludedLocations: normalizeExcludedLocations(fromJson(row.excludedLocations, [])), provider: fromJson(row.provider, []), shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: hydrateNotificationAdapter(row.notificationAdapter, channelMap), @@ -417,6 +429,7 @@ export const queryJobs = ({ j.enabled, j.name, j.blacklist, + j.excluded_locations AS excludedLocations, j.provider, j.shared_with_user, j.notification_adapter AS notificationAdapter, @@ -438,6 +451,7 @@ export const queryJobs = ({ ...row, enabled: !!row.enabled, blacklist: fromJson(row.blacklist, []), + excludedLocations: normalizeExcludedLocations(fromJson(row.excludedLocations, [])), provider: fromJson(row.provider, []), shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: hydrateNotificationAdapter(row.notificationAdapter, channelMap), diff --git a/lib/services/storage/migrations/sql/54.job-excluded-locations.js b/lib/services/storage/migrations/sql/54.job-excluded-locations.js new file mode 100644 index 000000000..543815e82 --- /dev/null +++ b/lib/services/storage/migrations/sql/54.job-excluded-locations.js @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Give each job its own address exclusions. Existing searches keep all their criteria and start + * with an empty list. + * + * @param {import('better-sqlite3').Database} db + * @returns {void} + */ +export function up(db) { + const columns = db + .prepare('PRAGMA table_info(jobs)') + .all() + .map((column) => column.name); + if (!columns.includes('excluded_locations')) { + db.exec("ALTER TABLE jobs ADD COLUMN excluded_locations TEXT NOT NULL DEFAULT '[]'"); + } +} diff --git a/lib/types/job.js b/lib/types/job.js index 49cba1d18..616eb86d9 100644 --- a/lib/types/job.js +++ b/lib/types/job.js @@ -12,6 +12,7 @@ * @property {string} [name] Job display name. * @property {boolean} [enabled] Whether the job is enabled. * @property {Array} [blacklist] Blacklist entries. + * @property {string[]} [excludedLocations] Case-insensitive address substrings excluded by this job; defaults to []. * @property {Array} [provider] Provider configuration list. * @property {Object} [notificationAdapter] Notification configuration. * @property {Array} [shared_with_user] Users this job is shared with. diff --git a/lib/utils/excludedLocations.js b/lib/utils/excludedLocations.js new file mode 100644 index 000000000..74c78a981 --- /dev/null +++ b/lib/utils/excludedLocations.js @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Trim a job's location exclusions, dropping blanks and duplicates while preserving the first + * spelling. Unicode normalization makes a typed umlaut and its decomposed form compare equally. + * + * @param {string[]|null|undefined} values + * @returns {string[]} + */ +export function normalizeExcludedLocations(values) { + const seen = new Set(); + const locations = []; + for (const value of Array.isArray(values) ? values : []) { + if (typeof value !== 'string') continue; + const location = value.trim().normalize('NFC'); + const key = location.toLowerCase(); + if (!location || seen.has(key)) continue; + seen.add(key); + locations.push(location); + } + return locations; +} + +/** + * Compile a job's normalized location exclusions once for reuse across its listings. Locations are + * literal text bounded by the start/end of the address or a character other than a Unicode letter + * or number, so a district name does not match the beginning of a longer street name. + * + * @param {string[]|null|undefined} values + * @returns {RegExp[]} + */ +export function compileExcludedLocations(values) { + return normalizeExcludedLocations(values).map((location) => { + const literal = location.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:^|[^\\p{L}\\p{N}])${literal}(?=$|[^\\p{L}\\p{N}])`, 'u'); + }); +} + +/** + * Whether an address contains one of the job's compiled location exclusions, ignoring case. Only + * address text is considered: a description mentioning a nearby district does not put the listing + * in it. Unknown addresses remain allowed; this is text matching, not a geographic boundary lookup. + * + * @param {string|null|undefined} address + * @param {RegExp[]} matchers + * @returns {boolean} + */ +export function matchesExcludedLocation(address, matchers) { + if (typeof address !== 'string' || !address.trim()) return false; + const normalizedAddress = address.trim().normalize('NFC').toLowerCase(); + // Several providers use this placeholder when the portal withheld the address. + if (normalizedAddress === 'no address found') return false; + return matchers.some((matcher) => matcher.test(normalizedAddress)); +} diff --git a/test/api/jobBlacklist.test.js b/test/api/jobBlacklist.test.js index bf7b50751..bfe3a723c 100644 --- a/test/api/jobBlacklist.test.js +++ b/test/api/jobBlacklist.test.js @@ -51,6 +51,7 @@ describe('POST /api/jobs/:jobId/blacklist', () => { enabled INTEGER DEFAULT 1, name TEXT, blacklist TEXT, + excluded_locations TEXT NOT NULL DEFAULT '[]', provider TEXT, notification_adapter TEXT, shared_with_user TEXT, diff --git a/test/api/jobChannelAuthorization.test.js b/test/api/jobChannelAuthorization.test.js index ae9d802d7..dbe4f7378 100644 --- a/test/api/jobChannelAuthorization.test.js +++ b/test/api/jobChannelAuthorization.test.js @@ -61,6 +61,7 @@ describe('POST /api/jobs channel authorisation', () => { enabled INTEGER DEFAULT 1, name TEXT, blacklist TEXT, + excluded_locations TEXT NOT NULL DEFAULT '[]', provider TEXT, notification_adapter TEXT, shared_with_user TEXT DEFAULT '[]', diff --git a/test/api/jobExcludedLocations.test.js b/test/api/jobExcludedLocations.test.js new file mode 100644 index 000000000..b1114487a --- /dev/null +++ b/test/api/jobExcludedLocations.test.js @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import Fastify from 'fastify'; + +let db; + +vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ + default: { + query: (sql, params = {}) => db.prepare(sql).all(params), + execute: (sql, params = {}) => db.prepare(sql).run(params), + }, +})); + +vi.mock('../../lib/services/storage/settingsStorage.js', () => ({ + getSettings: async () => ({ demoMode: false }), +})); + +const plugin = (await import('../../lib/api/routes/jobRouter.js')).default; + +describe('job location exclusion API', () => { + let app; + + beforeEach(async () => { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, user_id TEXT, enabled INTEGER DEFAULT 1, name TEXT, + blacklist TEXT, excluded_locations TEXT NOT NULL DEFAULT '[]', provider TEXT, + notification_adapter TEXT, shared_with_user TEXT DEFAULT '[]', spatial_filter TEXT, + spec_filter TEXT, commute_filter TEXT, deal_type TEXT, last_run_at INTEGER + ); + CREATE TABLE configured_adapter ( + id TEXT PRIMARY KEY, user_id TEXT, adapter_id TEXT, name TEXT, fields TEXT, + visibility TEXT, created_at INTEGER, updated_at INTEGER + ); + CREATE TABLE listings (job_id TEXT, is_active INTEGER, manually_deleted INTEGER); + `); + app = Fastify(); + app.addHook('preHandler', async (request) => { + request.currentUser = { id: 'u1', isAdmin: false }; + request.session = { currentUser: 'u1' }; + }); + await app.register(plugin, { prefix: '/api/jobs' }); + }); + + afterEach(async () => { + await app.close(); + db.close(); + }); + + const post = (extra = {}) => + app.inject({ + method: 'POST', + url: '/api/jobs', + payload: { name: 'Berlin', provider: [], notificationAdapter: [], ...extra }, + }); + + it('saves normalized exclusions and returns them in both job list responses', async () => { + const response = await post({ excludedLocations: [' Neukölln ', '', 'NEUKÖLLN', 'Spandau'] }); + expect(response.statusCode).toBe(200); + expect((await app.inject('/api/jobs')).json()[0].excludedLocations).toEqual(['Neukölln', 'Spandau']); + expect((await app.inject('/api/jobs/data')).json().result[0].excludedLocations).toEqual(['Neukölln', 'Spandau']); + }); + + it('defaults an omitted field on creation to an empty list', async () => { + expect((await post()).statusCode).toBe(200); + expect((await app.inject('/api/jobs')).json()[0].excludedLocations).toEqual([]); + }); + + it('preserves exclusions on an older client update and clears an explicit empty list', async () => { + expect((await post({ excludedLocations: ['Neukölln'] })).statusCode).toBe(200); + const { id: jobId } = (await app.inject('/api/jobs')).json()[0]; + expect((await post({ jobId, name: 'Renamed' })).statusCode).toBe(200); + expect((await app.inject('/api/jobs')).json()[0].excludedLocations).toEqual(['Neukölln']); + expect((await post({ jobId, excludedLocations: [] })).statusCode).toBe(200); + expect((await app.inject('/api/jobs')).json()[0].excludedLocations).toEqual([]); + }); + + it.each([null, 'Neukölln', {}, [1], ['Neukölln', null]].map((excludedLocations) => ({ excludedLocations })))( + 'rejects a non-string-array field ($excludedLocations) without creating a job', + async ({ excludedLocations }) => { + const response = await post({ excludedLocations }); + expect(response.statusCode).toBe(400); + expect(db.prepare('SELECT COUNT(*) AS count FROM jobs').get().count).toBe(0); + }, + ); +}); diff --git a/test/api/jobRouterTourGuard.test.js b/test/api/jobRouterTourGuard.test.js index c0f5853f0..8f86df07e 100644 --- a/test/api/jobRouterTourGuard.test.js +++ b/test/api/jobRouterTourGuard.test.js @@ -39,7 +39,7 @@ describe('job routes and the onboarding tour', () => { CREATE TABLE jobs ( id TEXT PRIMARY KEY, user_id TEXT, enabled INTEGER DEFAULT 1, name TEXT, blacklist TEXT, provider TEXT, notification_adapter TEXT, shared_with_user TEXT DEFAULT '[]', spatial_filter TEXT, spec_filter TEXT, - commute_filter TEXT, deal_type TEXT, last_run_at INTEGER + commute_filter TEXT, deal_type TEXT, last_run_at INTEGER, excluded_locations TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE configured_adapter ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, adapter_id TEXT NOT NULL, name TEXT NOT NULL, diff --git a/test/api/mcpJobDraftTools.test.js b/test/api/mcpJobDraftTools.test.js index 0aeabe16c..5d5219bfe 100644 --- a/test/api/mcpJobDraftTools.test.js +++ b/test/api/mcpJobDraftTools.test.js @@ -127,6 +127,17 @@ describe('start_job_draft', () => { }); describe('update_job_draft', () => { + it('accepts location exclusions and carries them through final validation into the job', async () => { + await interview(); + const result = await callTool('update_job_draft', { + excludedLocations: [' Neukölln ', 'neukölln', '', 'Spandau'], + }); + + expect(textOf(result)).toContain('**Excluded locations:** Neukölln, Spandau'); + await callTool('create_job_from_draft', { confirmed: true }); + expect(upsertJob.mock.calls[0][0].excludedLocations).toEqual(['Neukölln', 'Spandau']); + }); + it('refuses to run without an interview', async () => { const result = await callTool('update_job_draft', { name: 'Köln' }); @@ -159,6 +170,7 @@ describe('create_job_from_draft', () => { expect(payload.jobId).toEqual(expect.any(String)); expect(payload.jobId.length).toBeGreaterThan(0); expect(payload.spatialFilter).toBeNull(); + expect(payload.excludedLocations).toEqual([]); expect(payload.enabled).toBe(true); expect(payload.notificationAdapter).toEqual([{ configuredAdapterId: 'c1' }]); expect(trackPoi).toHaveBeenCalledWith(TRACKING_POIS.MCP_JOB_CREATED); diff --git a/test/pipeline_excludedLocations.test.js b/test/pipeline_excludedLocations.test.js new file mode 100644 index 000000000..4ab902bba --- /dev/null +++ b/test/pipeline_excludedLocations.test.js @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockFredy, sseEvents } from './utils.js'; +import * as mockStore from './mocks/mockStore.js'; +import { get as getNotification, reset as resetNotification } from './mocks/mockNotification.js'; + +const PROVIDER = 'excluded-locations-provider'; +const advert = (id, address) => ({ id, title: 'Flat', link: `https://example.com/${id}`, price: 1000, address }); + +/** Run real pipeline orchestration with external I/O replaced by the existing fixture helpers. */ +async function run( + page, + { jobId = 'job-1', excludedLocations = ['Neukölln'], fetchDetails, normalize = (item) => item } = {}, +) { + const Fredy = await mockFredy(); + const pipeline = new Fredy( + { + url: 'https://example.com/search', + getListings: async () => page, + normalize, + filter: () => true, + requiredFieldNames: ['id', 'title', 'link'], + fetchDetails, + }, + { id: jobId, notificationAdapter: [], ...(excludedLocations == null ? {} : { excludedLocations }) }, + PROVIDER, + { checkAndAddEntry: () => false }, + undefined, + { maxDetailFetches: null }, + ); + await pipeline.execute(); + return mockStore.getKnownListingHashesForJobAndProvider(jobId, PROVIDER); +} + +describe('per-job location exclusions in the pipeline', () => { + beforeEach(() => { + mockStore.resetListings(); + mockStore.geocodedAddresses.length = 0; + mockStore.deletedIds.length = 0; + sseEvents.length = 0; + resetNotification(); + mockStore.setUserSettings({ provider_details: [PROVIDER], blacklist_filter_on_provider_details: false }); + vi.spyOn(mockStore, 'getSettings').mockResolvedValue({ excludedLocations: ['Mitte'] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + mockStore.setUserSettings(null); + }); + + it('rejects normalized addresses before details, geocoding, storage, SSE and notifications', async () => { + const fetchDetails = vi.fn(async (item) => item); + const stored = await run([advert('excluded', 'raw'), advert('allowed', 'Berlin Mitte')], { + normalize: (item) => ({ ...item, address: item.id === 'excluded' ? 'Berlin Neukölln' : item.address }), + fetchDetails, + }); + + expect(stored.map((item) => item.id)).toEqual(['allowed']); + expect(fetchDetails.mock.calls.map(([item]) => item.id)).toEqual(['allowed']); + expect(mockStore.geocodedAddresses).toEqual(['Berlin Mitte']); + expect(getNotification().payload.map((item) => item.id)).toEqual(['allowed']); + expect(sseEvents).toEqual([{ userId: 'user1', event: 'listings:new', data: { jobId: 'job-1', count: 1 } }]); + expect(mockStore.deletedIds).toEqual([]); + }); + + it('rechecks detail-enriched addresses even when the optional blacklist recheck is disabled', async () => { + const stored = await run([advert('excluded', 'Berlin'), advert('allowed', 'Berlin Mitte')], { + fetchDetails: async (item) => ({ ...item, address: item.id === 'excluded' ? 'Berlin Neukölln' : item.address }), + }); + + expect(stored.map((item) => item.id)).toEqual(['allowed']); + expect(mockStore.geocodedAddresses).toEqual(['Berlin Mitte']); + expect(getNotification().payload.map((item) => item.id)).toEqual(['allowed']); + }); + + it('sends nothing when every listing is excluded', async () => { + await run([advert('excluded', 'Berlin Neukölln')]); + expect(getNotification()).toEqual({}); + expect(mockStore.getKnownListingHashesForJobAndProvider('job-1', PROVIDER)).toEqual([]); + expect(sseEvents).toEqual([]); + }); + + it('sends nothing when details reveal that every listing is excluded', async () => { + await run([advert('excluded', undefined)], { + fetchDetails: async (item) => ({ ...item, address: 'Berlin Neukölln' }), + }); + expect(getNotification()).toEqual({}); + expect(mockStore.getKnownListingHashesForJobAndProvider('job-1', PROVIDER)).toEqual([]); + expect(sseEvents).toEqual([]); + }); + + it('isolates exclusions between jobs, ignoring any obsolete global setting', async () => { + const page = [advert('neukoelln', 'Berlin Neukölln'), advert('marzahn', 'Berlin Marzahn')]; + expect( + (await run(page, { jobId: 'job-a', excludedLocations: ['Neukölln', 'Spandau'] })).map((item) => item.id), + ).toEqual(['marzahn']); + mockStore.resetListings(); + expect((await run(page, { jobId: 'job-b', excludedLocations: ['Marzahn'] })).map((item) => item.id)).toEqual([ + 'neukoelln', + ]); + }); + + it('allows all locations for an existing job without exclusions', async () => { + const stored = await run([advert('allowed', 'Berlin Mitte')], { excludedLocations: null }); + expect(stored.map((item) => item.id)).toEqual(['allowed']); + expect(getNotification().payload).toHaveLength(1); + }); + + it('passes listings through unchanged when no exclusions are configured, including an empty batch', async () => { + const Fredy = await mockFredy(); + const pipeline = new Fredy({}, { id: 'job-1', excludedLocations: [] }, PROVIDER); + const listings = [advert('allowed', 'Berlin Mitte')]; + const empty = []; + + expect(pipeline._filterByExcludedLocations(listings)).toBe(listings); + expect(pipeline._filterByExcludedLocations(empty)).toBe(empty); + }); + + it('keeps street names that only contain an excluded location as part of a word', async () => { + const stored = await run( + [ + advert('spandauer', 'Spandauer Straße 1, Berlin'), + advert('mittenwalder', 'Mittenwalder Straße 1, Berlin'), + advert('buchholzer', 'Buchholzer Straße 1, Berlin'), + advert('koepenicker', 'Köpenicker Straße 1, Berlin'), + advert('excluded', 'Berlin Köpenick'), + ], + { excludedLocations: ['Spandau', 'Mitte', 'Buch', 'Köpenick'] }, + ); + + expect(stored.map((item) => item.id)).toEqual(['spandauer', 'mittenwalder', 'buchholzer', 'koepenicker']); + expect(getNotification().payload.map((item) => item.id)).toEqual([ + 'spandauer', + 'mittenwalder', + 'buchholzer', + 'koepenicker', + ]); + }); + + it('allows missing addresses and does not match place names mentioned only in descriptions', async () => { + const stored = await run( + [ + advert('unknown', undefined), + advert('placeholder', 'NO ADDRESS FOUND'), + { ...advert('other', 'Berlin Mitte'), description: 'Close to Neukölln' }, + ], + { excludedLocations: ['Neukölln', 'No'] }, + ); + expect(stored.map((item) => item.id)).toEqual(['unknown', 'placeholder', 'other']); + expect(getNotification().payload).toHaveLength(3); + }); +}); diff --git a/test/pipeline_priceChange.test.js b/test/pipeline_priceChange.test.js index a9aca293f..42e1391f1 100644 --- a/test/pipeline_priceChange.test.js +++ b/test/pipeline_priceChange.test.js @@ -26,6 +26,7 @@ let storage; let notify; let Fredy; let NoNewListingsWarning; +let job; /** @returns {Promise} The production schema, in memory. */ async function migratedDatabase() { @@ -45,6 +46,7 @@ async function migratedDatabase() { } beforeEach(async () => { + job = { ...JOB }; db = await migratedDatabase(); vi.resetModules(); vi.doMock(root + '/lib/services/storage/SqliteConnection.js', () => ({ @@ -60,7 +62,7 @@ beforeEach(async () => { getSettings: async () => ({ priceChangeThresholdPercent: 0 }), getUserSettings: () => ({ language: 'en' }), })); - vi.doMock(root + '/lib/services/storage/jobStorage.js', () => ({ getJob: () => JOB })); + vi.doMock(root + '/lib/services/storage/jobStorage.js', () => ({ getJob: () => job })); vi.doMock(root + '/lib/notification/notify.js', () => ({ send: vi.fn(() => []), sendPriceChange: vi.fn(() => []) })); vi.doMock(root + '/lib/services/similarity-check/similarityCache.js', () => ({ removeEntry: vi.fn() })); @@ -83,10 +85,40 @@ const advert = (hash, price, overrides = {}) => ({ ...overrides, }); const pipeline = (providerConfig = { priceTracking: { selector: '.price' } }) => - new Fredy(providerConfig, JOB, 'kleinanzeigen', { checkAndAddEntry: () => false }, undefined); + new Fredy(providerConfig, job, 'kleinanzeigen', { checkAndAddEntry: () => false }, undefined); const rows = () => db.prepare(`SELECT hash, price, previous_price FROM listings`).all(); describe('a stored advert at a new price', () => { + it('keeps tracking an excluded advert while rejecting new adverts in the excluded location', async () => { + job.excludedLocations = ['Neukölln']; + storage.storeListings(JOB.id, 'kleinanzeigen', [advert('hash-800', 800, { address: 'Berlin Neukölln' })]); + db.exec(`UPDATE listings SET last_price_check_at = 1`); + const fetchDetails = vi.fn(async (item) => item); + const execution = pipeline({ + url: 'https://www.kleinanzeigen.de/search', + priceTracking: { selector: '.price' }, + getListings: async () => [ + advert('hash-750', 750, { address: 'Berlin Neukölln' }), + advert('hash-new', 700, { address: 'Berlin Neukölln', link: LINK + '-new' }), + ], + normalize: (item) => item, + filter: () => true, + requiredFieldNames: ['id', 'title', 'link'], + fetchDetails, + }); + + await execution.execute(); + + expect(rows()).toEqual([{ hash: 'hash-750', price: 750, previous_price: 800 }]); + expect(db.prepare(`SELECT price, source FROM listing_price_history WHERE source = 'scrape'`).all()).toEqual([ + { price: 750, source: 'scrape' }, + ]); + expect(db.prepare(`SELECT last_price_check_at FROM listings`).get().last_price_check_at).toBeGreaterThan(1); + expect(fetchDetails).not.toHaveBeenCalled(); + expect(notify.send).not.toHaveBeenCalled(); + expect(notify.sendPriceChange).not.toHaveBeenCalled(); + }); + it('is reported as a price change, not stored and announced a second time', async () => { storage.storeListings(JOB.id, 'kleinanzeigen', [advert('hash-800', 800)]); diff --git a/test/services/listings/priceHistoryService.test.js b/test/services/listings/priceHistoryService.test.js index 99f6d40fb..4ddce990c 100644 --- a/test/services/listings/priceHistoryService.test.js +++ b/test/services/listings/priceHistoryService.test.js @@ -193,6 +193,61 @@ describe('services/listings/priceHistoryService', () => { expect(state.sent).toHaveLength(0); }); + it('applies independent job exclusions while keeping other and unknown addresses', async () => { + state.settings.excludedLocations = ['Mitte']; + state.jobs.job1.excludedLocations = [' Neukölln ']; + state.jobs.job2 = { + id: 'job2', + userId: 'u2', + excludedLocations: ['Marzahn'], + notificationAdapter: [{ id: 'ntfy' }], + }; + const { notifyPriceChanges } = await loadService(); + const at = (jobId, address) => ({ ...change(jobId), listing: listing(1100, { job_id: jobId, address }) }); + + await notifyPriceChanges([ + at('job1', 'Berlin NEUKÖLLN'), + at('job2', 'Berlin Neukölln'), + at('job2', 'Berlin Marzahn'), + at('job1', 'Berlin Mitte'), + at('job2', undefined), + ]); + + expect(state.sent).toHaveLength(2); + expect(state.sent[0][1]).toHaveLength(1); + expect(state.sent[0][1][0].address).toBe('Berlin Mitte'); + expect(state.sent[1][1]).toHaveLength(2); + expect(state.sent[1][1][0].address).toBe('Berlin Neukölln'); + }); + + it('reads exclusions for each notification batch without changing price history', async () => { + const { notifyPriceChanges, recordPriceChange } = await loadService(); + const reading = recordPriceChange(listing(1200, { address: 'Berlin Neukölln' }), 1100); + + state.jobs.job1.excludedLocations = ['Neukölln']; + await notifyPriceChanges([reading]); + expect(state.sent).toHaveLength(0); + expect(state.observations).toHaveLength(1); + expect(state.applied).toHaveLength(1); + + state.jobs.job1.excludedLocations = []; + await notifyPriceChanges([reading]); + expect(state.sent).toHaveLength(1); + }); + + it('notifies street names containing an excluded location only as part of a word', async () => { + state.jobs.job1.excludedLocations = ['Köpenick']; + const { notifyPriceChanges } = await loadService(); + + await notifyPriceChanges([ + { ...change('job1'), listing: listing(1100, { address: 'Berlin Köpenick' }) }, + { ...change('job1'), listing: listing(1100, { address: 'Köpenicker Straße 1, Berlin' }) }, + ]); + + expect(state.sent).toHaveLength(1); + expect(state.sent[0][1].map((item) => item.address)).toEqual(['Köpenicker Straße 1, Berlin']); + }); + // One job's misconfigured adapter must not swallow every other job's notifications. it('carries on after one job fails', async () => { state.jobs.job2 = { id: 'job2', userId: 'u2', name: 'Other', notificationAdapter: [{ id: 'ntfy' }] }; diff --git a/test/services/mcpJobDraft.test.js b/test/services/mcpJobDraft.test.js index eecd1281f..e86a6b0d2 100644 --- a/test/services/mcpJobDraft.test.js +++ b/test/services/mcpJobDraft.test.js @@ -4,6 +4,7 @@ */ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { normalizeGetJob, normalizeJobCreated } from '../../lib/mcp/mcpNormalizer.js'; import { DRAFT_TTL_MS, @@ -237,6 +238,26 @@ describe('optional refinements', () => { expect(apply({ blacklist: [] }).draft.blacklist).toEqual([]); }); + it('starts without excluded locations and normalizes an exclusion answer', () => { + expect(emptyDraft().excludedLocations).toEqual([]); + const result = apply({ excludedLocations: [' Neukölln ', '', 'neukölln', ' Spandau '] }); + + expect(result.ok).toBe(true); + expect(result.draft.excludedLocations).toEqual(['Neukölln', 'Spandau']); + expect(result.draft.refinementsAnswered).toBe(true); + expect(describeDraft(result.draft, CTX)).toContain('**Excluded locations:** Neukölln, Spandau'); + }); + + it('replaces exclusions without changing another draft or the previous answer', () => { + const first = apply({ excludedLocations: ['Neukölln'] }).draft; + const replacement = apply({ excludedLocations: ['Marzahn'] }, first).draft; + + expect(first.excludedLocations).toEqual(['Neukölln']); + expect(replacement.excludedLocations).toEqual(['Marzahn']); + expect(apply({ excludedLocations: [] }, replacement).draft.excludedLocations).toEqual([]); + expect(emptyDraft().excludedLocations).toEqual([]); + }); + it('defaults the commute action to notify', () => { expect(apply({ commuteFilter: { limits: { Work: 30 } } }).draft.commuteFilter).toEqual({ action: 'notify', @@ -283,6 +304,7 @@ describe('what gets saved', () => { it('carries the interview into upsertJob arguments', () => { const draft = completedDraft({ blacklist: ['Tausch'], + excludedLocations: ['Neukölln'], specFilter: { maxPrice: 1200 }, commuteFilter: { action: 'exclude', limits: { Work: 30 } }, shareWithUsers: ['u2'], @@ -294,6 +316,7 @@ describe('what gets saved', () => { name: 'Köln', enabled: true, blacklist: ['Tausch'], + excludedLocations: ['Neukölln'], provider: [{ id: 'immoscout', name: 'Immoscout', url: RENT_URL, enabled: true }], notificationAdapter: [{ configuredAdapterId: 'c1' }], shareWithUsers: ['u2'], @@ -304,6 +327,10 @@ describe('what gets saved', () => { }); }); + it('saves an empty exclusion list when the draft has none', () => { + expect(toUpsertPayload(completedDraft(), 'u1', 'job-1').excludedLocations).toEqual([]); + }); + it('keeps a job the user asked to start switched off', () => { expect(toUpsertPayload(completedDraft({ enabled: false }), 'u1', 'job-1').enabled).toBe(false); }); @@ -313,6 +340,23 @@ describe('what gets saved', () => { }); }); +describe('job location exclusions in MCP responses', () => { + const responses = [ + { tool: 'get_job', normalize: normalizeGetJob }, + { tool: 'create_job_from_draft', normalize: normalizeJobCreated }, + ]; + + it.each(responses)('shows exclusions in $tool', ({ normalize }) => { + const result = normalize({ id: 'job-1', excludedLocations: ['Neukölln', 'Spandau'] }); + expect(result.content[0].text).toContain('**Excluded locations:** Neukölln, Spandau'); + }); + + it.each(responses)('allows older jobs without exclusions in $tool', ({ normalize }) => { + const result = normalize({ id: 'job-1' }); + expect(result.content[0].text).toContain('**Excluded locations:** –'); + }); +}); + describe('the store keeps one interview per user', () => { it('resumes rather than restarts', () => { startDraft('u1'); diff --git a/test/storage/jobExcludedLocations.test.js b/test/storage/jobExcludedLocations.test.js new file mode 100644 index 000000000..0cc8860c7 --- /dev/null +++ b/test/storage/jobExcludedLocations.test.js @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; + +let db; + +vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ + default: { + query: (sql, params = {}) => db.prepare(sql).all(params), + execute: (sql, params = {}) => db.prepare(sql).run(params), + }, +})); + +const storage = await import('../../lib/services/storage/jobStorage.js'); + +describe('job location exclusion persistence', () => { + beforeEach(() => { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, user_id TEXT, enabled INTEGER DEFAULT 1, name TEXT, + blacklist TEXT, excluded_locations TEXT NOT NULL DEFAULT '[]', provider TEXT, + notification_adapter TEXT, shared_with_user TEXT DEFAULT '[]', spatial_filter TEXT, + spec_filter TEXT, commute_filter TEXT, deal_type TEXT, last_run_at INTEGER + ); + CREATE TABLE configured_adapter ( + id TEXT PRIMARY KEY, user_id TEXT, adapter_id TEXT, name TEXT, fields TEXT, + visibility TEXT, created_at INTEGER, updated_at INTEGER + ); + CREATE TABLE listings (job_id TEXT, is_active INTEGER, manually_deleted INTEGER); + `); + }); + + afterEach(() => db.close()); + + const save = (jobId, extra = {}) => + storage.upsertJob({ jobId, userId: 'u1', name: jobId, provider: [], notificationAdapter: [], ...extra }); + + it('defaults new jobs and pre-existing jobs without exclusions to an empty list on every read path', () => { + db.prepare(`INSERT INTO jobs (id, user_id, name) VALUES ('old', 'u1', 'Old job')`).run(); + save('new'); + + expect(storage.getJob('old').excludedLocations).toEqual([]); + expect(storage.getJob('new').excludedLocations).toEqual([]); + expect(storage.getJobs().map((job) => job.excludedLocations)).toEqual([[], []]); + expect(storage.queryJobs({ userId: 'u1' }).result.map((job) => job.excludedLocations)).toEqual([[], []]); + }); + + it('round-trips trimmed case-insensitively deduplicated locations on every read path', () => { + save('j1', { excludedLocations: [' Neukölln ', '', ' ', 'NEUKÖLLN', 'Spandau'] }); + + expect( + JSON.parse(db.prepare(`SELECT excluded_locations FROM jobs WHERE id = 'j1'`).get().excluded_locations), + ).toEqual(['Neukölln', 'Spandau']); + expect(storage.getJob('j1').excludedLocations).toEqual(['Neukölln', 'Spandau']); + expect(storage.getJobs()[0].excludedLocations).toEqual(['Neukölln', 'Spandau']); + expect(storage.queryJobs({ userId: 'u1' }).result[0].excludedLocations).toEqual(['Neukölln', 'Spandau']); + }); + + it('keeps exclusions independent between jobs when a job is edited or cloned', () => { + save('a', { excludedLocations: ['Neukölln', 'Spandau'] }); + save('b', { excludedLocations: ['Marzahn'] }); + const original = storage.getJob('a'); + storage.upsertJob({ ...original, jobId: 'clone' }); + save('clone', { excludedLocations: ['Mitte'] }); + + expect(storage.getJob('a').excludedLocations).toEqual(['Neukölln', 'Spandau']); + expect(storage.getJob('b').excludedLocations).toEqual(['Marzahn']); + expect(storage.getJob('clone').excludedLocations).toEqual(['Mitte']); + }); + + it('preserves exclusions for an older client update that omits the field, and lets an explicit empty list clear it', () => { + save('a', { excludedLocations: ['Neukölln'] }); + save('a', { name: 'Renamed' }); + expect(storage.getJob('a').excludedLocations).toEqual(['Neukölln']); + + save('a', { excludedLocations: [] }); + expect(storage.getJob('a').excludedLocations).toEqual([]); + }); + + it.each(['not JSON', 'null', '{}'])('tolerates a malformed stored exclusion list (%s)', (raw) => { + save('a'); + db.prepare(`UPDATE jobs SET excluded_locations = ? WHERE id = 'a'`).run(raw); + expect(storage.getJob('a').excludedLocations).toEqual([]); + expect(storage.getJobs()[0].excludedLocations).toEqual([]); + expect(storage.queryJobs({ userId: 'u1' }).result[0].excludedLocations).toEqual([]); + }); + + it('migrates existing jobs without changing saved criteria and can be rerun safely', async () => { + db.exec(` + ALTER TABLE jobs DROP COLUMN excluded_locations; + INSERT INTO jobs (id, user_id, name, blacklist, provider, spatial_filter) + VALUES ('old', 'u1', 'My search', '["ground floor"]', '[{"id":"immoscout"}]', '{"type":"FeatureCollection","features":[]}'); + `); + const before = db.prepare(`SELECT * FROM jobs WHERE id = 'old'`).get(); + const { up } = await import('../../lib/services/storage/migrations/sql/54.job-excluded-locations.js'); + + up(db); + up(db); + + expect(db.prepare(`SELECT * FROM jobs WHERE id = 'old'`).get()).toEqual({ ...before, excluded_locations: '[]' }); + expect(storage.getJob('old').excludedLocations).toEqual([]); + }); +}); diff --git a/test/storage/jobStorageHydration.test.js b/test/storage/jobStorageHydration.test.js index eb9864573..67ba755d8 100644 --- a/test/storage/jobStorageHydration.test.js +++ b/test/storage/jobStorageHydration.test.js @@ -31,7 +31,7 @@ describe('jobStorage notification adapter hydration', () => { CREATE TABLE jobs ( id TEXT PRIMARY KEY, user_id TEXT, enabled INTEGER, name TEXT, blacklist TEXT, provider TEXT, notification_adapter TEXT, shared_with_user TEXT, spatial_filter TEXT, spec_filter TEXT, commute_filter TEXT, - deal_type TEXT, last_run_at INTEGER + deal_type TEXT, last_run_at INTEGER, excluded_locations TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE listings (id TEXT PRIMARY KEY, job_id TEXT, is_active INTEGER, manually_deleted INTEGER); CREATE TABLE configured_adapter ( diff --git a/test/ui/jobDraft.test.js b/test/ui/jobDraft.test.js index 2139273c6..de1a5e7dd 100644 --- a/test/ui/jobDraft.test.js +++ b/test/ui/jobDraft.test.js @@ -51,6 +51,7 @@ const aDraft = () => ({ providerData: [{ id: 'immoscout', url: 'https://www.immobilienscout24.de/Suche/de/koeln/wohnung-mieten' }], selectedChannelIds: [], blacklist: ['Tausch'], + excludedLocations: ['Neukölln'], shareWithUsers: [], enabled: true, spatialFilter: null, @@ -84,6 +85,7 @@ describe('jobDraft', () => { ['a provider', { providerData: [{ id: 'a' }] }], ['a channel', { selectedChannelIds: ['a'] }], ['a blacklist word', { blacklist: ['x'] }], + ['an excluded location', { excludedLocations: ['Neukölln'] }], ['a deal type', { dealType: 'buy' }], ['a drawn area', { spatialFilter: { type: 'Polygon' } }], // Not reachable on its own from the form, which needs a name before it can be saved, but the @@ -97,7 +99,7 @@ describe('jobDraft', () => { it.each([ ['nothing at all', {}], ['a blank name', { name: ' ' }], - ['empty lists', { providerData: [], selectedChannelIds: [], blacklist: [] }], + ['empty lists', { providerData: [], selectedChannelIds: [], blacklist: [], excludedLocations: [] }], // `enabled` defaults to true on a fresh form, so on its own it is not evidence of any editing. ['only the activation default', { enabled: true }], ['null', null], diff --git a/test/ui/jobExcludedLocations.test.js b/test/ui/jobExcludedLocations.test.js new file mode 100644 index 000000000..ccbc84e9b --- /dev/null +++ b/test/ui/jobExcludedLocations.test.js @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import { isJobDirty } from '../../ui/src/services/jobs/jobDirty.js'; +import { resolveJobFormSource } from '../../ui/src/services/jobs/jobFormSource.js'; +import { loadDraft, saveDraft } from '../../ui/src/services/jobs/jobDraft.js'; + +// As in the settings-hook tests, only browser/network APIs and UI children are stubbed. The real +// job form controls its state, creates its payload, and saves/restores its real session draft. +const harness = vi.hoisted(() => ({ cells: [], cursor: 0, effects: [], storage: new Map() })); +const io = vi.hoisted(() => ({ post: vi.fn(), refresh: vi.fn(), channels: vi.fn(), navigate: vi.fn() })); +const state = vi.hoisted(() => ({ + jobsData: { jobs: [], shareableUserList: [] }, + notificationChannels: { channels: [] }, +})); +const actions = vi.hoisted(() => ({ + jobsData: { getJobs: io.refresh }, + notificationChannels: { getChannels: io.channels }, +})); + +vi.mock('react', async (original) => ({ + ...(await original()), + useState(initial) { + const index = harness.cursor++; + if (!(index in harness.cells)) harness.cells[index] = initial; + return [ + harness.cells[index], + (value) => { + harness.cells[index] = typeof value === 'function' ? value(harness.cells[index]) : value; + }, + ]; + }, + useRef(initial) { + const index = harness.cursor++; + return (harness.cells[index] ??= { current: initial }); + }, + useCallback: (fn) => fn, + useEffect(effect, deps) { + const index = harness.cursor++; + const previous = harness.cells[index]; + if (!previous || deps.some((value, i) => !Object.is(value, previous[i]))) { + harness.cells[index] = deps; + harness.effects.push(effect); + } + }, +})); +vi.mock('@douyinfe/semi-ui-19', () => ({ + Input: function Input() {}, + Switch: function Switch() {}, + Button: function Button() {}, + TagInput: function TagInput() {}, + Select: { Option: function Option() {} }, + Banner: function Banner() {}, + Collapse: { Panel: function Panel() {} }, + Toast: { success: vi.fn(), error: vi.fn() }, +})); +vi.mock('@douyinfe/semi-icons', () => ({ + IconArrowLeft: function IconArrowLeft() {}, + IconBell: function IconBell() {}, + IconBriefcase: function IconBriefcase() {}, + IconPlus: function IconPlus() {}, +})); +vi.mock('react-router', () => ({ + useNavigate: () => io.navigate, + useParams: () => ({}), + useLocation: () => ({ pathname: '/jobs/new', search: '' }), +})); +vi.mock('../../ui/src/services/state/store', () => ({ + useActions: () => actions, + useSelector: (selector) => selector(state), +})); +vi.mock('../../ui/src/services/xhr', () => ({ xhrPost: io.post, errorMessage: () => 'error' })); +vi.mock('../../ui/src/services/i18n/i18n.jsx', () => ({ useTranslation: () => (key) => key, useLocale: () => 'en' })); +vi.mock('../../ui/src/hooks/useUnsavedWarning.js', () => ({ useUnsavedWarning: () => {} })); +vi.mock('../../ui/src/components/cards/chartTheme.js', () => ({ formatEuro: (value) => String(value) })); +vi.mock('../../ui/src/components/segment/SegmentPart', () => ({ SegmentPart: function SegmentPart() {} })); +vi.mock('../../ui/src/components/headline/Headline', () => ({ default: function Headline() {} })); +vi.mock('../../ui/src/components/settingsShell/SettingsEmptyState', () => ({ + default: function SettingsEmptyState() {}, +})); +vi.mock('../../ui/src/components/settingsShell/SettingsSaveBar.jsx', () => ({ + default: function SettingsSaveBar() {}, +})); +vi.mock('../../ui/src/views/admin/components/AdminField.jsx', () => ({ default: function AdminField() {} })); +vi.mock('../../ui/src/components/table/NotificationChannelTable', () => ({ + default: function NotificationChannelTable() {}, +})); +vi.mock('../../ui/src/components/table/ProviderTable', () => ({ default: function ProviderTable() {} })); +vi.mock('../../ui/src/views/jobs/mutation/JobReadinessBar.jsx', () => ({ default: function JobReadinessBar() {} })); +vi.mock('../../ui/src/views/jobs/mutation/components/notificationAdapter/NotificationChannelPicker', () => ({ + default: function NotificationChannelPicker() {}, +})); +vi.mock('../../ui/src/views/jobs/mutation/components/notificationAdapter/NotificationChannelEditor', () => ({ + default: function NotificationChannelEditor() {}, +})); +vi.mock('../../ui/src/views/jobs/mutation/components/provider/ProviderMutator', () => ({ + default: function ProviderMutator() {}, +})); +vi.mock('../../ui/src/views/jobs/mutation/components/areaFilter/AreaFilter', () => ({ + default: function AreaFilter() {}, +})); +vi.mock('../../ui/src/views/jobs/mutation/components/CommuteFilter.jsx', () => ({ + default: function CommuteFilter() {}, +})); + +const { default: JobMutation } = await import('../../ui/src/views/jobs/mutation/JobMutation.jsx'); +// Obtain the form that the public route renders without adding a test-only production export. +const JobForm = JobMutation().type; + +function find(node, predicate) { + if (Array.isArray(node)) return node.map((child) => find(child, predicate)).find(Boolean); + if (node == null || typeof node !== 'object') return undefined; + return predicate(node) ? node : find(node.props?.children, predicate); +} + +function render(source) { + harness.cursor = 0; + let tree = JobForm({ source }); + while (harness.effects.length) { + harness.effects.splice(0).forEach((effect) => effect()); + harness.cursor = 0; + tree = JobForm({ source }); + } + return { + locations: find(tree, (node) => node.props?.['aria-label'] === 'jobs.mutation.excludedLocations'), + saveBar: find(tree, (node) => node.type?.name === 'SettingsSaveBar'), + }; +} + +describe('excluded locations belong to the job form', () => { + beforeEach(() => { + harness.cells = []; + harness.cursor = 0; + harness.effects = []; + harness.storage.clear(); + vi.stubGlobal('sessionStorage', { + getItem: (key) => harness.storage.get(key) ?? null, + setItem: (key, value) => harness.storage.set(key, value), + removeItem: (key) => harness.storage.delete(key), + }); + io.post.mockReset(); + io.post.mockResolvedValue(undefined); + io.refresh.mockReset(); + io.refresh.mockResolvedValue(undefined); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it.each([{}, { jobId: 'job-1' }])('defaults to [] for new and existing jobs without the field: %o', (params) => { + const source = resolveJobFormSource({ jobs: [{ id: 'job-1' }], ...params }); + expect(render(source).locations?.props.value).toEqual([]); + }); + + it.each(['edit', 'clone'])('saves exclusions in %s mode', async (mode) => { + const job = { id: 'job-1', name: 'Berlin', excludedLocations: ['Neukölln', 'Spandau'] }; + const source = resolveJobFormSource({ + jobs: [job], + ...(mode === 'edit' ? { jobId: job.id } : { cloneFrom: job.id }), + }); + expect(render(source).locations?.props.value).toEqual(job.excludedLocations); + if (mode === 'edit') render(source).locations.props.onChange(['Wedding']); + await render(source).saveBar.props.onSave(); + expect(io.post).toHaveBeenCalledWith( + '/api/jobs', + expect.objectContaining({ + excludedLocations: mode === 'edit' ? ['Wedding'] : job.excludedLocations, + jobId: mode === 'edit' ? job.id : null, + }), + ); + }); + + it('saves a new job with the exclusions entered in its TagInput', async () => { + const source = resolveJobFormSource({ jobs: [] }); + const form = render(source); + expect(form.locations?.type.name).toBe('TagInput'); + form.locations.props.onChange(['Neukölln']); + expect(render(source).saveBar.props.dirty).toBe(true); + expect(loadDraft(source.draftId).excludedLocations).toEqual(['Neukölln']); + await render(source).saveBar.props.onSave(); + expect(io.post).toHaveBeenCalledWith('/api/jobs', expect.objectContaining({ excludedLocations: ['Neukölln'] })); + }); + + it('restores a job draft, detects the change, and discards back to stored exclusions', () => { + const source = resolveJobFormSource({ jobs: [{ id: 'job-1', excludedLocations: ['Spandau'] }], jobId: 'job-1' }); + saveDraft(source.draftId, { excludedLocations: ['Neukölln'] }); + const restored = render(source); + expect(restored.locations?.props.value).toEqual(['Neukölln']); + expect(restored.saveBar.props.dirty).toBe(true); + restored.saveBar.props.onDiscard(); + const discarded = render(source); + expect(discarded.locations?.props.value).toEqual(['Spandau']); + expect(discarded.saveBar.props.dirty).toBe(false); + expect(loadDraft(source.draftId)).toBeNull(); + }); + + it('tracks exclusions in dirty-state detection while old missing values equal []', () => { + expect(isJobDirty({ excludedLocations: ['Neukölln'] }, { excludedLocations: [] })).toBe(true); + expect(isJobDirty({ excludedLocations: [] }, {})).toBe(false); + }); + + it('restores an older draft without replacing the job’s exclusions', () => { + const source = resolveJobFormSource({ + jobs: [{ id: 'job-1', name: 'Berlin', excludedLocations: ['Neukölln'] }], + jobId: 'job-1', + }); + saveDraft(source.draftId, { name: 'Updated Berlin search' }); + expect(render(source).locations.props.value).toEqual(['Neukölln']); + }); + + it.each(['de', 'en', 'es', 'fr', 'it', 'tr'])( + 'moves the translated control and summary into job settings in %s', + (locale) => { + const translations = JSON.parse(fs.readFileSync(new URL(`../../ui/src/locales/${locale}.json`, import.meta.url))); + for (const key of [ + 'excludedLocations', + 'excludedLocationsHelp', + 'excludedLocationsPlaceholder', + 'summaryExcludedLocations', + ]) { + expect(translations[`jobs.mutation.${key}`], key).toBeTypeOf('string'); + } + expect(translations).not.toHaveProperty(['settings.excludedLocations']); + }, + ); +}); diff --git a/test/ui/jobSummary.test.js b/test/ui/jobSummary.test.js index 226263c61..303060d57 100644 --- a/test/ui/jobSummary.test.js +++ b/test/ui/jobSummary.test.js @@ -34,6 +34,7 @@ describe('jobSummary', () => { ['a size floor', { specFilter: { minSize: 60 } }, 'jobs.mutation.summaryMinSize'], ['a room count', { specFilter: { minRooms: 3 } }, 'jobs.mutation.summaryMinRooms'], ['blocked words', { blacklist: ['Tausch'] }, 'jobs.mutation.summaryBlacklist'], + ['excluded locations', { excludedLocations: ['Neukölln'] }, 'jobs.mutation.summaryExcludedLocations'], ['a drawn area', { spatialFilter: { type: 'Polygon' } }, 'jobs.mutation.summaryArea'], ])('mentions %s', (_what, job, expected) => { expect(describeJobRefinements(job, context)).toContain(expected); @@ -60,6 +61,7 @@ describe('jobSummary', () => { const job = { specFilter: { maxPrice: 1200, minSize: 60, minRooms: 3 }, blacklist: ['Tausch', 'WG'], + excludedLocations: ['Neukölln', 'Spandau'], spatialFilter: { type: 'Polygon' }, commuteFilter: { action: 'mark', limits: { 'home-1': 30 } }, }; @@ -68,6 +70,7 @@ describe('jobSummary', () => { 'jobs.mutation.summaryMinSize', 'jobs.mutation.summaryMinRooms', 'jobs.mutation.summaryBlacklist', + 'jobs.mutation.summaryExcludedLocations', 'jobs.mutation.summaryArea', 'jobs.mutation.summaryCommute', ]); @@ -78,6 +81,15 @@ describe('jobSummary', () => { expect(summariseJobRefinements(job, context)).toBe('jobs.mutation.summaryMaxPrice · jobs.mutation.summaryArea'); }); + it('counts excluded locations and ignores an empty exclusion list', () => { + const parts = describeJobRefinements( + { excludedLocations: ['Neukölln', 'Spandau'] }, + { t: (key, vars) => `${key}:${vars?.count}`, formatPrice }, + ); + expect(parts).toEqual(['jobs.mutation.summaryExcludedLocations:2']); + expect(describeJobRefinements({ excludedLocations: [] }, context)).toEqual([]); + }); + it('formats the price through the caller rather than printing a raw number', () => { const seen = []; describeJobRefinements( @@ -94,6 +106,7 @@ describe('jobSummary', () => { 'jobs.mutation.summaryMinSize', 'jobs.mutation.summaryMinRooms', 'jobs.mutation.summaryBlacklist', + 'jobs.mutation.summaryExcludedLocations', 'jobs.mutation.summaryArea', 'jobs.mutation.summaryCommute', ]) { diff --git a/test/utils/excludedLocations.test.js b/test/utils/excludedLocations.test.js new file mode 100644 index 000000000..52e5ac3f3 --- /dev/null +++ b/test/utils/excludedLocations.test.js @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, expect, it } from 'vitest'; +import { + compileExcludedLocations, + matchesExcludedLocation, + normalizeExcludedLocations, +} from '../../lib/utils/excludedLocations.js'; + +describe('job excluded locations', () => { + it('trims, drops blanks and deduplicates without changing the first spelling or the input', () => { + const values = [' Neukölln ', '', ' ', 'NEUKÖLLN', 'Wedding', ' wedding ']; + expect(normalizeExcludedLocations(values)).toEqual(['Neukölln', 'Wedding']); + expect(values[0]).toBe(' Neukölln '); + }); + + it('treats composed and decomposed place names as the same text', () => { + expect(normalizeExcludedLocations(['Neukölln', 'Neuko\u0308lln'])).toEqual(['Neukölln']); + expect(matchesExcludedLocation('Berlin, Neuko\u0308lln', compileExcludedLocations(['NEUKÖLLN']))).toBe(true); + }); + + it('compiles normalized exclusions once for reuse across addresses', () => { + const locations = [' Spandau ', 'SPANDAU', '', 'Neuko\u0308lln']; + const matchers = compileExcludedLocations(locations); + expect(matchers).toHaveLength(2); + locations[0] = 'Wedding'; + expect(matchesExcludedLocation('Berlin, Spandau', matchers)).toBe(true); + expect(matchesExcludedLocation('Berlin, Spandau', matchers)).toBe(true); + expect(matchesExcludedLocation('Berlin, Neukölln', matchers)).toBe(true); + expect(matchesExcludedLocation('Berlin, Wedding', matchers)).toBe(false); + }); + + it.each([ + ['12043 Berlin, NEUKÖLLN', [' Neukölln '], true], + ['Berlin-Wedding, Müllerstraße 1', ['Spandau', 'Wedding'], true], + ['Berlin Mitte', ['Neukölln'], false], + ['Berlin Mitte', [' '], false], + ['Berlin Mitte', [], false], + ['Berlin Mitte', undefined, false], + [undefined, ['Neukölln'], false], + [null, ['Neukölln'], false], + ['', ['Neukölln'], false], + ['NO ADDRESS FOUND', ['No'], false], + [' no address found ', ['Address'], false], + ])('matches address %s against %j: %s', (address, excluded, expected) => { + expect(matchesExcludedLocation(address, compileExcludedLocations(excluded))).toBe(expected); + }); + + it.each([ + ['Berlin, Spandauer Damm 1', 'Spandau'], + ['Berlin, Mittenwalder Straße 1', 'Mitte'], + ['Berlin, Buchholzer Straße 1', 'Buch'], + ['Berlin, Köpenicker Straße 1', 'Köpenick'], + ['äSpandau', 'Spandau'], + ['Spandauä', 'Spandau'], + ['𐐀Spandau', 'Spandau'], + ['Spandau𐐀', 'Spandau'], + ['1Spandau', 'Spandau'], + ['Spandau1', 'Spandau'], + ['١Spandau', 'Spandau'], + ['Spandau١', 'Spandau'], + ])('does not match a location inside a Unicode word: %s / %s', (address, location) => { + expect(matchesExcludedLocation(address, compileExcludedLocations([location]))).toBe(false); + }); + + it.each([ + ['Spandau', 'Spandau'], + ['Berlin, Spandau', 'Spandau'], + ['Spandau, Berlin', 'Spandau'], + ['Berlin-Mitte, Chausseestraße 1', 'Mitte'], + ['Berlin (Buch), Straße 1', 'Buch'], + ['Köpenick / Berlin', 'Köpenick'], + ['_Spandau_', 'Spandau'], + ['Berlin, Äußere Neustadt', 'Äußere Neustadt'], + ])('matches a complete location between word boundaries: %s / %s', (address, location) => { + expect(matchesExcludedLocation(address, compileExcludedLocations([location]))).toBe(true); + }); + + it.each([ + ['St. Pauli', 'St. Pauli', true], + ['Stx Pauli', 'St. Pauli', false], + ['Halle (Saale)', 'Halle (Saale)', true], + ['Halle Saale', 'Halle (Saale)', false], + ['A+B', 'A+B', true], + ['AB', 'A+B', false], + ['Nord[1]', 'Nord[1]', true], + ['Nord1', 'Nord[1]', false], + ['West\\Ost', 'West\\Ost', true], + ['WestOst', 'West\\Ost', false], + ['Alt.*?^${2}|', 'Alt.*?^${2}|', true], + ['Alt', 'Alt.*?^${2}|', false], + ])('treats regex metacharacters as literal location text: %s / %s', (address, location, expected) => { + expect(matchesExcludedLocation(address, compileExcludedLocations([location]))).toBe(expected); + }); +}); diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 1d81b8398..ba673b01b 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -158,6 +158,7 @@ "jobs.mutation.summaryMinSize": "ab {{value}} m²", "jobs.mutation.summaryMinRooms": "ab {{value}} Zimmer", "jobs.mutation.summaryBlacklist": "{{count}} gesperrte Wörter", + "jobs.mutation.summaryExcludedLocations": "{{count}} ausgeschlossene Orte", "jobs.mutation.summaryArea": "Gebiet gezeichnet", "jobs.mutation.areaStep1": "Suche nach einem Ort, um die Karte dorthin zu bewegen.", "jobs.mutation.areaStep2": "Wähle das Quadrat-Werkzeug und klicke jede Ecke deines Gebiets an.", @@ -193,6 +194,9 @@ "jobs.mutation.sectionBlacklist": "Blacklist", "jobs.mutation.blacklistHelp": "Wenn ein Inserat eines dieser Wörter enthält, wird es herausgefiltert. Gib ein Wort ein und bestätige mit Enter.", "jobs.mutation.blacklistPlaceholder": "Wort zum Filtern hinzufügen...", + "jobs.mutation.excludedLocations": "Ausgeschlossene Orte", + "jobs.mutation.excludedLocationsHelp": "Gilt nur für diesen Suchauftrag. Erkennt Übereinstimmungen in beliebigen Teilen der Adresse unabhängig von Groß- und Kleinschreibung. Inserate ohne Adresse bleiben erlaubt. Bestehende Inserate bleiben erhalten; passende neue Inserate und Preisänderungsbenachrichtigungen werden ausgeschlossen.", + "jobs.mutation.excludedLocationsPlaceholder": "z. B. Neukölln, Spandau oder eine Postleitzahl", "jobs.mutation.sectionCriteriaFilter": "Kriterienfilter", "jobs.mutation.criteriaFilterHelp": "Inserate nach bestimmten Kriterien filtern. Nur Zahlen sind erlaubt. Felder können leer gelassen werden, wenn kein Filter gewünscht ist.", "jobs.mutation.criteriaNumberPlaceholder": "Zahl eingeben", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index aff3877d6..37f1c4ac2 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -158,6 +158,7 @@ "jobs.mutation.summaryMinSize": "from {{value}} m²", "jobs.mutation.summaryMinRooms": "from {{value}} rooms", "jobs.mutation.summaryBlacklist": "{{count}} blocked words", + "jobs.mutation.summaryExcludedLocations": "{{count}} excluded locations", "jobs.mutation.summaryArea": "area drawn", "jobs.mutation.areaStep1": "Search for a place to move the map there.", "jobs.mutation.areaStep2": "Pick the square tool, then click each corner of your area.", @@ -193,6 +194,9 @@ "jobs.mutation.sectionBlacklist": "Blacklist", "jobs.mutation.blacklistHelp": "If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter.", "jobs.mutation.blacklistPlaceholder": "Add a word for filtering...", + "jobs.mutation.excludedLocations": "Excluded locations", + "jobs.mutation.excludedLocationsHelp": "Only applies to this job. Matches any part of the address, ignoring case. Listings without an address are allowed. Existing listings are kept; matching new listings and price-change notifications are excluded.", + "jobs.mutation.excludedLocationsPlaceholder": "e.g. Neukölln, Spandau or a postal code", "jobs.mutation.sectionCriteriaFilter": "Criteria Filter", "jobs.mutation.criteriaFilterHelp": "Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them.", "jobs.mutation.criteriaNumberPlaceholder": "Add a number", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index 025af2ca4..7b3cd92e4 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -158,6 +158,7 @@ "jobs.mutation.summaryMinSize": "desde {{value}} m²", "jobs.mutation.summaryMinRooms": "desde {{value}} habitaciones", "jobs.mutation.summaryBlacklist": "{{count}} palabras bloqueadas", + "jobs.mutation.summaryExcludedLocations": "{{count}} ubicaciones excluidas", "jobs.mutation.summaryArea": "zona dibujada", "jobs.mutation.areaStep1": "Busca un lugar para mover el mapa hasta allí.", "jobs.mutation.areaStep2": "Elige la herramienta del cuadrado y haz clic en cada esquina de tu zona.", @@ -193,6 +194,9 @@ "jobs.mutation.sectionBlacklist": "Lista negra", "jobs.mutation.blacklistHelp": "Si un anuncio contiene una de estas palabras, se descarta. Escribe una palabra y pulsa Intro.", "jobs.mutation.blacklistPlaceholder": "Añade una palabra para filtrar...", + "jobs.mutation.excludedLocations": "Ubicaciones excluidas", + "jobs.mutation.excludedLocationsHelp": "Solo se aplica a esta búsqueda. Busca coincidencias en cualquier parte de la dirección, sin distinguir mayúsculas y minúsculas. Se permiten anuncios sin dirección. Los anuncios existentes se conservan; se excluyen los nuevos anuncios y las notificaciones de cambios de precio que coincidan.", + "jobs.mutation.excludedLocationsPlaceholder": "p. ej., Neukölln, Spandau o un código postal", "jobs.mutation.sectionCriteriaFilter": "Filtro por criterios", "jobs.mutation.criteriaFilterHelp": "Filtra los anuncios por criterios concretos. Solo se admiten números. Puedes dejar en blanco los campos por los que no quieras filtrar.", "jobs.mutation.criteriaNumberPlaceholder": "Escribe un número", diff --git a/ui/src/locales/fr.json b/ui/src/locales/fr.json index 97f21dd0d..e79d2db04 100644 --- a/ui/src/locales/fr.json +++ b/ui/src/locales/fr.json @@ -158,6 +158,7 @@ "jobs.mutation.summaryMinSize": "à partir de {{value}} m²", "jobs.mutation.summaryMinRooms": "à partir de {{value}} pièces", "jobs.mutation.summaryBlacklist": "{{count}} mots exclus", + "jobs.mutation.summaryExcludedLocations": "{{count}} lieux exclus", "jobs.mutation.summaryArea": "zone dessinée", "jobs.mutation.areaStep1": "Recherchez un lieu pour y déplacer la carte.", "jobs.mutation.areaStep2": "Choisissez l'outil carré, puis cliquez sur chaque coin de votre zone.", @@ -193,6 +194,9 @@ "jobs.mutation.sectionBlacklist": "Liste noire", "jobs.mutation.blacklistHelp": "Si une annonce contient l'un de ces mots, elle est écartée. Saisissez un mot, puis appuyez sur Entrée.", "jobs.mutation.blacklistPlaceholder": "Ajouter un mot à filtrer…", + "jobs.mutation.excludedLocations": "Lieux exclus", + "jobs.mutation.excludedLocationsHelp": "S’applique uniquement à cette recherche. Recherche les correspondances dans toute partie de l’adresse, sans tenir compte de la casse. Les annonces sans adresse restent autorisées. Les annonces existantes sont conservées ; les nouvelles annonces et les notifications de changement de prix correspondantes sont exclues.", + "jobs.mutation.excludedLocationsPlaceholder": "p. ex. Neukölln, Spandau ou un code postal", "jobs.mutation.sectionCriteriaFilter": "Filtre par critères", "jobs.mutation.criteriaFilterHelp": "Filtrez les annonces selon des critères précis. Seuls les nombres sont autorisés. Laissez un champ vide si vous ne voulez pas filtrer sur ce critère.", "jobs.mutation.criteriaNumberPlaceholder": "Saisir un nombre", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 20bf35f9a..7710f9c31 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -158,6 +158,7 @@ "jobs.mutation.summaryMinSize": "da {{value}} m²", "jobs.mutation.summaryMinRooms": "da {{value}} locali", "jobs.mutation.summaryBlacklist": "{{count}} parole bloccate", + "jobs.mutation.summaryExcludedLocations": "{{count}} località escluse", "jobs.mutation.summaryArea": "zona disegnata", "jobs.mutation.areaStep1": "Cerca un luogo per spostare la mappa lì.", "jobs.mutation.areaStep2": "Scegli lo strumento quadrato e clicca su ogni angolo della tua zona.", @@ -193,6 +194,9 @@ "jobs.mutation.sectionBlacklist": "Lista nera", "jobs.mutation.blacklistHelp": "Se un annuncio contiene una di queste parole, viene scartato. Scrivi una parola e premi Invio.", "jobs.mutation.blacklistPlaceholder": "Aggiungi una parola per filtrare...", + "jobs.mutation.excludedLocations": "Località escluse", + "jobs.mutation.excludedLocationsHelp": "Si applica solo a questa ricerca. Cerca corrispondenze in qualsiasi parte dell’indirizzo, senza distinguere maiuscole e minuscole. Gli annunci senza indirizzo sono consentiti. Gli annunci esistenti vengono conservati; i nuovi annunci e le notifiche di variazione del prezzo corrispondenti sono esclusi.", + "jobs.mutation.excludedLocationsPlaceholder": "ad es. Neukölln, Spandau o un codice postale", "jobs.mutation.sectionCriteriaFilter": "Filtro per criteri", "jobs.mutation.criteriaFilterHelp": "Filtra gli annunci per criteri specifici. Sono ammessi solo numeri. Puoi lasciare vuoti i campi per cui non vuoi filtrare.", "jobs.mutation.criteriaNumberPlaceholder": "Inserisci un numero", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index 4b7a4fa68..3e6ab82df 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -158,6 +158,7 @@ "jobs.mutation.summaryMinSize": "{{value}} m² üzeri", "jobs.mutation.summaryMinRooms": "{{value}} oda üzeri", "jobs.mutation.summaryBlacklist": "{{count}} engellenen kelime", + "jobs.mutation.summaryExcludedLocations": "{{count}} hariç tutulan konum", "jobs.mutation.summaryArea": "alan çizildi", "jobs.mutation.areaStep1": "Haritayı oraya taşımak için bir yer ara.", "jobs.mutation.areaStep2": "Kare aracını seç, sonra alanının her köşesine tıkla.", @@ -193,6 +194,9 @@ "jobs.mutation.sectionBlacklist": "Kara Liste", "jobs.mutation.blacklistHelp": "Bir ilan bu kelimelerden birini içeriyorsa filtrelenir. Bir kelime yazıp enter tuşuna basın.", "jobs.mutation.blacklistPlaceholder": "Filtrelemek için bir kelime ekleyin...", + "jobs.mutation.excludedLocations": "Hariç tutulan konumlar", + "jobs.mutation.excludedLocationsHelp": "Yalnızca bu iş için geçerlidir. Büyük/küçük harf ayrımı yapmadan adresin herhangi bir bölümünde eşleşme arar. Adresi olmayan ilanlara izin verilir. Mevcut ilanlar korunur; eşleşen yeni ilanlar ve fiyat değişikliği bildirimleri hariç tutulur.", + "jobs.mutation.excludedLocationsPlaceholder": "ör. Neukölln, Spandau veya bir posta kodu", "jobs.mutation.sectionCriteriaFilter": "Kriter Filtresi", "jobs.mutation.criteriaFilterHelp": "İlanları belirli kriterlere göre filtreleyin. Yalnızca sayılara izin verilir. Filtrelemek istemediğiniz alanları boş bırakabilirsiniz.", "jobs.mutation.criteriaNumberPlaceholder": "Bir sayı ekleyin", diff --git a/ui/src/services/jobs/jobDraft.js b/ui/src/services/jobs/jobDraft.js index c22553c40..5a47ec272 100644 --- a/ui/src/services/jobs/jobDraft.js +++ b/ui/src/services/jobs/jobDraft.js @@ -6,7 +6,7 @@ /** * Keeps a half-finished job around while the user goes somewhere else. * - * The job form holds twelve pieces of plain component state, and the form itself offers two links + * The job form holds plain component state, and the form itself offers two links * that unmount it: "Manage channels", and the picker's "you have no channels yet". A first-time * user has to follow one of them - a job cannot be saved without a notification channel, and * channels are only created on the Settings page - and until now that threw away the name, the deal @@ -48,6 +48,7 @@ export const DRAFT_FIELDS = [ 'providerData', 'selectedChannelIds', 'blacklist', + 'excludedLocations', 'shareWithUsers', 'enabled', 'spatialFilter', @@ -86,6 +87,7 @@ export function hasContent(draft) { (draft.providerData?.length ?? 0) > 0 || (draft.selectedChannelIds?.length ?? 0) > 0 || (draft.blacklist?.length ?? 0) > 0 || + (draft.excludedLocations?.length ?? 0) > 0 || (draft.shareWithUsers?.length ?? 0) > 0 || draft.dealType != null || draft.spatialFilter != null || diff --git a/ui/src/services/jobs/jobSummary.js b/ui/src/services/jobs/jobSummary.js index 080775034..3bf3f46ab 100644 --- a/ui/src/services/jobs/jobSummary.js +++ b/ui/src/services/jobs/jobSummary.js @@ -6,7 +6,7 @@ /** * What a job's filters add up to, in a line. * - * The five of them - price and size criteria, blacklist, drawn area, commute limits - are folded + * Price and size criteria, blacklist, excluded locations, drawn area and commute limits are folded * into a collapsed section, and a collapsed section has to answer "is there anything in here" * without being opened. Otherwise the fold does not save the user anything: they open it every * time to check. @@ -23,6 +23,7 @@ import { countCommuteLimits } from './commuteFilter.js'; * * @param {Object} job * @param {string[]} [job.blacklist] + * @param {string[]} [job.excludedLocations] * @param {{maxPrice?: number, minSize?: number, minRooms?: number}|null} [job.specFilter] * @param {Object|null} [job.spatialFilter] * @param {{action?: string, limits?: Record}|null} [job.commuteFilter] @@ -49,6 +50,9 @@ export function describeJobRefinements(job, { t, formatPrice }) { if ((job?.blacklist?.length ?? 0) > 0) { parts.push(t('jobs.mutation.summaryBlacklist', { count: job.blacklist.length })); } + if ((job?.excludedLocations?.length ?? 0) > 0) { + parts.push(t('jobs.mutation.summaryExcludedLocations', { count: job.excludedLocations.length })); + } if (job?.spatialFilter != null) { parts.push(t('jobs.mutation.summaryArea')); } diff --git a/ui/src/views/jobs/mutation/JobMutation.jsx b/ui/src/views/jobs/mutation/JobMutation.jsx index d37759f1f..075b86be4 100644 --- a/ui/src/views/jobs/mutation/JobMutation.jsx +++ b/ui/src/views/jobs/mutation/JobMutation.jsx @@ -134,6 +134,7 @@ function JobMutator({ source }) { const isEdit = source.mode === 'edit'; const defaultBlacklist = sourceJob?.blacklist || []; + const defaultExcludedLocations = sourceJob?.excludedLocations ?? []; const defaultName = source.mode === 'clone' ? `Copy of - ${sourceJob?.name}` : sourceJob?.name || null; const defaultProviderData = sourceJob?.provider || []; // The job stores references, and a read hands back the hydrated adapter shape carrying the @@ -158,6 +159,7 @@ function JobMutator({ source }) { const [providerData, setProviderData] = useState(defaultProviderData); const [name, setName] = useState(defaultName); const [blacklist, setBlacklist] = useState(defaultBlacklist); + const [excludedLocations, setExcludedLocations] = useState(defaultExcludedLocations); const [selectedChannelIds, setSelectedChannelIds] = useState(sourceChannelIds); const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers); const [enabled, setEnabled] = useState(defaultEnabled); @@ -239,6 +241,7 @@ function JobMutator({ source }) { if (draft.providerData !== undefined) setProviderData(draft.providerData); if (draft.selectedChannelIds !== undefined) setSelectedChannelIds(draft.selectedChannelIds); if (draft.blacklist !== undefined) setBlacklist(draft.blacklist); + if (draft.excludedLocations !== undefined) setExcludedLocations(draft.excludedLocations); if (draft.shareWithUsers !== undefined) setShareWithUsers(draft.shareWithUsers); if (draft.enabled !== undefined) setEnabled(draft.enabled); if (draft.spatialFilter !== undefined) setSpatialFilter(draft.spatialFilter); @@ -257,6 +260,7 @@ function JobMutator({ source }) { providerData, selectedChannelIds, blacklist, + excludedLocations, shareWithUsers, enabled, spatialFilter, @@ -278,6 +282,7 @@ function JobMutator({ source }) { providerData, selectedChannelIds, blacklist, + excludedLocations, shareWithUsers, enabled, spatialFilter, @@ -317,6 +322,7 @@ function JobMutator({ source }) { providerData: defaultProviderData, selectedChannelIds: sourceChannelIds, blacklist: defaultBlacklist, + excludedLocations: defaultExcludedLocations, shareWithUsers: defaultShareWithUsers, enabled: defaultEnabled, spatialFilter: defaultSpatialFilter, @@ -332,6 +338,7 @@ function JobMutator({ source }) { setProviderData(baseline.providerData); setSelectedChannelIds(baseline.selectedChannelIds); setBlacklist(baseline.blacklist); + setExcludedLocations(baseline.excludedLocations); setShareWithUsers(baseline.shareWithUsers); setEnabled(baseline.enabled); setSpatialFilter(baseline.spatialFilter); @@ -347,6 +354,7 @@ function JobMutator({ source }) { providerData, selectedChannelIds, blacklist, + excludedLocations, shareWithUsers, enabled, spatialFilter, @@ -398,7 +406,7 @@ function JobMutator({ source }) { // What the collapsed section holds, so it does not have to be opened to find out. const refinementSummary = summariseJobRefinements( - { blacklist, specFilter, spatialFilter, commuteFilter }, + { blacklist, excludedLocations, specFilter, spatialFilter, commuteFilter }, { t, formatPrice: (value) => formatEuro(value, locale) }, ); @@ -420,6 +428,7 @@ function JobMutator({ source }) { shareWithUsers, name, blacklist, + excludedLocations, spatialFilter, specFilter, commuteFilter, @@ -782,6 +791,19 @@ function JobMutator({ source }) { /> + + setExcludedLocations([...value])} + /> + +