Compare commits

...

13 Commits

Author SHA1 Message Date
orangecoding
019b9ac87b next release version 2026-06-03 17:36:40 +02:00
Ramin
0d23d43e79 fix: multiple small style fixes/improvements (#316)
* fix(ui-nav): use selected ring and remove item margin override

* fix(listings): format prices as 1.234,50 € with tabular numerals

* fix(listings): align watchlist star button styles

* fix(version-banner): set margin-bottom to 16px

* fix(ui-nav): keep selected ring with focus reset

* fix(listings): right-align price values

* fix(listings): remove cart icon from price display

* fix(listings): format detail price with shared formatter

* revert(listings): restore grid tile layout from master

Drop watchlist/actions restyle on grid cards.
2026-06-03 17:34:37 +02:00
orangecoding
324afee483 next release version 2026-06-03 10:20:04 +02:00
orangecoding
e95ebb9624 more housekeeping 2026-06-03 10:19:50 +02:00
orangecoding
c29387c85d housekeeping 2026-06-03 09:59:32 +02:00
orangecoding
322ae199b0 allowing multiple chat id's for telegram 2026-06-03 09:46:56 +02:00
orangecoding
b3300169fa merged branch 2026-06-03 08:13:49 +02:00
orangecoding
9296bcdc86 fixing 'open in fredy' link 2026-06-03 08:12:30 +02:00
Christian Kellner
44edf47393 Improved Listing Management (#317)
* adding ability to tag listings eg if you have applied to it / adding ability to add notes to a listing

* storing the date when a status was set
2026-06-02 21:10:08 +02:00
orangecoding
bbebc2a1a2 storing the date when a status was set 2026-06-02 21:09:35 +02:00
orangecoding
d2978c14db merged master 2026-06-02 20:41:43 +02:00
orangecoding
696ae451d3 fixing tooltip issues 2026-06-02 12:57:12 +02:00
orangecoding
317ef79336 adding ability to tag listings eg if you have applied to it / adding ability to add notes to a listing 2026-06-02 12:48:01 +02:00
49 changed files with 1249 additions and 287 deletions

View File

@@ -11,6 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title> <title>Fredy || Real Estate Finder</title>
<link rel="icon" type="image/png" href="/ui/src/assets/heart.png" />
<link rel="apple-touch-icon" href="/ui/src/assets/heart.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />

View File

@@ -99,8 +99,8 @@ class FredyPipelineExecutioner {
/** /**
* Optionally, enrich new listings with data from their detail pages. * Optionally, enrich new listings with data from their detail pages.
* Only called when the provider config defines a `fetchDetails` function. * Only called when the provider config defines a `fetchDetails` function.
* Runs all fetches in parallel. Each fetch must handle its own errors * Fetches are performed sequentially to avoid overloading the provider or
* and always resolve (never reject) to avoid aborting other listings. * the shared browser instance.
* *
* @param {Listing[]} newListings New listings to enrich. * @param {Listing[]} newListings New listings to enrich.
* @returns {Promise<Listing[]>} Resolves with enriched listings. * @returns {Promise<Listing[]>} Resolves with enriched listings.
@@ -199,9 +199,9 @@ class FredyPipelineExecutioner {
const toDeleteListingByIds = []; const toDeleteListingByIds = [];
const keptListings = newListings.filter((listing) => { const keptListings = newListings.filter((listing) => {
const filterOut = const filterOut =
(minRooms && listing.rooms && listing.rooms < minRooms) || (minRooms && listing.rooms != null && listing.rooms < minRooms) ||
(minSize && listing.size && listing.size < minSize) || (minSize && listing.size != null && listing.size < minSize) ||
(maxPrice && listing.price && listing.price > maxPrice); (maxPrice && listing.price != null && listing.price > maxPrice);
if (filterOut) { if (filterOut) {
toDeleteListingByIds.push(listing.id); toDeleteListingByIds.push(listing.id);
@@ -223,24 +223,15 @@ class FredyPipelineExecutioner {
* @param {string} url The provider URL to fetch from. * @param {string} url The provider URL to fetch from.
* @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found). * @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found).
*/ */
_getListings(url) { async _getListings(url) {
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser }); const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
return new Promise((resolve, reject) => { await extractor.execute(url, this._providerConfig.waitForSelector, this._providerId);
extractor const listings = extractor.parseResponseText(
.execute(url, this._providerConfig.waitForSelector, this._providerId) this._providerConfig.crawlContainer,
.then(() => { this._providerConfig.crawlFields,
const listings = extractor.parseResponseText( url,
this._providerConfig.crawlContainer, );
this._providerConfig.crawlFields, return listings == null ? [] : listings;
url,
);
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
logger.error(err);
});
});
} }
/** /**

View File

@@ -12,4 +12,6 @@ export const TRACKING_POIS = {
BASE_URL_SETTING: 'BASE_URL_SETTING', BASE_URL_SETTING: 'BASE_URL_SETTING',
SET_PROXY_SETTING: 'SET_PROXY_SETTING', SET_PROXY_SETTING: 'SET_PROXY_SETTING',
DETECTED_AS_BOT: 'DETECTED_AS_BOT', DETECTED_AS_BOT: 'DETECTED_AS_BOT',
NOTES_CREATE: 'NOTES_CREATE',
USING_LISTING_STATUS: 'USING_LISTING_STATUS',
}; };

View File

@@ -195,6 +195,9 @@ export default async function jobPlugin(fastify) {
const settings = await getSettings(); const settings = await getSettings();
try { try {
const job = jobStorage.getJob(jobId); const job = jobStorage.getJob(jobId);
if (!job) {
return reply.code(404).send({ error: 'Job not found' });
}
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) { if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' }); return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
} }
@@ -216,6 +219,9 @@ export default async function jobPlugin(fastify) {
const settings = await getSettings(); const settings = await getSettings();
try { try {
const job = jobStorage.getJob(jobId); const job = jobStorage.getJob(jobId);
if (!job) {
return reply.code(404).send({ error: 'Job not found' });
}
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) { if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' }); return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });

View File

@@ -8,8 +8,10 @@ import * as watchListStorage from '../../services/storage/watchListStorage.js';
import { isAdmin as isAdminFn } from '../security.js'; import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { nullOrEmpty } from '../../utils.js'; import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js'; import { getJob } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
/** /**
* @param {import('fastify').FastifyInstance} fastify * @param {import('fastify').FastifyInstance} fastify
@@ -23,6 +25,7 @@ export default async function listingsPlugin(fastify) {
jobNameFilter, jobNameFilter,
providerFilter, providerFilter,
watchListFilter, watchListFilter,
statusFilter,
sortfield = null, sortfield = null,
sortdir = 'asc', sortdir = 'asc',
freeTextFilter, freeTextFilter,
@@ -35,12 +38,16 @@ export default async function listingsPlugin(fastify) {
}; };
const normalizedActivity = toBool(activityFilter); const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter); const normalizedWatch = toBool(watchListFilter);
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
const normalizedStatus =
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
? statusFilter.toLowerCase()
: undefined;
let jobFilter = null; let jobFilter = null;
let jobIdFilter = null; let jobIdFilter = null;
const jobs = getJobs();
if (!nullOrEmpty(jobNameFilter)) { if (!nullOrEmpty(jobNameFilter)) {
const job = jobs.find((j) => j.id === jobNameFilter); const job = getJob(jobNameFilter);
jobFilter = job != null ? job.name : null; jobFilter = job != null ? job.name : null;
jobIdFilter = job != null ? job.id : null; jobIdFilter = job != null ? job.id : null;
} }
@@ -54,6 +61,7 @@ export default async function listingsPlugin(fastify) {
jobIdFilter: jobIdFilter, jobIdFilter: jobIdFilter,
providerFilter, providerFilter,
watchListFilter: normalizedWatch, watchListFilter: normalizedWatch,
statusFilter: normalizedStatus,
sortField: sortfield || null, sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc', sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser, userId: request.session.currentUser,
@@ -94,6 +102,55 @@ export default async function listingsPlugin(fastify) {
return reply.send(); return reply.send();
}); });
fastify.post('/:listingId/notes', async (request, reply) => {
const { listingId } = request.params || {};
const { notes } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
try {
const changes = listingStorage.setListingNotes(listingId, typeof notes === 'string' ? notes : null);
if (changes === 0) {
return reply.code(404).send({ message: 'Listing not found' });
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to update listing notes' });
}
await trackPoi(TRACKING_POIS.NOTES_CREATE);
return reply.send();
});
fastify.post('/:listingId/status', async (request, reply) => {
const { listingId } = request.params || {};
const { status } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
const allowed = ['applied', 'rejected', 'accepted'];
const normalized = status == null ? null : String(status).toLowerCase();
if (normalized != null && !allowed.includes(normalized)) {
return reply.code(400).send({ message: `Invalid status: ${status}` });
}
try {
const changes = listingStorage.setListingStatus(listingId, normalized);
await trackPoi(TRACKING_POIS.USING_LISTING_STATUS);
if (changes === 0) {
return reply.code(404).send({ message: 'Listing not found' });
}
if (normalized != null) {
watchListStorage.ensureWatch(listingId, userId);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to update listing status' });
}
return reply.send();
});
fastify.delete('/job', async (request, reply) => { fastify.delete('/job', async (request, reply) => {
const { jobId, hardDelete = false } = request.body; const { jobId, hardDelete = false } = request.body;
const settings = await getSettings(); const settings = await getSettings();
@@ -101,6 +158,16 @@ export default async function listingsPlugin(fastify) {
if (settings.demoMode && !isAdminFn(request)) { if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' }); return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
} }
const job = getJob(jobId);
if (!job) {
return reply.code(404).send({ error: 'Job not found' });
}
const userId = request.session.currentUser;
if (!isAdminFn(request) && job.userId !== userId && !job.shared_with_user.includes(userId)) {
return reply
.code(403)
.send({ error: 'You are trying to remove listings for a job that is not associated to your user' });
}
listingStorage.deleteListingsByJobId(jobId, hardDelete); listingStorage.deleteListingsByJobId(jobId, hardDelete);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
@@ -111,7 +178,11 @@ export default async function listingsPlugin(fastify) {
fastify.delete('/', async (request, reply) => { fastify.delete('/', async (request, reply) => {
const { ids, hardDelete = false } = request.body; const { ids, hardDelete = false } = request.body;
const settings = await getSettings();
try { try {
if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
}
if (Array.isArray(ids) && ids.length > 0) { if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete); listingStorage.deleteListingsById(ids, hardDelete);
} }

View File

@@ -20,6 +20,9 @@ function getClientIp(request) {
function isRateLimited(ip) { function isRateLimited(ip) {
const now = Date.now(); const now = Date.now();
for (const [key, rec] of loginAttempts) {
if (now - rec.firstAttempt > LOGIN_WINDOW_MS) loginAttempts.delete(key);
}
const record = loginAttempts.get(ip); const record = loginAttempts.get(ip);
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) { if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
loginAttempts.set(ip, { count: 1, firstAttempt: now }); loginAttempts.set(ip, { count: 1, firstAttempt: now });

View File

@@ -3,13 +3,11 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import SqliteConnection from '../../services/storage/SqliteConnection.js'; import { getSettings, getUserSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js'; import { isAdmin } from '../security.js';
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js'; import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js'; import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js'; import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
import { fromJson } from '../../utils.js';
import { trackPoi } from '../../services/tracking/Tracker.js'; import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
@@ -21,12 +19,7 @@ import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
export default async function userSettingsPlugin(fastify) { export default async function userSettingsPlugin(fastify) {
fastify.get('/', async (request) => { fastify.get('/', async (request) => {
const userId = request.session.currentUser; const userId = request.session.currentUser;
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId }); return getUserSettings(userId);
const settings = {};
for (const r of rows) {
settings[r.name] = fromJson(r.value, null);
}
return settings;
}); });
fastify.get('/autocomplete', async (request, reply) => { fastify.get('/autocomplete', async (request, reply) => {

View File

@@ -155,6 +155,12 @@ export function createMcpServer() {
), ),
sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'), sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'),
sortDir: z.string().optional().describe('Sort direction: asc or desc'), sortDir: z.string().optional().describe('Sort direction: asc or desc'),
status: z
.enum(['applied', 'rejected', 'accepted', 'none'])
.optional()
.describe(
'Filter by user-set status. "applied", "rejected", or "accepted" return only listings with that status; "none" returns only listings without a status set.',
),
}, },
async ( async (
{ {
@@ -170,6 +176,7 @@ export function createMcpServer() {
maxPrice, maxPrice,
sortField, sortField,
sortDir, sortDir,
status,
}, },
extra, extra,
) => { ) => {
@@ -192,6 +199,7 @@ export function createMcpServer() {
maxPrice: maxPrice ?? null, maxPrice: maxPrice ?? null,
sortField: sortField ?? null, sortField: sortField ?? null,
sortDir: sortDir ?? 'desc', sortDir: sortDir ?? 'desc',
statusFilter: status,
userId: user.id, userId: user.id,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
}); });

View File

@@ -124,10 +124,10 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
md += '\n\n'; md += '\n\n';
if (listings.length > 0) { if (listings.length > 0) {
md += `| ID | Title | Address | Price | Size | Provider | Active | Created | Job |\n`; md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|---------|-----|\n`; md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
for (const l of listings) { for (const l of listings) {
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
} }
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`; md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
} else { } else {
@@ -156,6 +156,10 @@ export function normalizeGetListing(listing) {
md += `- **Link:** ${listing.link || ''}\n`; md += `- **Link:** ${listing.link || ''}\n`;
md += `- **Image:** ${listing.image_url || ''}\n`; md += `- **Image:** ${listing.image_url || ''}\n`;
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`; md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
md += `- **Status:** ${listing.status?.status || ''}\n`;
if (listing.status?.setAt) {
md += `- **Status set at:** ${formatDate(listing.status.setAt)}\n`;
}
md += `- **Created:** ${formatDate(listing.created_at)}\n`; md += `- **Created:** ${formatDate(listing.created_at)}\n`;
md += `- **Job:** ${listing.job_name || ''}\n`; md += `- **Job:** ${listing.job_name || ''}\n`;
if (listing.latitude != null && listing.longitude != null) { if (listing.latitude != null && listing.longitude != null) {

View File

@@ -13,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : ''; const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${newListing.id}` : '';
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`; const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
return fetch(server, { return fetch(server, {
method: 'POST', method: 'POST',

View File

@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => { export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
/* eslint-disable no-console */ /* eslint-disable no-console */
const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/listings/listing/${l.id}`).join(', ') : null; const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/#/listings/listing/${l.id}`).join(', ') : null;
return [ return [
Promise.resolve( Promise.resolve(
console.info( console.info(

View File

@@ -7,6 +7,7 @@ import fetch from 'node-fetch';
import { getJob } from '../../services/storage/jobStorage.js'; import { getJob } from '../../services/storage/jobStorage.js';
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js'; import { normalizeImageUrl } from '../../utils.js';
import logger from '../../services/logger.js';
/** /**
* Generates an idempotent decimal color code. The input string-based color code is * Generates an idempotent decimal color code. The input string-based color code is
@@ -67,11 +68,19 @@ const buildEmbed = (jobKey, listing, baseUrl) => {
}, },
]; ];
if (baseUrl && listing.id) {
fields.push({
name: 'Open in Fredy',
value: `[Open in Fredy](${baseUrl}/#/listings/listing/${listing.id})`,
inline: false,
});
}
const embed = { const embed = {
title: title, title: title,
color: generateColorFromString(jobKey), color: generateColorFromString(jobKey),
url: listing.link, url: listing.link,
fields: fields, fields,
}; };
if (listing.image) { if (listing.image) {
@@ -80,14 +89,6 @@ const buildEmbed = (jobKey, listing, baseUrl) => {
}; };
} }
if (baseUrl && listing.id) {
fields.push({
name: 'Open in Fredy',
value: `[Open in Fredy](${baseUrl}/listings/listing/${listing.id})`,
inline: false,
});
}
return embed; return embed;
}; };
@@ -119,7 +120,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body, body,
}).catch((error) => { }).catch((error) => {
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error); logger.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
return Promise.reject(new Error(`Webhook failed: ${error.message}`)); return Promise.reject(new Error(`Webhook failed: ${error.message}`));
}); });

View File

@@ -13,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`; let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`; message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
message += newListings.map((o) => { message += newListings.map((o) => {
const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/listings/listing/${o.id}) |` : ''; const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/#/listings/listing/${o.id}) |` : '';
return ( return (
`| [${o.title}](${o.link}) | ` + `| [${o.title}](${o.link}) | ` +
[o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') +

View File

@@ -14,7 +14,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : ''; const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${newListing.id}` : '';
const message = ` const message = `
Address: ${newListing.address} Address: ${newListing.address}
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')} Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}

View File

@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
const results = await Promise.all( const results = await Promise.all(
newListings.map(async (newListing) => { newListings.map(async (newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : ''; const fredyLine =
baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${newListing.id}` : '';
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`; const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
const form = new FormData(); const form = new FormData();

View File

@@ -39,7 +39,7 @@ const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
if (baseUrl && p.id) { if (baseUrl && p.id) {
blocks.push({ blocks.push({
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` }, text: { type: 'mrkdwn', text: `<${baseUrl}/#/listings/listing/${p.id}|Open in Fredy>` },
}); });
} }

View File

@@ -39,7 +39,7 @@ const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
if (baseUrl && p.id) { if (baseUrl && p.id) {
blocks.push({ blocks.push({
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` }, text: { type: 'mrkdwn', text: `<${baseUrl}/#/listings/listing/${p.id}|Open in Fredy>` },
}); });
} }

View File

@@ -12,41 +12,45 @@ import logger from '../../services/logger.js';
import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js'; import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js';
const RATE_LIMIT_INTERVAL = 1000; const RATE_LIMIT_INTERVAL = 1000;
const THROTTLE_MAX_IDLE_MS = RATE_LIMIT_INTERVAL + 2000;
const chatThrottleMap = new Map(); const chatThrottleMap = new Map();
/** /**
* Removes stale throttled call entries to keep memory bounded. * Removes stale throttled call entries to keep memory bounded.
* An entry is stale when no API call has fired for longer than THROTTLE_MAX_IDLE_MS.
*/ */
function cleanupOldThrottles() { function cleanupOldThrottles() {
const now = Date.now(); const now = Date.now();
const maxAge = RATE_LIMIT_INTERVAL + 1000;
const toBeDeleted = [];
for (const [chatId, chatThrottle] of chatThrottleMap.entries()) { for (const [chatId, chatThrottle] of chatThrottleMap.entries()) {
if (now - chatThrottle.lastUsedAt > maxAge) toBeDeleted.push(chatId); if (now - chatThrottle.lastUsedAt > THROTTLE_MAX_IDLE_MS) chatThrottleMap.delete(chatId);
} }
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
} }
/** /**
* Return a throttled wrapper for a chatId to limit Telegram API calls. * Return a throttled wrapper for a chatId to limit Telegram API calls.
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat. * Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
* `lastUsedAt` is refreshed on every actual API call so that the idle window
* starts from the last fired call, not from when send() was invoked.
* *
* @template {Function} T
* @param {string|number} chatId * @param {string|number} chatId
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response> * @param {Function} call - async function (endpoint: string, body: any) => Promise<Response>
* @returns {T} * @returns {Function}
*/ */
function getThrottled(chatId, call) { function getThrottled(chatId, call) {
cleanupOldThrottles(); cleanupOldThrottles();
const now = Date.now(); const existing = chatThrottleMap.get(chatId);
const chatThrottle = chatThrottleMap.get(chatId); if (existing) {
if (chatThrottle) { existing.lastUsedAt = Date.now();
chatThrottle.lastUsedAt = now; return existing.throttled;
return chatThrottle.throttled;
} }
const throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(call); const entry = { lastUsedAt: Date.now(), throttled: null };
chatThrottleMap.set(chatId, { lastUsedAt: now, throttled }); chatThrottleMap.set(chatId, entry);
return throttled; entry.throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(async (endpoint, body) => {
const e = chatThrottleMap.get(chatId);
if (e) e.lastUsedAt = Date.now();
return call(endpoint, body);
});
return entry.throttled;
} }
/** /**
@@ -70,39 +74,20 @@ function escapeHtml(s = '') {
} }
/** /**
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode. * Build a Telegram HTML-formatted message body.
* Suitable for both sendMessage (uncapped) and sendPhoto captions (caller must slice to 1024).
*
* @param {string} jobName * @param {string} jobName
* @param {string} serviceName * @param {string} serviceName
* @param {Object} o - Listing object * @param {Object} o - Listing object
* @param {string} [o.title] * @param {string} [baseUrl]
* @param {string} [o.address]
* @param {string|number} [o.price]
* @param {string|number} [o.size]
* @param {string} [o.link]
* @returns {string} * @returns {string}
*/ */
function buildCaption(jobName, serviceName, o, baseUrl) { function buildHtmlBody(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink = const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : ''; baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/#/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
o.link || '',
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024);
}
/**
* Build a Telegram message text using HTML parse mode.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildText(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return ( return (
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` + `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` + `<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
@@ -111,34 +96,128 @@ function buildText(jobName, serviceName, o, baseUrl) {
} }
/** /**
* Build a plain text Telegram photo caption (max 4096 characters). * Build a plain-text Telegram photo caption (max 4096 characters).
* Meta appears before the link so the most relevant info is visible within the cap.
*
* @param {string} jobName * @param {string} jobName
* @param {string} serviceName * @param {string} serviceName
* @param {Object} o - Listing object * @param {Object} o - Listing object
* @param baseUrl * @param {string} [baseUrl]
* @returns {string} * @returns {string}
*/ */
function buildCaptionPlain(jobName, serviceName, o, baseUrl) { function buildPlainCaption(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : ''; const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${o.id}` : '';
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096); return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096);
} }
/** /**
* Build a plain text Telegram message. * Build a plain-text Telegram message body.
* Link appears early so it is tappable without scrolling.
*
* @param {string} jobName * @param {string} jobName
* @param {string} serviceName * @param {string} serviceName
* @param {Object} o - Listing object * @param {Object} o - Listing object
* @param {string} [baseUrl]
* @returns {string} * @returns {string}
*/ */
function buildTextPlain(jobName, serviceName, o, baseUrl) { function buildPlainText(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : ''; const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${o.id}` : '';
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`; return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`;
} }
/**
* Create the raw Telegram API caller for a given bot token.
* Handles JSON and multipart (FormData) bodies.
*
* @param {string} token - Telegram bot token.
* @param {string} jobName - Used in error messages.
* @returns {(endpoint: string, body: object|FormData) => Promise<Response>}
*/
function makeTelegramCaller(token, jobName) {
return async function (endpoint, body) {
const isFormData = body instanceof FormData;
const opts = isFormData
? { method: 'post', body }
: { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } };
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, opts);
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
}
return res;
};
}
/**
* Send a single listing to a single Telegram chat, with photo-then-text fallback.
*
* @param {Function} throttledCall - Throttled Telegram API caller for this chat.
* @param {Object} listing - Listing object.
* @param {string|number} chatId
* @param {Object} opts
* @param {string} opts.jobName
* @param {string} opts.serviceName
* @param {string} opts.baseUrl
* @param {boolean} opts.plainText
* @param {number|undefined} opts.message_thread_id
* @returns {Promise<void>}
*/
async function sendListingToChat(
throttledCall,
listing,
chatId,
{ jobName, serviceName, baseUrl, plainText, message_thread_id },
) {
const img = normalizeImageUrl(listing.image);
const textPayload = {
chat_id: chatId,
text: plainText
? buildPlainText(jobName, serviceName, listing, baseUrl)
: buildHtmlBody(jobName, serviceName, listing, baseUrl),
...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
if (!img) {
return throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
const caption = plainText
? buildPlainCaption(jobName, serviceName, listing, baseUrl)
: buildHtmlBody(jobName, serviceName, listing, baseUrl).slice(0, 1024);
const parseMode = plainText ? undefined : 'HTML';
// .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with
// "failed to get HTTP URL content". Upload the bytes via multipart instead.
const photoCall = shouldUseMultipart(img)
? buildPhotoFormData({ chatId, imageUrl: img, caption, parseMode, messageThreadId: message_thread_id }).then((fd) =>
throttledCall('sendPhoto', fd),
)
: throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption,
...(parseMode ? { parse_mode: parseMode } : {}),
...(message_thread_id ? { message_thread_id } : {}),
});
return photoCall.catch(async (e) => {
logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
throw e;
});
});
}
/** /**
* Send new listings to Telegram. * Send new listings to Telegram.
* - Respects per-chat Telegram rate limits using a lightweight throttle cache. * - Respects per-chat Telegram rate limits using a lightweight throttle cache.
@@ -161,6 +240,11 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config"); throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
} }
const chatIds = String(chatId)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Optional Telegram topic/thread support (supergroups) // Optional Telegram topic/thread support (supergroups)
let message_thread_id; let message_thread_id;
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') { if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
@@ -177,70 +261,16 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
const job = getJob(jobKey); const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
// FormData (multipart) vs JSON. node-fetch sets its own multipart boundary
// header, so we must NOT supply Content-Type ourselves in that case.
const isFormData = body instanceof FormData;
const opts = isFormData
? { method: 'post', body }
: { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } };
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, opts);
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
}
return res;
});
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]); if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
const promises = newListings.map(async (o) => { const allPromises = chatIds.flatMap((id) => {
const img = normalizeImageUrl(o.image); const caller = makeTelegramCaller(token, jobName);
const textPayload = { const throttledCall = getThrottled(id, caller);
chat_id: chatId, const opts = { jobName, serviceName, baseUrl, plainText, message_thread_id };
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl), return newListings.map((listing) => sendListingToChat(throttledCall, listing, id, opts));
...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
if (!img) {
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
const caption = plainText
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
: buildCaption(jobName, serviceName, o, baseUrl);
const parseMode = plainText ? undefined : 'HTML';
// .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with
// "failed to get HTTP URL content". Upload the bytes via multipart instead;
// the rendered chat message is identical.
const photoCall = shouldUseMultipart(img)
? buildPhotoFormData({ chatId, imageUrl: img, caption, parseMode, messageThreadId: message_thread_id }).then(
(fd) => throttledCall('sendPhoto', fd),
)
: throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption,
...(parseMode ? { parse_mode: parseMode } : {}),
...(message_thread_id ? { message_thread_id } : {}),
});
return await photoCall.catch(async (e) => {
logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return await throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
throw e;
});
});
}); });
return Promise.all(promises); return Promise.all(allPromises);
}; };
/** /**
@@ -261,7 +291,8 @@ export const config = {
chatId: { chatId: {
type: 'chatId', type: 'chatId',
label: 'Chat Id', label: 'Chat Id',
description: 'The chat id to send messages to you.', description:
'The chat ID to send messages to. Separate multiple IDs with commas to notify several recipients (e.g. 123456789, 987654321).',
}, },
messageThreadId: { messageThreadId: {
type: 'text', type: 'text',

View File

@@ -21,6 +21,8 @@ Steps:
- Private chats: `chat.id` is a positive number - Private chats: `chat.id` is a positive number
- Groups/supergroups: `chat.id` is a negative number - Groups/supergroups: `chat.id` is a negative number
**Multiple recipients:** To notify several users individually, enter a comma-separated list of chat IDs in the Chat Id field, e.g. `123456789, 987654321`. Each recipient receives the same messages and gets its own independent rate-limit window. This avoids having to create a group and add the bot to it.
Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bots privacy settings allow it to see group messages when used in groups. Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bots privacy settings allow it to see group messages when used in groups.
#### Getting the thread ID (this is optional to be used for forum topics) #### Getting the thread ID (this is optional to be used for forum topics)

View File

@@ -4,6 +4,7 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import logger from '../services/logger.js';
const path = './adapter'; const path = './adapter';
/** Read every integration existing in ./adapter **/ /** Read every integration existing in ./adapter **/
@@ -23,7 +24,13 @@ const findAdapter = (notificationAdapter) => {
export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => { export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => {
//this is not being used in tests, therefore adapter are always set //this is not being used in tests, therefore adapter are always set
return notificationConfig return notificationConfig
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null) .map((notificationAdapter) => {
.map((notificationAdapter) => findAdapter(notificationAdapter)) const found = findAdapter(notificationAdapter);
if (!found) {
logger.warn(`Notification adapter '${notificationAdapter.id}' not found for job '${jobKey || ''}'`);
}
return found;
})
.filter(Boolean)
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })); .map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl }));
}; };

View File

@@ -105,14 +105,11 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
return; return;
} }
settings.lastRun = now; settings.lastRun = now;
const jobs = jobStorage const jobs = jobStorage.getJobs().filter((job) => {
.getJobs() if (!context) return true; // startup/cron → all
.filter((job) => job.enabled) if (context.isAdmin) return true; // admin → all
.filter((job) => { return context.userId ? job.userId === context.userId : false; // user → own
if (!context) return true; // startup/cron → all });
if (context.isAdmin) return true; // admin → all
return context.userId ? job.userId === context.userId : false; // user → own
});
for (const job of jobs) { for (const job of jobs) {
await executeJob(job); await executeJob(job);

View File

@@ -16,7 +16,7 @@ import logger from '../../services/logger.js';
* Concurrency: network-bound checks are executed with a configurable concurrency limit. * Concurrency: network-bound checks are executed with a configurable concurrency limit.
* *
* @param {object} [opts] * @param {object} [opts]
* @param {number} [opts.concurrency=8] Max number of parallel activeTester calls. * @param {number} [opts.concurrency=4] Max number of parallel activeTester calls.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export default async function runActiveChecker(opts = {}) { export default async function runActiveChecker(opts = {}) {

View File

@@ -3,10 +3,27 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { nullOrEmpty } from '../../utils.js'; import { nullOrEmpty, fromJson } from '../../utils.js';
import SqliteConnection from './SqliteConnection.js'; import SqliteConnection from './SqliteConnection.js';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
/**
* Parse the JSON `status` column of a listing row in place.
*
* The DB stores status as a JSON payload `{ status, setAt }` (or NULL).
* Consumers expect an object/null, so we normalize before returning.
*
* @param {Object|null|undefined} row - A raw row from the listings table.
* @returns {Object|null|undefined} The same row with `status` parsed.
*/
const parseListingStatus = (row) => {
if (row == null) return row;
if (typeof row.status === 'string') {
row.status = fromJson(row.status, null);
}
return row;
};
/** /**
* Return a list of known listing hashes for a given job and provider. * Return a list of known listing hashes for a given job and provider.
* Useful to de-duplicate before inserting new listings. * Useful to de-duplicate before inserting new listings.
@@ -43,18 +60,14 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
const placeholders = jobIds.map(() => '?').join(','); const placeholders = jobIds.map(() => '?').join(',');
const rows = SqliteConnection.query( const rows = SqliteConnection.query(
`SELECT `SELECT is_active, price
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) OVER() AS active_count, FROM listings
price WHERE job_id IN (${placeholders})
FROM listings AND manually_deleted = 0`,
WHERE job_id IN (${placeholders})
AND manually_deleted = 0
GROUP BY
id`,
jobIds, jobIds,
); );
const activeCount = rows[0]?.active_count ?? 0; const activeCount = rows.filter((r) => r.is_active === 1).length;
const prices = rows const prices = rows
.map((r) => r.price) .map((r) => r.price)
@@ -244,6 +257,7 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {object} [params.jobNameFilter] * @param {object} [params.jobNameFilter]
* @param {object} [params.providerFilter] * @param {object} [params.providerFilter]
* @param {object} [params.watchListFilter] * @param {object} [params.watchListFilter]
* @param {('applied'|'rejected'|'accepted'|'none')} [params.statusFilter] - Filter by listing status. 'none' matches NULL.
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'. * @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
* @param {('asc'|'desc')} [params.sortDir='asc'] * @param {('asc'|'desc')} [params.sortDir='asc']
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms). * @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
@@ -260,6 +274,7 @@ export const queryListings = ({
jobIdFilter, jobIdFilter,
providerFilter, providerFilter,
watchListFilter, watchListFilter,
statusFilter,
freeTextFilter, freeTextFilter,
sortField = null, sortField = null,
sortDir = 'asc', sortDir = 'asc',
@@ -289,13 +304,15 @@ export const queryListings = ({
} }
if (freeTextFilter && String(freeTextFilter).trim().length > 0) { if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
params.filter = `%${String(freeTextFilter).trim()}%`; params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`); whereParts.push(
`(l.title LIKE @filter OR l.address LIKE @filter OR l.provider LIKE @filter OR l.link LIKE @filter)`,
);
} }
// activityFilter: when true -> only active listings (is_active = 1), false -> only inactive // activityFilter: when true -> only active listings (is_active = 1), false -> only inactive
if (activityFilter === true) { if (activityFilter === true) {
whereParts.push('(is_active = 1)'); whereParts.push('(l.is_active = 1)');
} else if (activityFilter === false) { } else if (activityFilter === false) {
whereParts.push('(is_active = 0)'); whereParts.push('(l.is_active = 0)');
} }
// Prefer filtering by job id when provided (unambiguous and robust) // Prefer filtering by job id when provided (unambiguous and robust)
if (jobIdFilter && String(jobIdFilter).trim().length > 0) { if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
@@ -309,7 +326,7 @@ export const queryListings = ({
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match) // providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
if (providerFilter && String(providerFilter).trim().length > 0) { if (providerFilter && String(providerFilter).trim().length > 0) {
params.providerName = String(providerFilter).trim(); params.providerName = String(providerFilter).trim();
whereParts.push('(provider = @providerName)'); whereParts.push('(l.provider = @providerName)');
} }
// watchListFilter: when true -> only watched listings, false -> only unwatched // watchListFilter: when true -> only watched listings, false -> only unwatched
if (watchListFilter === true) { if (watchListFilter === true) {
@@ -317,14 +334,26 @@ export const queryListings = ({
} else if (watchListFilter === false) { } else if (watchListFilter === false) {
whereParts.push('(wl.id IS NULL)'); whereParts.push('(wl.id IS NULL)');
} }
// statusFilter: 'applied'|'rejected'|'accepted' -> equality on JSON status field; 'none' -> NULL.
// The status column is a JSON payload `{ status, setAt }`, so we extract the inner
// status string for comparison instead of matching the raw text.
if (statusFilter === 'none') {
whereParts.push('(l.status IS NULL)');
} else if (
typeof statusFilter === 'string' &&
['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase())
) {
params.statusValue = statusFilter.toLowerCase();
whereParts.push(`(json_extract(l.status, '$.status') = @statusValue)`);
}
// Time range filters (unix timestamps in milliseconds) // Time range filters (unix timestamps in milliseconds)
if (Number.isFinite(createdAfter) && createdAfter > 0) { if (Number.isFinite(createdAfter) && createdAfter > 0) {
params.createdAfter = createdAfter; params.createdAfter = createdAfter;
whereParts.push('(created_at >= @createdAfter)'); whereParts.push('(l.created_at >= @createdAfter)');
} }
if (Number.isFinite(createdBefore) && createdBefore > 0) { if (Number.isFinite(createdBefore) && createdBefore > 0) {
params.createdBefore = createdBefore; params.createdBefore = createdBefore;
whereParts.push('(created_at <= @createdBefore)'); whereParts.push('(l.created_at <= @createdBefore)');
} }
// Price range filters // Price range filters
if (Number.isFinite(minPrice) && minPrice >= 0) { if (Number.isFinite(minPrice) && minPrice >= 0) {
@@ -339,32 +368,22 @@ export const queryListings = ({
// Build whereSql (filtering by manually_deleted = 0) // Build whereSql (filtering by manually_deleted = 0)
whereParts.push('(l.manually_deleted = 0)'); whereParts.push('(l.manually_deleted = 0)');
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
.replace(/\bdescription\b/g, 'l.description')
.replace(/\baddress\b/g, 'l.address')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\blink\b/g, 'l.link')
.replace(/\bis_active\b/g, 'l.is_active')
.replace(/\bj\.user_id\b/g, 'j.user_id')
.replace(/\bj\.name\b/g, 'j.name')
.replace(/\bwl\.id\b/g, 'wl.id');
// whitelist sortable fields to avoid SQL injection // whitelist sortable fields to avoid SQL injection; map to fully-qualified expressions
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']); const sortableMap = {
const safeSortField = sortField && sortable.has(sortField) ? sortField : null; created_at: 'l.created_at',
price: 'l.price',
size: 'l.size',
provider: 'l.provider',
title: 'l.title',
job_name: 'j.name',
is_active: 'l.is_active',
isWatched: 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END',
};
const safeSortExpr = sortField && sortableMap[sortField] ? sortableMap[sortField] : null;
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC'; const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC'; const orderSqlWithAlias = safeSortExpr ? `ORDER BY ${safeSortExpr} ${safeSortDir}` : 'ORDER BY l.created_at DESC';
const orderSqlWithAlias = orderSql
.replace(/\bcreated_at\b/g, 'l.created_at')
.replace(/\bprice\b/g, 'l.price')
.replace(/\bsize\b/g, 'l.size')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\btitle\b/g, 'l.title')
.replace(/\bjob_name\b/g, 'j.name')
// Sort by computed watch flag when requested
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
// count total with same WHERE // count total with same WHERE
const countRow = SqliteConnection.query( const countRow = SqliteConnection.query(
@@ -391,7 +410,7 @@ export const queryListings = ({
params, params,
); );
return { totalNumber, page: safePage, result: rows }; return { totalNumber, page: safePage, result: rows.map(parseListingStatus) };
}; };
/** /**
@@ -485,7 +504,7 @@ export const updateListingGeocoordinates = (id, latitude, longitude) => {
* @param {string} [params.jobId] * @param {string} [params.jobId]
* @param {string} [params.userId] * @param {string} [params.userId]
* @param {boolean} [params.isAdmin=false] * @param {boolean} [params.isAdmin=false]
* @returns {{listings: Object[], maxPrice: number}} Object containing listings and maxPrice. * @returns {{listings: Object[]}} Object containing listings.
*/ */
export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}) => { export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}) => {
const baseWhereParts = [ const baseWhereParts = [
@@ -626,7 +645,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
if (!isAdmin) { if (!isAdmin) {
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`; whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
} }
return ( return parseListingStatus(
SqliteConnection.query( SqliteConnection.query(
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched `SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l FROM listings l
@@ -634,10 +653,57 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`, WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
params, params,
)[0] || null )[0] || null,
); );
}; };
/**
* Set or clear the notes attached to a single listing.
*
* Empty strings are normalized to NULL so the DB doesn't keep meaningless
* whitespace and queries can filter "has notes" with a simple IS NOT NULL.
*
* @param {string} id - The listing ID.
* @param {string|null} notes - The note text to store, or null/empty to clear.
* @returns {number} Number of rows affected (0 if listing not found).
*/
export const setListingNotes = (id, notes) => {
if (!id) return 0;
const trimmed = typeof notes === 'string' ? notes.trim() : null;
const value = trimmed && trimmed.length > 0 ? trimmed : null;
const res = SqliteConnection.execute(`UPDATE listings SET notes = @notes WHERE id = @id`, {
id,
notes: value,
});
return res?.changes ?? 0;
};
/**
* Set or clear the status of a single listing.
*
* The status column stores a JSON payload `{ status, setAt }` so consumers
* can show both the user's decision and when it was made. Passing `null`
* clears the column.
*
* @param {string} id - The listing ID.
* @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear.
* @returns {number} Number of rows affected (0 if listing not found).
*/
export const setListingStatus = (id, status) => {
if (!id) return 0;
const allowed = ['applied', 'rejected', 'accepted'];
const normalized = status == null ? null : String(status).toLowerCase();
if (normalized != null && !allowed.includes(normalized)) {
throw new Error(`Invalid listing status: ${status}`);
}
const payload = normalized == null ? null : JSON.stringify({ status: normalized, setAt: Date.now() });
const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, {
id,
status: payload,
});
return res?.changes ?? 0;
};
/** /**
* Resets geocoordinates and distance for all listings related to a user. * Resets geocoordinates and distance for all listings related to a user.
* *

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN status JSON;
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (json_extract(status, '$.status'));
`);
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN notes TEXT;
`);
}

View File

@@ -123,8 +123,11 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
); );
} }
} }
// keep cache in sync (only for global settings) // Invalidate cache synchronously so the next getSettings() call rebuilds it.
// refreshSettingsCache() is async (reads config.json), so we cannot await it
// here without making upsertSettings async everywhere. Nulling is safe because
// getSettings() will call refreshSettingsCache() on the next invocation.
if (userId == null) { if (userId == null) {
refreshSettingsCache(); cachedSettingsConfig = null;
} }
} }

View File

@@ -47,6 +47,25 @@ export const deleteWatch = (listingId, userId) => {
return { deleted: Boolean(res?.changes) }; return { deleted: Boolean(res?.changes) };
}; };
/**
* Ensure a watch entry exists. Does not toggle; safe to call when row may already exist.
* Used by the status endpoint to auto-watch a listing when a status is set.
* @param {string} listingId
* @param {string} userId
* @returns {{watched:boolean}}
*/
export const ensureWatch = (listingId, userId) => {
if (!listingId || !userId) return { watched: false };
const { created } = createWatch(listingId, userId);
if (created) return { watched: true };
const exists =
SqliteConnection.query(
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
{ listing_id: listingId, user_id: userId },
).length > 0;
return { watched: exists };
};
/** /**
* Toggle a watch entry. If exists -> delete, otherwise create. * Toggle a watch entry. If exists -> delete, otherwise create.
* @param {string} listingId * @param {string} listingId

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "22.2.2", "version": "22.3.3",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",

View File

@@ -335,6 +335,61 @@ describe('telegram send() - mixed batch (regression-safety)', () => {
}); });
}); });
describe('telegram send() - multiple chat IDs', () => {
const listing = {
id: '1',
title: 'Flat',
link: 'https://ex.com',
address: 'Berlin',
price: '800',
size: '50',
image: 'https://ex.com/img.jpg',
};
it('sends to every chat ID in a comma-separated list', async () => {
mockNodeFetch.mockResolvedValue(jsonOk());
await send({
serviceName: 'immoscout',
newListings: [listing],
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '111, 222' } }],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body));
expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['111', '222']));
});
it('trims whitespace around each chat ID', async () => {
mockNodeFetch.mockResolvedValue(jsonOk());
await send({
serviceName: 'immoscout',
newListings: [listing],
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: ' 333 , 444 ' } }],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body));
expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['333', '444']));
});
it('sends each listing to each chat ID (N listings × M chats)', async () => {
mockNodeFetch.mockResolvedValue(jsonOk());
await send({
serviceName: 'immoscout',
newListings: [listing, { ...listing, id: '2' }],
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '555, 666' } }],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(4);
});
});
describe('telegram send() - config validation', () => { describe('telegram send() - config validation', () => {
it('throws when telegram adapter config is missing', () => { it('throws when telegram adapter config is missing', () => {
expect(() => expect(() =>

View File

@@ -0,0 +1,193 @@
/*
* 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';
// We mock SqliteConnection so we can assert which SQL the storage layer
// runs and with which params, without spinning up a real SQLite DB.
const calls = {
execute: [],
query: [],
};
const sqliteMock = {
execute: (sql, params) => {
calls.execute.push({ sql, params });
// Default: pretend 1 row was affected (so setListingStatus reports success).
return { changes: 1 };
},
query: (sql, params) => {
calls.query.push({ sql, params });
// Return shape varies by test — overridden via queryHandler when needed.
if (sqliteMock.__queryHandler) return sqliteMock.__queryHandler(sql, params);
return [];
},
__queryHandler: null,
};
vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
default: sqliteMock,
}));
describe('listingsStorage.setListingStatus', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('runs an UPDATE storing a JSON payload with status and setAt', () => {
const before = Date.now();
const changes = listingsStorage.setListingStatus('listing-1', 'Applied');
const after = Date.now();
expect(changes).toBe(1);
expect(calls.execute).toHaveLength(1);
expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/);
expect(calls.execute[0].params.id).toBe('listing-1');
const parsed = JSON.parse(calls.execute[0].params.status);
expect(parsed.status).toBe('applied');
expect(parsed.setAt).toBeGreaterThanOrEqual(before);
expect(parsed.setAt).toBeLessThanOrEqual(after);
});
it('accepts null to clear the status (no JSON wrapping)', () => {
listingsStorage.setListingStatus('listing-2', null);
expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null });
});
it('rejects invalid statuses', () => {
expect(() => listingsStorage.setListingStatus('listing-3', 'maybe')).toThrow(/Invalid listing status/);
expect(calls.execute).toHaveLength(0);
});
it('returns 0 when no id is supplied (no SQL is run)', () => {
const result = listingsStorage.setListingStatus(null, 'applied');
expect(result).toBe(0);
expect(calls.execute).toHaveLength(0);
});
});
describe('listingsStorage.queryListings statusFilter', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
// Return empty rows for both the count and the page-fetch queries.
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
return [];
};
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it("adds 'l.status IS NULL' to WHERE when statusFilter is 'none'", () => {
listingsStorage.queryListings({ statusFilter: 'none', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/);
});
it('extracts the inner status field via json_extract for a concrete status', () => {
listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/json_extract\(l\.status, '\$\.status'\) = @statusValue/);
expect(pageQuery.params.statusValue).toBe('applied');
});
it('ignores unknown statusFilter values silently', () => {
listingsStorage.queryListings({ statusFilter: 'bogus', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).not.toMatch(/status/i);
});
it('parses the JSON status payload of returned rows into an object', () => {
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 2 }];
return [
{ id: 'a', status: JSON.stringify({ status: 'applied', setAt: 1700000000000 }) },
{ id: 'b', status: null },
];
};
const result = listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
expect(result.result[0].status).toEqual({ status: 'applied', setAt: 1700000000000 });
expect(result.result[1].status).toBeNull();
});
});
describe('listingsStorage.getListingById', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('parses the JSON status payload of the returned row', () => {
sqliteMock.__queryHandler = () => [
{ id: 'a', status: JSON.stringify({ status: 'rejected', setAt: 1700000000001 }) },
];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toEqual({ status: 'rejected', setAt: 1700000000001 });
});
it('returns null status untouched', () => {
sqliteMock.__queryHandler = () => [{ id: 'a', status: null }];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toBeNull();
});
it('returns null when no row is found', () => {
sqliteMock.__queryHandler = () => [];
const row = listingsStorage.getListingById('missing', 'u1', true);
expect(row).toBeNull();
});
});
describe('watchListStorage.ensureWatch', () => {
let watchListStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
watchListStorage = await import('../../lib/services/storage/watchListStorage.js');
});
it('inserts and reports watched=true on first call', () => {
// After INSERT, createWatch queries for existence and gets a row back.
sqliteMock.__queryHandler = () => [{ ok: 1 }];
const result = watchListStorage.ensureWatch('listing-1', 'user-1');
expect(result).toEqual({ watched: true });
// INSERT should have been issued.
expect(calls.execute.some((c) => /INSERT INTO watch_list/.test(c.sql))).toBe(true);
});
it('returns watched=true when an entry already exists', () => {
// Simulate ON CONFLICT being a no-op: execute reports no changes, then SELECT confirms row exists.
sqliteMock.execute = (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 0 };
};
sqliteMock.__queryHandler = () => [{ ok: 1 }];
const result = watchListStorage.ensureWatch('listing-2', 'user-2');
expect(result).toEqual({ watched: true });
// Restore execute to default for subsequent tests.
sqliteMock.execute = (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 1 };
};
});
it('returns watched=false when listingId or userId is missing', () => {
expect(watchListStorage.ensureWatch(null, 'u')).toEqual({ watched: false });
expect(watchListStorage.ensureWatch('l', null)).toEqual({ watched: false });
expect(calls.execute).toHaveLength(0);
});
});

View File

@@ -97,6 +97,7 @@ export default function FredyApp() {
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} /> <Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} /> <Route path="/listings" element={<Listings />} />
<Route path="/listings/watchlist" element={<Listings mode="watchlist" />} />
<Route path="/listings/listing/:listingId" element={<ListingDetail />} /> <Route path="/listings/listing/:listingId" element={<ListingDetail />} />
<Route path="/map" element={<MapView />} /> <Route path="/map" element={<MapView />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} /> <Route path="/watchlistManagement" element={<WatchlistManagement />} />

View File

@@ -296,7 +296,7 @@ const JobGrid = () => {
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{job.isOnlyShared && ( {job.isOnlyShared && (
<Popover content={getPopoverContent('This job has been shared with you read only.')}> <Popover content={getPopoverContent('This job has been shared with you - read only.')}>
<div> <div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} /> <IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</div> </div>

View File

@@ -16,13 +16,14 @@ import {
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import no_image from '../../../assets/no_image.png'; import no_image from '../../../assets/no_image.png';
import * as timeService from '../../../services/time/timeService.js'; import * as timeService from '../../../services/time/timeService.js';
import StatusControl from '../../listings/StatusControl.jsx';
import './ListingsGrid.less'; import './ListingsGrid.less';
/** /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
*/ */
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => ( const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
<div className="listingsGrid__grid"> <div className="listingsGrid__grid">
{listings.map((item) => ( {listings.map((item) => (
<div <div
@@ -49,14 +50,16 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
<span>Inactive</span> <span>Inactive</span>
</div> </div>
)} )}
<button <Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
type="button" <button
className="listingsGrid__card__star" type="button"
onClick={(e) => onWatch(e, item)} className="listingsGrid__card__star"
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'} onClick={(e) => onWatch(e, item)}
> aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />} >
</button> {item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
</div> </div>
<div className="listingsGrid__card__body"> <div className="listingsGrid__card__body">
@@ -83,6 +86,12 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
</div> </div>
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}> <div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<StatusControl
status={item.status?.status ?? null}
compact
onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()}
/>
<Tooltip content="Original Listing"> <Tooltip content="Original Listing">
<Button <Button
size="small" size="small"

View File

@@ -11,12 +11,11 @@
border: 1px solid @color-border !important; border: 1px solid @color-border !important;
border-radius: @radius-card !important; border-radius: @radius-card !important;
overflow: hidden; overflow: hidden;
transition: transform @transition-card, box-shadow @transition-card; transition: box-shadow @transition-card;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
&:hover { &:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6); box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
} }

View File

@@ -23,7 +23,8 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
import './ListingsOverview.less'; import './ListingsOverview.less';
const ListingsOverview = () => { const ListingsOverview = ({ mode = 'all' }) => {
const isWatchlistMode = mode === 'watchlist';
const listingsData = useSelector((state) => state.listingsData); const listingsData = useSelector((state) => state.listingsData);
const providers = useSelector((state) => state.provider); const providers = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobsData.jobs); const jobs = useSelector((state) => state.jobsData.jobs);
@@ -46,9 +47,13 @@ const ListingsOverview = () => {
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString); const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean); const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString); const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null); const [listingToDelete, setListingToDelete] = useState(null);
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
const loadData = () => { const loadData = () => {
actions.listingsData.getListingsData({ actions.listingsData.getListingsData({
page, page,
@@ -56,13 +61,30 @@ const ListingsOverview = () => {
sortfield: sortField, sortfield: sortField,
sortdir: sortDir, sortdir: sortDir,
freeTextFilter, freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter }, filter: {
watchListFilter: effectiveWatchListFilter,
jobNameFilter,
activityFilter,
providerFilter,
statusFilter,
},
}); });
}; };
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]); }, [
page,
sortField,
sortDir,
freeTextFilter,
providerFilter,
activityFilter,
jobNameFilter,
watchListFilter,
statusFilter,
isWatchlistMode,
]);
const handleFilterChange = useMemo( const handleFilterChange = useMemo(
() => () =>
@@ -92,6 +114,17 @@ const ListingsOverview = () => {
} }
}; };
const handleStatusChange = async (item, nextStatus) => {
try {
await actions.listingsData.setListingStatus(item.id, nextStatus);
Toast.success(nextStatus ? `Marked as ${nextStatus}` : 'Status cleared');
loadData();
} catch (e) {
console.error(e);
Toast.error('Failed to update status');
}
};
const handleDelete = (id) => { const handleDelete = (id) => {
if (listingDeletionPref?.skipPrompt) { if (listingDeletionPref?.skipPrompt) {
confirmDeletion(listingDeletionPref.hardDelete, false, id); confirmDeletion(listingDeletionPref.hardDelete, false, id);
@@ -148,20 +181,38 @@ const ListingsOverview = () => {
<Radio value="false">Inactive</Radio> <Radio value="false">Inactive</Radio>
</RadioGroup> </RadioGroup>
<RadioGroup {!isWatchlistMode && (
type="button" <RadioGroup
buttonSize="middle" type="button"
value={watchListFilter === null ? 'all' : String(watchListFilter)} buttonSize="middle"
onChange={(e) => { value={watchListFilter === null ? 'all' : String(watchListFilter)}
const v = e.target.value; onChange={(e) => {
setWatchListFilter(v === 'all' ? null : v === 'true'); const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
)}
<Select
placeholder="Status"
showClear
onChange={(val) => {
setStatusFilter(val ?? null);
setPage(1); setPage(1);
}} }}
value={statusFilter}
style={{ width: 150 }}
> >
<Radio value="all">All</Radio> <Select.Option value="applied">Applied</Select.Option>
<Radio value="true">Watched</Radio> <Select.Option value="rejected">Rejected</Select.Option>
<Radio value="false">Unwatched</Radio> <Select.Option value="accepted">Accepted</Select.Option>
</RadioGroup> <Select.Option value="none">No status</Select.Option>
</Select>
<Select <Select
placeholder="Provider" placeholder="Provider"
@@ -197,7 +248,13 @@ const ListingsOverview = () => {
))} ))}
</Select> </Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}> <Select
prefix="Sort by"
className="listingsOverview__topbar__sort"
style={{ width: 220 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="job_name">Job Name</Select.Option> <Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option> <Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option> <Select.Option value="price">Price</Select.Option>
@@ -241,9 +298,21 @@ const ListingsOverview = () => {
)} )}
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<ListingsGrid listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} /> <ListingsGrid
listings={listings}
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
/>
) : ( ) : (
<ListingsTable listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} /> <ListingsTable
listings={listings}
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
/>
)} )}
{listings.length > 0 && ( {listings.length > 0 && (

View File

@@ -19,6 +19,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
&__sort {
flex-shrink: 0;
.semi-select-prefix {
white-space: nowrap;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.listingsOverview__topbar__search { .listingsOverview__topbar__search {
width: 100%; width: 100%;

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useState } from 'react';
import { Dropdown, Button, Tooltip } from '@douyinfe/semi-ui-19';
import { IconChevronDown } from '@douyinfe/semi-icons';
import './StatusControl.less';
const STATUS_TOOLTIP =
'Track where you stand with this listing: Applied once you have reached out, Rejected if it did not work out, or Accepted if you got it.';
/**
* @typedef {('applied'|'rejected'|'accepted'|null)} ListingStatus
*/
const STATUS_OPTIONS = [
{ value: null, label: 'None' },
{ value: 'applied', label: 'Applied' },
{ value: 'rejected', label: 'Rejected' },
{ value: 'accepted', label: 'Accepted' },
];
/**
* Look up the option metadata for a status value.
* @param {ListingStatus} status
*/
const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ?? STATUS_OPTIONS[0];
/**
* Shared control for setting a listing's user-decision status
* (Applied / Rejected / Accepted).
*
* Both compact (table/grid rows) and full (listing detail header) modes
* render a Button that picks up the project's CI tokens via the
* .status-btn classes, with a small size variant for compact contexts.
*
* @param {Object} props
* @param {ListingStatus} props.status - The current status value.
* @param {(next: ListingStatus) => void} props.onChange - Called with the new status when the user picks one.
* @param {boolean} [props.compact=false] - When true, renders smaller for table/grid rows; full size otherwise.
* @param {(e: React.MouseEvent) => void} [props.onTriggerClick] - Optional click handler to stop propagation on the trigger.
*/
export default function StatusControl({ status = null, onChange, compact = false, onTriggerClick }) {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const current = optionFor(status);
const handlePick = (next) => {
setOpen(false);
if (next === status) return;
onChange?.(next);
};
const menu = (
<Dropdown.Menu>
{STATUS_OPTIONS.map((opt) => (
<Dropdown.Item
key={opt.value ?? '__none__'}
active={opt.value === status}
onClick={() => handlePick(opt.value)}
>
{opt.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
);
const className = ['status-btn', compact ? 'status-btn--compact' : null, status ? `status-btn--${status}` : null]
.filter(Boolean)
.join(' ');
const trigger = (
<Tooltip
content={STATUS_TOOLTIP}
position="top"
trigger="custom"
visible={tooltipOpen && !open}
onVisibleChange={setTooltipOpen}
>
<Button
size={compact ? 'small' : 'default'}
theme="borderless"
icon={<IconChevronDown />}
iconPosition="right"
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
onClick={(e) => {
onTriggerClick?.(e);
setTooltipOpen(false);
setOpen((o) => !o);
}}
className={className}
>
{status ? current.label : 'Status'}
</Button>
</Tooltip>
);
return (
<Dropdown
trigger="custom"
visible={open}
onVisibleChange={setOpen}
onClickOutSide={() => setOpen(false)}
position="bottom"
render={menu}
stopPropagation
>
<span className="status-btn__anchor">{trigger}</span>
</Dropdown>
);
}

View File

@@ -0,0 +1,64 @@
@import '../../tokens.less';
// Wrapper span used as the Dropdown's positioning anchor so the menu opens
// directly below the visible button rather than the implicit wrapper of the
// hover tooltip (which can have a different bounding box).
.status-btn__anchor {
display: inline-block;
line-height: 0;
}
// StatusControl shared base. Matches dimensions and border treatment
// of the surrounding Watched / Open listing / Delete buttons in the
// detail view, and shrinks via the --compact modifier for table rows
// and grid cards.
.status-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-btn !important;
background: transparent !important;
transition: color @transition-fast, border-color @transition-fast, background @transition-fast;
&:hover {
color: @color-text !important;
background: rgba(255, 255, 255, 0.06) !important;
}
&--compact {
height: 24px !important;
padding: 0 8px !important;
font-size: @text-sm !important;
border-radius: @radius-chip !important;
.semi-icon {
font-size: 12px !important;
}
}
&--applied {
color: @color-info !important;
border-color: rgba(96, 165, 250, 0.4) !important;
background: rgba(96, 165, 250, 0.08) !important;
&:hover {
background: rgba(96, 165, 250, 0.14) !important;
}
}
&--rejected {
color: @color-error !important;
border-color: rgba(251, 113, 133, 0.4) !important;
background: rgba(251, 113, 133, 0.08) !important;
&:hover {
background: rgba(251, 113, 133, 0.14) !important;
}
}
&--accepted {
color: @color-success !important;
border-color: rgba(52, 211, 153, 0.4) !important;
background: rgba(52, 211, 153, 0.08) !important;
&:hover {
background: rgba(52, 211, 153, 0.14) !important;
}
}
}

View File

@@ -112,9 +112,9 @@
.semi-navigation-item { .semi-navigation-item {
border-radius: @radius-btn !important; border-radius: @radius-btn !important;
border: 1px solid transparent !important;
color: @color-muted !important; color: @color-muted !important;
transition: background @transition-fast, color @transition-fast !important; transition: background @transition-fast, color @transition-fast, border-color @transition-fast !important;
margin: 2px 8px !important;
&:hover { &:hover {
color: @color-text !important; color: @color-text !important;
@@ -123,7 +123,7 @@
&.semi-navigation-item-selected, &.semi-navigation-item-selected,
&[aria-selected="true"] { &[aria-selected="true"] {
background: rgba(224,74,56,0.12) !important; background: rgba(224,74,56,0.12) !important;
border: 1px solid rgba(224,74,56,0.25) !important; border-color: rgba(224,74,56,0.25) !important;
color: @color-text !important; color: @color-text !important;
.semi-navigation-item-icon { .semi-navigation-item-icon {

View File

@@ -37,6 +37,7 @@ export default function Navigation({ isAdmin }) {
items: [ items: [
{ itemKey: '/listings', text: 'Overview' }, { itemKey: '/listings', text: 'Overview' },
{ itemKey: '/map', text: 'Map View' }, { itemKey: '/map', text: 'Map View' },
{ itemKey: '/listings/watchlist', text: 'Watchlist' },
], ],
}, },
]; ];
@@ -61,6 +62,22 @@ export default function Navigation({ isAdmin }) {
} }
function parsePathName(name) { function parsePathName(name) {
// Collect every leaf itemKey that looks like a route (starts with '/').
// Prefer the longest exact-prefix match so nested routes like
// '/listings/watchlist' resolve to themselves instead of being collapsed
// to '/listings'.
const allKeys = [];
const collect = (nodes) => {
for (const n of nodes) {
if (typeof n.itemKey === 'string' && n.itemKey.startsWith('/')) allKeys.push(n.itemKey);
if (Array.isArray(n.items)) collect(n.items);
}
};
collect(items);
const longestMatch = allKeys
.filter((k) => name === k || name.startsWith(k + '/'))
.sort((a, b) => b.length - a.length)[0];
if (longestMatch) return longestMatch;
const split = name.split('/').filter((s) => s.length !== 0); const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0]; return '/' + split[0];
} }

View File

@@ -63,7 +63,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
</Tag> </Tag>
)} )}
{job.isOnlyShared && ( {job.isOnlyShared && (
<Tooltip content="Shared with you read only"> <Tooltip content="Shared with you - read only">
<span style={{ display: 'flex', alignItems: 'center' }}> <span style={{ display: 'flex', alignItems: 'center' }}>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} /> <IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</span> </span>

View File

@@ -6,7 +6,6 @@
import { Button, Tooltip } from '@douyinfe/semi-ui-19'; import { Button, Tooltip } from '@douyinfe/semi-ui-19';
import { import {
IconBriefcase, IconBriefcase,
IconCart,
IconDelete, IconDelete,
IconLink, IconLink,
IconMapPin, IconMapPin,
@@ -15,14 +14,16 @@ import {
IconEyeOpened, IconEyeOpened,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.png'; import no_image from '../../assets/no_image.png';
import { formatEuroPrice } from '../../services/price/priceService.js';
import * as timeService from '../../services/time/timeService.js'; import * as timeService from '../../services/time/timeService.js';
import StatusControl from '../listings/StatusControl.jsx';
import './ListingsTable.less'; import './ListingsTable.less';
/** /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
*/ */
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => ( const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
<div className="listingsTable"> <div className="listingsTable">
{listings.map((item) => ( {listings.map((item) => (
<div <div
@@ -51,12 +52,9 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsTable__row__price"> <div className="listingsTable__row__price">
{item.price ? ( {item.price ? (
<> formatEuroPrice(item.price)
<IconCart size="small" />
{item.price}
</>
) : ( ) : (
<span className="listingsTable__row__empty"></span> <span className="listingsTable__row__empty">---</span>
)} )}
</div> </div>
@@ -67,7 +65,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
{item.address} {item.address}
</> </>
) : ( ) : (
<span className="listingsTable__row__empty"></span> <span className="listingsTable__row__empty">---</span>
)} )}
</div> </div>
@@ -79,14 +77,22 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div> <div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}> <div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<button <StatusControl
type="button" status={item.status?.status ?? null}
className="listingsTable__row__star" compact
onClick={(e) => onWatch(e, item)} onChange={(next) => onStatusChange?.(item, next)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'} onTriggerClick={(e) => e.stopPropagation()}
> />
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />} <Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
</button> <button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
<Tooltip content="Original Listing"> <Tooltip content="Original Listing">
<Button <Button
size="small" size="small"

View File

@@ -55,8 +55,11 @@
color: @color-success; color: @color-success;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 4px; gap: 4px;
white-space: nowrap; white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
} }
&__address { &__address {
@@ -94,26 +97,33 @@
} }
&__star { &__star {
width: 28px; width: 30px;
height: 28px; height: 30px;
background: transparent; background: rgba(0,0,0,0.28);
border: none; border: 1px solid rgba(255,255,255,0.12);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
transition: background @transition-fast; transition: background @transition-fast, border-color @transition-fast, transform @transition-fast;
flex-shrink: 0; flex-shrink: 0;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.1); background: rgba(0,0,0,0.48);
border-color: rgba(255,255,255,0.22);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(224,74,56,0.35);
} }
svg { svg {
color: @color-accent; color: @color-accent;
font-size: 14px; font-size: 15px;
} }
} }

View File

@@ -1,5 +1,5 @@
.versionBanner { .versionBanner {
margin-bottom: 0 !important; margin-bottom: 16px;
.semi-banner-body { .semi-banner-body {
padding: 6px 16px; padding: 6px 16px;

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const euroPriceFormatter = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* @param {number|string} price
* @returns {string}
*/
export const formatEuroPrice = (price) => {
const parsedPrice = Number(price);
if (!Number.isFinite(parsedPrice)) {
return `${price}`;
}
return `${euroPriceFormatter.format(parsedPrice)}`;
};

View File

@@ -260,6 +260,22 @@ export const useFredyState = create(
console.error('Error while trying to get resource for api/listings/map. Error:', Exception); console.error('Error while trying to get resource for api/listings/map. Error:', Exception);
} }
}, },
async setListingStatus(listingId, status) {
try {
await xhrPost(`/api/listings/${listingId}/status`, { status });
} catch (Exception) {
console.error(`Error while trying to set status for listing ${listingId}. Error:`, Exception);
throw Exception;
}
},
async setListingNotes(listingId, notes) {
try {
await xhrPost(`/api/listings/${listingId}/notes`, { notes });
} catch (Exception) {
console.error(`Error while trying to set notes for listing ${listingId}. Error:`, Exception);
throw Exception;
}
},
}, },
userSettings: { userSettings: {
async getUserSettings() { async getUserSettings() {

View File

@@ -20,6 +20,8 @@ import {
Banner, Banner,
Spin, Spin,
Toast, Toast,
TextArea,
Tooltip,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconArrowLeft, IconArrowLeft,
@@ -39,11 +41,13 @@ import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.png'; import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js'; import * as timeService from '../../services/time/timeService.js';
import { formatEuroPrice } from '../../services/price/priceService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js'; import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost, xhrDelete } from '../../services/xhr.js'; import { xhrPost, xhrDelete } from '../../services/xhr.js';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Headline from '../../components/headline/Headline.jsx'; import Headline from '../../components/headline/Headline.jsx';
import StatusControl from '../../components/listings/StatusControl.jsx';
import './ListingDetail.less'; import './ListingDetail.less';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -65,6 +69,8 @@ export default function ListingDetail() {
const map = useRef(null); const map = useRef(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [notesSaving, setNotesSaving] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchListing() { async function fetchListing() {
@@ -82,6 +88,10 @@ export default function ListingDetail() {
fetchListing(); fetchListing();
}, [listingId]); }, [listingId]);
useEffect(() => {
setNotesDraft(listing?.notes ?? '');
}, [listing?.id, listing?.notes]);
const hasGeo = const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1; listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
@@ -271,6 +281,32 @@ export default function ListingDetail() {
} }
}; };
const handleStatusChange = async (next) => {
try {
await actions.listingsData.setListingStatus(listing.id, next);
await actions.listingsData.getListing(listingId);
Toast.success(next ? `Marked as ${next}` : 'Status cleared');
} catch (e) {
console.error('Failed to update status:', e);
Toast.error('Failed to update status');
}
};
const handleSaveNotes = async () => {
if (!listing) return;
setNotesSaving(true);
try {
await actions.listingsData.setListingNotes(listing.id, notesDraft);
await actions.listingsData.getListing(listingId);
Toast.success('Notes saved');
} catch (e) {
console.error('Failed to save notes:', e);
Toast.error('Failed to save notes');
} finally {
setNotesSaving(false);
}
};
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
@@ -281,35 +317,62 @@ export default function ListingDetail() {
if (!listing) return null; if (!listing) return null;
const statusLabel = listing.status?.status
? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
: null;
const data = [ const data = [
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> }, {
key: 'Price',
value: listing.price ? (
<span className="listing-detail__price">{formatEuroPrice(listing.price)}</span>
) : (
'N/A'
),
Icon: <IconCart />,
helpText: 'The asking price of this listing, as reported by the provider.',
},
{ {
key: 'Size', key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A', value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconExpand />, Icon: <IconExpand />,
helpText: 'Living space of the listing in square meters.',
}, },
{ {
key: 'Rooms', key: 'Rooms',
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A', value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
Icon: <IconGridView />, Icon: <IconGridView />,
helpText: 'Number of rooms in the listing.',
}, },
{ {
key: 'Job', key: 'Job',
value: listing.job_name, value: listing.job_name,
Icon: <IconBriefcase />, Icon: <IconBriefcase />,
helpText: 'The Fredy job that found this listing.',
}, },
{ {
key: 'Provider', key: 'Provider',
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown', value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />, Icon: <IconBriefcase />,
helpText: 'The real estate portal where this listing was scraped from.',
}, },
{ {
key: 'Added', key: 'Added',
value: timeService.format(listing.created_at), value: timeService.format(listing.created_at),
Icon: <IconClock />, Icon: <IconClock />,
helpText: 'When Fredy first added this listing to your database.',
}, },
]; ];
if (statusLabel) {
data.push({
key: 'Status',
value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel,
Icon: <IconActivity />,
helpText: 'The status you marked for this listing and when you set it.',
});
}
return ( return (
<div className="listing-detail"> <div className="listing-detail">
<Headline <Headline
@@ -347,6 +410,7 @@ export default function ListingDetail() {
> >
{listing.isWatched === 1 ? 'Watched' : 'Watch'} {listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button> </Button>
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn"> <a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} /> <IconLink style={{ marginRight: 6 }} />
Open listing Open listing
@@ -380,6 +444,32 @@ export default function ListingDetail() {
preview={!!listing.image_url} preview={!!listing.image_url}
/> />
</div> </div>
<div className="listing-detail__notes">
<Title heading={4} className="listing-detail__notes-title">
Notes
</Title>
<TextArea
value={notesDraft}
onChange={(val) => setNotesDraft(val)}
placeholder="Your private notes about this listing…"
rows={5}
autosize={{ minRows: 4, maxRows: 12 }}
className="listing-detail__notes-textarea"
showClear
/>
<Space className="listing-detail__notes-actions">
<Button
theme="solid"
type="primary"
loading={notesSaving}
disabled={notesSaving || (notesDraft ?? '') === (listing.notes ?? '')}
onClick={handleSaveNotes}
>
Store notes
</Button>
</Space>
</div>
</Col> </Col>
<Col span={24} lg={12}> <Col span={24} lg={12}>
<div className="listing-detail__info-section"> <div className="listing-detail__info-section">
@@ -389,10 +479,12 @@ export default function ListingDetail() {
<Descriptions column={1}> <Descriptions column={1}>
{data.map((item, index) => ( {data.map((item, index) => (
<Descriptions.Item key={index}> <Descriptions.Item key={index}>
<Space> <Tooltip content={item.helpText} position="left">
{item.Icon} <span className="listing-detail__details-item">
{item.value} {item.Icon}
</Space> {item.value}
</span>
</Tooltip>
</Descriptions.Item> </Descriptions.Item>
))} ))}
</Descriptions> </Descriptions>

View File

@@ -89,6 +89,49 @@
} }
} }
&__notes {
padding: 1.5rem;
border-top: 1px solid var(--semi-color-border);
}
&__notes-title {
margin-bottom: 1rem !important;
}
&__notes-actions {
margin-top: 0.75rem;
width: 100%;
justify-content: flex-end;
}
&__notes-textarea {
background: #2a2a2a !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
box-shadow: none !important;
outline: none !important;
transition: border-color @transition-fast, background @transition-fast !important;
textarea {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
outline: none !important;
color: @color-text !important;
font-family: @font-ui !important;
font-size: @text-base !important;
}
&.semi-input-textarea-focus,
&:focus,
&:focus-within {
border-color: @color-accent !important;
box-shadow: none !important;
outline: none !important;
background: #2f2f2f !important;
}
}
&__watch-btn { &__watch-btn {
color: @color-muted !important; color: @color-muted !important;
border: 1px solid @color-border-bright !important; border: 1px solid @color-border-bright !important;
@@ -128,6 +171,13 @@
padding: 1.5rem; padding: 1.5rem;
} }
&__details-item {
cursor: help;
display: inline-flex;
align-items: center;
gap: 8px;
}
&__map-container { &__map-container {
height: 400px; height: 400px;
width: 100%; width: 100%;
@@ -149,6 +199,10 @@
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
} }
&__price {
font-variant-numeric: tabular-nums;
}
} }
.listing-detail-popup { .listing-detail-popup {

View File

@@ -6,11 +6,15 @@
import ListingsOverview from '../../components/listings/ListingsOverview.jsx'; import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
import Headline from '../../components/headline/Headline.jsx'; import Headline from '../../components/headline/Headline.jsx';
export default function Listings() { /**
* @param {{ mode?: 'all' | 'watchlist' }} props
*/
export default function Listings({ mode = 'all' }) {
const title = mode === 'watchlist' ? 'Watchlist' : 'Listings';
return ( return (
<> <>
<Headline text="Listings" /> <Headline text={title} />
<ListingsOverview /> <ListingsOverview mode={mode} />
</> </>
); );
} }