Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions lib/api/routes/jobRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
43 changes: 43 additions & 0 deletions lib/services/storage/jobStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Comment thread
chbndrhnns marked this conversation as resolved.
Resolved
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.
Expand Down
171 changes: 171 additions & 0 deletions test/api/jobBlacklist.test.js
Original file line number Diff line number Diff line change
@@ -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']);
});
});
42 changes: 42 additions & 0 deletions test/storage/jobStorage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const sqliteMock = {
return [];
},
__queryHandler: null,
withTransaction: (cb) => cb(),
};

vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
Expand Down Expand Up @@ -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();
});
});
4 changes: 4 additions & 0 deletions ui/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions ui/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions ui/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions ui/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions ui/src/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions ui/src/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading