Files
fredy/lib/services/storage/jobStorage.js
Stephan 10c94eea0a Feature/spec filter (#276)
* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers

* feat(): add specFilter config, also add rooms to listing

* feat(): change tests

* feat(): fix kleinanzeigen parser

* feat(): add spec filter switch for listing overviiews

* feat(): add rooms and size to the overview and detail of a listing

* feat(): rem label

* feat(): add types, update providers, they now return specs as numbers

* feat(): add jsonconfig to enable type checks

* feat: add type for prividerConfig, add fieldNames per provider

* feat: fix tests, provider, add formatListing

* chore: remov duplicates

* feat(): fix tests

* feat: fix immoscout

* chore: geojson typing

* feat: solve requested changes
2026-04-12 09:17:23 +02:00

293 lines
9.7 KiB
JavaScript

/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { nanoid } from 'nanoid';
import SqliteConnection from './SqliteConnection.js';
import logger from '../logger.js';
import { toJson, fromJson } from '../../utils.js';
/**
* Insert or update a job. Preserves original owner (userId) when updating an existing job.
*
* @param {Object} params
* @param {string} [params.jobId] - Existing job id to update; omit to insert a new job.
* @param {string} [params.name] - Job display name.
* @param {Array<any>} [params.blacklist] - Blacklist entries; defaults to empty array.
* @param {boolean} [params.enabled] - Whether the job is enabled; defaults to true.
* @param {Array<any>} params.provider - Provider configuration list.
* @param {Array<any>} params.notificationAdapter - Notification adapter configuration list.
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
* @returns {void}
*/
export const upsertJob = ({
jobId,
name,
blacklist = [],
enabled = true,
provider,
notificationAdapter,
userId,
shareWithUsers = [],
spatialFilter = null,
specFilter = null,
}) => {
const id = jobId || nanoid();
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
const ownerId = existing ? existing.user_id : userId;
if (existing) {
SqliteConnection.execute(
`UPDATE jobs
SET enabled = @enabled,
name = @name,
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers,
spatial_filter = @spatialFilter,
spec_filter = @specFilter
WHERE id = @id`,
{
id,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
specFilter: specFilter ? toJson(specFilter) : null,
},
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter)`,
{
id,
user_id: ownerId,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
provider: toJson(provider ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
specFilter: specFilter ? toJson(specFilter) : null,
},
);
}
};
/**
* Get a single job by id.
* @param {string} jobId - Job primary key.
* @returns {Job|null} The job or null if not found.
*/
export const getJob = (jobId) => {
const row = SqliteConnection.query(
`SELECT j.id,
j.user_id AS userId,
j.enabled,
j.name,
j.blacklist,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
LIMIT 1`,
{ id: jobId },
)[0];
if (!row) return null;
return {
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
specFilter: fromJson(row.specFilter, null),
};
};
/**
* Update job enabled status.
* @param {{jobId: string, status: boolean}} params - Parameters.
* @returns {void}
*/
export const setJobStatus = ({ jobId, status }) => {
SqliteConnection.execute(`UPDATE jobs SET enabled = @enabled WHERE id = @id`, {
id: jobId,
enabled: status ? 1 : 0,
});
};
/**
* Remove a job by id. Listings are deleted automatically due to FK ON DELETE CASCADE.
* @param {string} jobId - Job id.
* @returns {void}
*/
export const removeJob = (jobId) => {
// listings table has FK ON DELETE CASCADE via job_id
SqliteConnection.execute(`DELETE FROM jobs WHERE id = @id`, { id: jobId });
};
export const removeJobsByUserId = (userId) => {
// Count jobs to log similar to previous behavior
const count =
SqliteConnection.query(`SELECT COUNT(1) AS c FROM jobs WHERE user_id = @user_id`, { user_id: userId })[0]?.c ?? 0;
SqliteConnection.execute(`DELETE FROM jobs WHERE user_id = @user_id`, { user_id: userId });
if (count > 0) {
logger.info(`Removed ${count} jobs for user ${userId}`);
}
};
/**
* Get all jobs.
* @returns {Job[]} List of jobs ordered by name (NULLs last).
*/
export const getJobs = () => {
const rows = SqliteConnection.query(
`SELECT j.id,
j.user_id AS userId,
j.enabled,
j.name,
j.blacklist,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
ORDER BY j.name IS NULL, j.name`,
);
return rows.map((row) => ({
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
specFilter: fromJson(row.specFilter, null),
}));
};
/**
* Query jobs with pagination, filtering and sorting.
*
* @param {Object} params
* @param {number} [params.pageSize=50]
* @param {number} [params.page=1]
* @param {string} [params.freeTextFilter]
* @param {object} [params.activityFilter]
* @param {string|null} [params.sortField=null]
* @param {('asc'|'desc')} [params.sortDir='asc']
* @param {string} [params.userId] - Current user id used to scope jobs (ignored for admins).
* @param {boolean} [params.isAdmin=false] - When true, returns all jobs.
* @returns {{ totalNumber:number, page:number, result:Object[] }}
*/
export const queryJobs = ({
pageSize = 50,
page = 1,
activityFilter,
freeTextFilter,
sortField = null,
sortDir = 'asc',
userId = null,
isAdmin = false,
} = {}) => {
// sanitize inputs
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
const offset = (safePage - 1) * safePageSize;
// build WHERE filter
const whereParts = [];
const params = { limit: safePageSize, offset };
params.userId = userId || '__NO_USER__';
if (!isAdmin) {
whereParts.push(
`(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
);
}
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(j.name LIKE @filter)`);
}
if (activityFilter === true) {
whereParts.push('(j.enabled = 1)');
} else if (activityFilter === false) {
whereParts.push('(j.enabled = 0)');
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
// whitelist sortable fields
const sortable = new Set(['name', 'numberOfFoundListings', 'enabled']);
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
let orderSql = 'ORDER BY j.name IS NULL, j.name ASC';
if (safeSortField) {
if (safeSortField === 'numberOfFoundListings') {
orderSql = `ORDER BY numberOfFoundListings ${safeSortDir}`;
} else {
orderSql = `ORDER BY j.${safeSortField} ${safeSortDir}`;
}
}
// count total
const countRow = SqliteConnection.query(
`SELECT COUNT(1) as cnt
FROM jobs j
${whereSql}`,
params,
);
const totalNumber = countRow?.[0]?.cnt ?? 0;
// fetch page
const rows = SqliteConnection.query(
`SELECT j.id,
j.user_id AS userId,
j.enabled,
j.name,
j.blacklist,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
${whereSql}
${orderSql}
LIMIT @limit OFFSET @offset`,
params,
);
const result = rows.map((row) => ({
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
specFilter: fromJson(row.specFilter, null),
}));
return { totalNumber, page: safePage, result };
};