mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
* 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
293 lines
9.7 KiB
JavaScript
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 };
|
|
};
|