diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index eb33ced6..57e467c3 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -349,6 +349,50 @@ export default async function jobPlugin(fastify) { return reply.send(); }); + fastify.post('/:jobId/blacklist', async (request, reply) => { + const { jobId } = request.params; + const { word, words } = request.body || {}; + + const candidateWords = []; + if (typeof word === 'string') candidateWords.push(word); + if (Array.isArray(words)) candidateWords.push(...words); + + const cleanedWords = candidateWords + .filter((w) => typeof w === 'string') + .map((w) => w.trim()) + .filter((w) => w.length > 0); + + if (cleanedWords.length === 0) { + return reply.code(400).send({ error: 'No valid blacklist word provided' }); + } + + try { + const job = jobStorage.getJob(jobId); + if (!job) { + return reply.code(404).send({ error: 'Job not found' }); + } + + const settings = await getSettings(); + if (settings.demoMode && !isAdmin(request) && isDemoJob(job.id)) { + return reply.code(403).send({ error: 'Sorry, but you cannot modify the Demo Job ;)' }); + } + + if (!doesJobBelongsToUser(job, request)) { + return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user' }); + } + + const result = jobStorage.appendJobBlacklist(jobId, cleanedWords); + return reply.send({ + success: true, + blacklist: result.blacklist, + added: result.added, + }); + } catch (error) { + logger.error(error); + return reply.code(500).send({ error: error.message }); + } + }); + fastify.get('/shareableUserList', async (request) => { const currentUser = request.session.currentUser; const users = userStorage.getUsers(); diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js index 6bf1de12..b3f61d62 100644 --- a/lib/services/storage/jobStorage.js +++ b/lib/services/storage/jobStorage.js @@ -213,6 +213,49 @@ export const setJobStatus = ({ jobId, status }) => { }); }; +/** + * Append words to a job's blacklist, deduplicating case-insensitively while preserving input. + * + * @param {string} jobId - Job id. + * @param {string[]} wordsToAppend - List of words to add. + * @returns {{ blacklist: string[], added: string[] }|null} + */ +export const appendJobBlacklist = (jobId, wordsToAppend) => { + return SqliteConnection.withTransaction(() => { + const row = SqliteConnection.query(`SELECT blacklist FROM jobs WHERE id = @id LIMIT 1`, { id: jobId })[0]; + if (!row) return null; + const currentBlacklist = fromJson(row.blacklist, []); + const existingLower = new Set(currentBlacklist.map((item) => String(item).toLowerCase())); + + const added = []; + for (const word of wordsToAppend) { + const lower = word.toLowerCase(); + if (!existingLower.has(lower)) { + existingLower.add(lower); + added.push(word); + } + } + + if (added.length === 0) { + return { + blacklist: currentBlacklist, + added: [], + }; + } + + const updatedBlacklist = [...currentBlacklist, ...added]; + SqliteConnection.execute(`UPDATE jobs SET blacklist = @blacklist WHERE id = @id`, { + id: jobId, + blacklist: toJson(updatedBlacklist), + }); + + return { + blacklist: updatedBlacklist, + added, + }; + }); +}; + /** * Remove a job by id. Listings are deleted automatically due to FK ON DELETE CASCADE. * @param {string} jobId - Job id. diff --git a/test/api/jobBlacklist.test.js b/test/api/jobBlacklist.test.js new file mode 100644 index 00000000..bf7b5075 --- /dev/null +++ b/test/api/jobBlacklist.test.js @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { vi, describe, it, expect, beforeEach } 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), + withTransaction: (cb) => db.transaction(() => cb(db))(), + }, +})); + +vi.mock('../../lib/services/storage/settingsStorage.js', () => ({ + getSettings: async () => ({ demoMode: false }), + upsertSettings: () => {}, +})); + +const ALICE = { id: 'u1', username: 'alice', isAdmin: false }; +const BOB = { id: 'u2', username: 'bob', isAdmin: false }; +const ADMIN = { id: 'a1', username: 'root', isAdmin: true }; + +describe('POST /api/jobs/:jobId/blacklist', () => { + let app; + let currentUser; + + const build = async () => { + const plugin = (await import('../../lib/api/routes/jobRouter.js')).default; + const instance = Fastify(); + instance.addHook('preHandler', async (request) => { + request.currentUser = currentUser; + request.session = { currentUser: currentUser?.id }; + }); + await instance.register(plugin, { prefix: '/api/jobs' }); + return instance; + }; + + beforeEach(async () => { + currentUser = ALICE; + db = new Database(':memory:'); + db.exec(` + 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, + 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, + name TEXT, + adapter_id TEXT, + visibility TEXT, + fields TEXT + ); + CREATE TABLE listings ( + id TEXT PRIMARY KEY, + job_id TEXT, + is_active INTEGER DEFAULT 1, + manually_deleted INTEGER DEFAULT 0 + ); + `); + + db.prepare( + ` + INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, deal_type) + VALUES (?, ?, 1, ?, ?, '[]', '[]', '[]', 'rent') + `, + ).run('job-alice', 'u1', 'Alice Job', JSON.stringify(['OldWord'])); + + app = await build(); + }); + + it('rejects empty or whitespace-only word input with 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: ' ' }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.payload).error).toBe('No valid blacklist word provided'); + }); + + it('returns 404 if job does not exist', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/nonexistent-job/blacklist', + payload: { word: 'Tauschwohnung' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('returns 403 if another user tries to modify the job', async () => { + currentUser = BOB; + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: 'Tauschwohnung' }, + }); + expect(res.statusCode).toBe(403); + }); + + it('successfully appends word and trims whitespace', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: ' Tauschwohnung ' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.success).toBe(true); + expect(body.added).toEqual(['Tauschwohnung']); + expect(body.blacklist).toEqual(['OldWord', 'Tauschwohnung']); + + const row = db.prepare('SELECT blacklist FROM jobs WHERE id = ?').get('job-alice'); + expect(JSON.parse(row.blacklist)).toEqual(['OldWord', 'Tauschwohnung']); + }); + + it('deduplicates case-insensitively when word already exists', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: 'oldword' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.success).toBe(true); + expect(body.added).toEqual([]); + expect(body.blacklist).toEqual(['OldWord']); + }); + + it('supports words array in body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { words: ['WBS', 'Zwangsversteigerung', 'oldword'] }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.success).toBe(true); + expect(body.added).toEqual(['WBS', 'Zwangsversteigerung']); + expect(body.blacklist).toEqual(['OldWord', 'WBS', 'Zwangsversteigerung']); + }); + + it('allows admin to modify any job', async () => { + currentUser = ADMIN; + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: 'AdminAdded' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.added).toEqual(['AdminAdded']); + }); +}); diff --git a/test/storage/jobStorage.test.js b/test/storage/jobStorage.test.js index 8e7459f2..f7c47d8d 100644 --- a/test/storage/jobStorage.test.js +++ b/test/storage/jobStorage.test.js @@ -24,6 +24,7 @@ const sqliteMock = { return []; }, __queryHandler: null, + withTransaction: (cb) => cb(), }; vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ @@ -165,3 +166,44 @@ describe('jobStorage.upsertJob binds nothing it does not write', () => { expect(JSON.parse(insert.params.commuteFilter)).toEqual(aJob.commuteFilter); }); }); + +describe('jobStorage.appendJobBlacklist', () => { + let jobStorage; + + beforeEach(async () => { + calls.execute.length = 0; + calls.query.length = 0; + sqliteMock.__queryHandler = null; + jobStorage = await import('../../lib/services/storage/jobStorage.js'); + }); + + it('returns null if job does not exist', () => { + sqliteMock.__queryHandler = () => []; + const result = jobStorage.appendJobBlacklist('missing-job', ['word']); + expect(result).toBeNull(); + expect(calls.execute).toHaveLength(0); + }); + + it('appends and deduplicates words case-insensitively', () => { + sqliteMock.__queryHandler = () => [{ blacklist: JSON.stringify(['ExistingWord', 'wbs']) }]; + const result = jobStorage.appendJobBlacklist('job-1', ['existingword', 'WBS', 'Tauschwohnung']); + expect(result).toEqual({ + blacklist: ['ExistingWord', 'wbs', 'Tauschwohnung'], + added: ['Tauschwohnung'], + }); + const updateCall = calls.execute.find((c) => c.sql.includes('UPDATE jobs SET blacklist')); + expect(updateCall).toBeDefined(); + expect(JSON.parse(updateCall.params.blacklist)).toEqual(['ExistingWord', 'wbs', 'Tauschwohnung']); + }); + + it('returns early without updating the row when no words are added', () => { + sqliteMock.__queryHandler = () => [{ blacklist: JSON.stringify(['ExistingWord', 'wbs']) }]; + const result = jobStorage.appendJobBlacklist('job-1', ['existingword', 'WBS']); + expect(result).toEqual({ + blacklist: ['ExistingWord', 'wbs'], + added: [], + }); + const updateCall = calls.execute.find((c) => c.sql.includes('UPDATE jobs SET blacklist')); + expect(updateCall).toBeUndefined(); + }); +}); diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index a235db15..695f82d2 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Beschreibung", "listing.detail.noDescription": "Keine Beschreibung verfügbar.", + "listing.detail.addToBlacklist": "\"{{term}}\" zur Blacklist hinzufügen", + "listing.detail.blacklistAdded": "\"{{term}}\" zur Blacklist von {{job}} hinzugefügt.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ist bereits in der Blacklist vorhanden.", + "listing.detail.blacklistError": "Fehler beim Hinzufügen zur Blacklist.", "listing.detail.distanceToHome": "Entfernungen:", "listing.detail.locationTitle": "Lage", "listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index 2c077d2a..dfd812c2 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "No description available.", + "listing.detail.addToBlacklist": "Add \"{{term}}\" to ignore list", + "listing.detail.blacklistAdded": "Added \"{{term}}\" to the ignore list of {{job}}.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" is already in the ignore list.", + "listing.detail.blacklistError": "Error adding to ignore list.", "listing.detail.distanceToHome": "Distances:", "listing.detail.locationTitle": "Location", "listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index d7896635..9feb2907 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Datos", "listing.detail.descriptionTitle": "Descripción", "listing.detail.noDescription": "No hay descripción.", + "listing.detail.addToBlacklist": "Añadir \"{{term}}\" a la lista de exclusión", + "listing.detail.blacklistAdded": "Añadido \"{{term}}\" a la lista de exclusión de {{job}}.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ya está en la lista de exclusión.", + "listing.detail.blacklistError": "Error al añadir a la lista de exclusión.", "listing.detail.distanceToHome": "Distancias:", "listing.detail.locationTitle": "Ubicación", "listing.detail.noGeoWarning": "Este anuncio no tiene coordenadas válidas, así que no podemos mostrarlo en el mapa.", diff --git a/ui/src/locales/fr.json b/ui/src/locales/fr.json index 70015436..e6ec8e66 100644 --- a/ui/src/locales/fr.json +++ b/ui/src/locales/fr.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Détails", "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "Aucune description disponible.", + "listing.detail.addToBlacklist": "Ajouter « {{term}} » à la liste noire", + "listing.detail.blacklistAdded": "« {{term}} » ajouté à la liste noire de {{job}}.", + "listing.detail.blacklistAlreadyPresent": "« {{term}} » est déjà présent dans la liste noire.", + "listing.detail.blacklistError": "Erreur lors de l'ajout à la liste noire.", "listing.detail.distanceToHome": "Distances :", "listing.detail.locationTitle": "Emplacement", "listing.detail.noGeoWarning": "Cette annonce n'a pas de coordonnées géographiques valides, nous ne pouvons donc pas l'afficher sur la carte.", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 4d7fa015..6fc088e9 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Dati", "listing.detail.descriptionTitle": "Descrizione", "listing.detail.noDescription": "Nessuna descrizione disponibile.", + "listing.detail.addToBlacklist": "Aggiungi \"{{term}}\" alla lista di esclusione", + "listing.detail.blacklistAdded": "Aggiunto \"{{term}}\" alla lista di esclusione di {{job}}.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" è già presente nella lista di esclusione.", + "listing.detail.blacklistError": "Errore durante l'aggiunta alla lista di esclusione.", "listing.detail.distanceToHome": "Distanze:", "listing.detail.locationTitle": "Posizione", "listing.detail.noGeoWarning": "Questo annuncio non ha coordinate valide, quindi non possiamo mostrarlo sulla mappa.", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index 0463fd41..2b793c38 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -341,6 +341,10 @@ "listing.detail.detailsTitle": "Detaylar", "listing.detail.descriptionTitle": "Açıklama", "listing.detail.noDescription": "Açıklama bulunmuyor.", + "listing.detail.addToBlacklist": "\"{{term}}\" ifadesini kara listeye ekle", + "listing.detail.blacklistAdded": "\"{{term}}\" ifadesi {{job}} kara listesine eklendi.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" zaten kara listede mevcut.", + "listing.detail.blacklistError": "Kara listeye eklenirken hata oluştu.", "listing.detail.distanceToHome": "Uzaklıklar:", "listing.detail.locationTitle": "Konum", "listing.detail.noGeoWarning": "Bu ilanın geçerli coğrafi koordinatları yok, bu nedenle haritada gösterilemiyor.", diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 7f6a6951..d75cc7b8 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -322,6 +322,23 @@ export const useFredyState = create( return { jobsData: { ...state.jobsData, jobs: Object.freeze(updated), result: Object.freeze(result) } }; }); }, + async addJobBlacklistWord(jobId, word) { + const response = await xhrPost(`/api/jobs/${jobId}/blacklist`, { word }); + if (response.status === 200) { + set((state) => { + const updateList = (list) => + (list || []).map((j) => (j.id === jobId ? { ...j, blacklist: response.json.blacklist } : j)); + return { + jobsData: { + ...state.jobsData, + jobs: Object.freeze(updateList(state.jobsData.jobs)), + result: Object.freeze(updateList(state.jobsData.result)), + }, + }; + }); + } + return response.json; + }, }, user: { async getUsers() { diff --git a/ui/src/views/listings/components/ListingDescriptionCard.jsx b/ui/src/views/listings/components/ListingDescriptionCard.jsx index 36483660..f8973d57 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.jsx +++ b/ui/src/views/listings/components/ListingDescriptionCard.jsx @@ -3,10 +3,13 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import { Button, Spin, Typography } from '@douyinfe/semi-ui-19'; -import { IconArticle, IconRefresh } from '@douyinfe/semi-icons'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Button, Dropdown, Spin, Toast, Typography } from '@douyinfe/semi-ui-19'; +import { IconArticle, IconFilter, IconRefresh } from '@douyinfe/semi-icons'; import { useTranslation } from '../../../services/i18n/i18n.jsx'; +import { useActions, useSelector } from '../../../services/state/store.js'; +import { errorMessage } from '../../../services/xhr.js'; import './ListingDescriptionCard.less'; const { Text } = Typography; @@ -55,18 +58,110 @@ const OUTCOME = { */ export default function ListingDescriptionCard({ listing, onRefetch, refetching, outcome }) { const t = useTranslation(); + const actions = useActions(); + const jobs = useSelector((state) => state.jobsData.jobs); const description = listing?.description; + const jobId = listing?.job_id; + const job = (jobs ?? []).find((j) => j?.id === jobId); + const jobName = listing?.job_name; const note = outcome ? OUTCOME[outcome] : null; // A portal with no detail page will not grow one on a second press. const exhausted = outcome === 'unsupported'; + const [selectedText, setSelectedText] = useState(''); + const [menuVisible, setMenuVisible] = useState(false); + const [menuCoords, setMenuCoords] = useState({ x: 0, y: 0 }); + + const anchorRef = useRef(null); + const bodyRef = useRef(null); + const closeMenu = useCallback(() => { + setMenuVisible(false); + }, []); + + useEffect(() => { + if (!menuVisible) return; + const handleDocumentClick = (e) => { + if (anchorRef.current && anchorRef.current.contains(e.target)) return; + closeMenu(); + }; + const handleScroll = () => closeMenu(); + + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('click', handleDocumentClick); + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('click', handleDocumentClick); + }; + }, [menuVisible, closeMenu]); + + const handleContextMenu = (e) => { + if (!jobId || job?.isOnlyShared) { + return; + } + + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) { + return; + } + + const bodyEl = bodyRef.current; + if (!bodyEl || !bodyEl.contains(sel.anchorNode) || !bodyEl.contains(sel.focusNode)) { + return; + } + + const text = sel.toString().trim(); + if (!text || text.includes('\n') || text.includes('\r') || text.length > 100) { + return; + } + + e.preventDefault(); + setSelectedText(text); + setMenuCoords({ + x: e.clientX, + y: e.clientY, + }); + setMenuVisible(true); + }; + const handleAddToBlacklist = async () => { + if (!jobId || !selectedText) return; + closeMenu(); + const term = selectedText; + + try { + const result = await actions.jobsData.addJobBlacklistWord(jobId, term); + if (result && Array.isArray(result.added) && result.added.length > 0) { + Toast.success( + t('listing.detail.blacklistAdded', { + term, + job: jobName || jobId, + }), + ); + } else { + Toast.info( + t('listing.detail.blacklistAlreadyPresent', { + term, + }), + ); + } + } catch (err) { + Toast.error(errorMessage(err, t('listing.detail.blacklistError'))); + } + }; + + const menu = ( + + } onClick={handleAddToBlacklist}> + {t('listing.detail.addToBlacklist', { term: selectedText })} + + + ); // Read without variables, so the placeholder survives to be split on. The two halves are whatever // the locale wrote around it, which is the only way a German verb at the end and an English one // in the middle can share a single string. const [before, after] = t('listing.detail.refetchHint').split(ACTION_SLOT); return ( -
+

{t('listing.detail.descriptionTitle')}

{/* Only when there is already text: the empty state acts through its own sentence, and a @@ -87,7 +182,9 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching,
{description ? ( -

{description}

+

+ {description} +

) : (
); } diff --git a/ui/src/views/listings/components/ListingDescriptionCard.less b/ui/src/views/listings/components/ListingDescriptionCard.less index d55dd4e2..577c8ddf 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.less +++ b/ui/src/views/listings/components/ListingDescriptionCard.less @@ -121,3 +121,14 @@ } } } + +.listing-description-menu { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .semi-dropdown-item { + font-size: @text-sm; + } +}