mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019b9ac87b | ||
|
|
0d23d43e79 | ||
|
|
324afee483 | ||
|
|
e95ebb9624 | ||
|
|
c29387c85d | ||
|
|
322ae199b0 | ||
|
|
b3300169fa | ||
|
|
9296bcdc86 | ||
|
|
44edf47393 | ||
|
|
bbebc2a1a2 | ||
|
|
d2978c14db | ||
|
|
696ae451d3 | ||
|
|
317ef79336 |
@@ -11,6 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<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.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" />
|
||||
|
||||
@@ -99,8 +99,8 @@ class FredyPipelineExecutioner {
|
||||
/**
|
||||
* Optionally, enrich new listings with data from their detail pages.
|
||||
* Only called when the provider config defines a `fetchDetails` function.
|
||||
* Runs all fetches in parallel. Each fetch must handle its own errors
|
||||
* and always resolve (never reject) to avoid aborting other listings.
|
||||
* Fetches are performed sequentially to avoid overloading the provider or
|
||||
* the shared browser instance.
|
||||
*
|
||||
* @param {Listing[]} newListings New listings to enrich.
|
||||
* @returns {Promise<Listing[]>} Resolves with enriched listings.
|
||||
@@ -199,9 +199,9 @@ class FredyPipelineExecutioner {
|
||||
const toDeleteListingByIds = [];
|
||||
const keptListings = newListings.filter((listing) => {
|
||||
const filterOut =
|
||||
(minRooms && listing.rooms && listing.rooms < minRooms) ||
|
||||
(minSize && listing.size && listing.size < minSize) ||
|
||||
(maxPrice && listing.price && listing.price > maxPrice);
|
||||
(minRooms && listing.rooms != null && listing.rooms < minRooms) ||
|
||||
(minSize && listing.size != null && listing.size < minSize) ||
|
||||
(maxPrice && listing.price != null && listing.price > maxPrice);
|
||||
|
||||
if (filterOut) {
|
||||
toDeleteListingByIds.push(listing.id);
|
||||
@@ -223,24 +223,15 @@ class FredyPipelineExecutioner {
|
||||
* @param {string} url The provider URL to fetch from.
|
||||
* @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 });
|
||||
return new Promise((resolve, reject) => {
|
||||
extractor
|
||||
.execute(url, this._providerConfig.waitForSelector, this._providerId)
|
||||
.then(() => {
|
||||
const listings = extractor.parseResponseText(
|
||||
this._providerConfig.crawlContainer,
|
||||
this._providerConfig.crawlFields,
|
||||
url,
|
||||
);
|
||||
resolve(listings == null ? [] : listings);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
logger.error(err);
|
||||
});
|
||||
});
|
||||
await extractor.execute(url, this._providerConfig.waitForSelector, this._providerId);
|
||||
const listings = extractor.parseResponseText(
|
||||
this._providerConfig.crawlContainer,
|
||||
this._providerConfig.crawlFields,
|
||||
url,
|
||||
);
|
||||
return listings == null ? [] : listings;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,4 +12,6 @@ export const TRACKING_POIS = {
|
||||
BASE_URL_SETTING: 'BASE_URL_SETTING',
|
||||
SET_PROXY_SETTING: 'SET_PROXY_SETTING',
|
||||
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
|
||||
NOTES_CREATE: 'NOTES_CREATE',
|
||||
USING_LISTING_STATUS: 'USING_LISTING_STATUS',
|
||||
};
|
||||
|
||||
@@ -195,6 +195,9 @@ export default async function jobPlugin(fastify) {
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
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) {
|
||||
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();
|
||||
try {
|
||||
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) {
|
||||
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
|
||||
|
||||
@@ -8,8 +8,10 @@ import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||
import { isAdmin as isAdminFn } from '../security.js';
|
||||
import logger from '../../services/logger.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 { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
|
||||
/**
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
@@ -23,6 +25,7 @@ export default async function listingsPlugin(fastify) {
|
||||
jobNameFilter,
|
||||
providerFilter,
|
||||
watchListFilter,
|
||||
statusFilter,
|
||||
sortfield = null,
|
||||
sortdir = 'asc',
|
||||
freeTextFilter,
|
||||
@@ -35,12 +38,16 @@ export default async function listingsPlugin(fastify) {
|
||||
};
|
||||
const normalizedActivity = toBool(activityFilter);
|
||||
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 jobIdFilter = null;
|
||||
const jobs = getJobs();
|
||||
if (!nullOrEmpty(jobNameFilter)) {
|
||||
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||
const job = getJob(jobNameFilter);
|
||||
jobFilter = job != null ? job.name : null;
|
||||
jobIdFilter = job != null ? job.id : null;
|
||||
}
|
||||
@@ -54,6 +61,7 @@ export default async function listingsPlugin(fastify) {
|
||||
jobIdFilter: jobIdFilter,
|
||||
providerFilter,
|
||||
watchListFilter: normalizedWatch,
|
||||
statusFilter: normalizedStatus,
|
||||
sortField: sortfield || null,
|
||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||
userId: request.session.currentUser,
|
||||
@@ -94,6 +102,55 @@ export default async function listingsPlugin(fastify) {
|
||||
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) => {
|
||||
const { jobId, hardDelete = false } = request.body;
|
||||
const settings = await getSettings();
|
||||
@@ -101,6 +158,16 @@ export default async function listingsPlugin(fastify) {
|
||||
if (settings.demoMode && !isAdminFn(request)) {
|
||||
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);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
@@ -111,7 +178,11 @@ export default async function listingsPlugin(fastify) {
|
||||
|
||||
fastify.delete('/', async (request, reply) => {
|
||||
const { ids, hardDelete = false } = request.body;
|
||||
const settings = await getSettings();
|
||||
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) {
|
||||
listingStorage.deleteListingsById(ids, hardDelete);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ function getClientIp(request) {
|
||||
|
||||
function isRateLimited(ip) {
|
||||
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);
|
||||
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
|
||||
loginAttempts.set(ip, { count: 1, firstAttempt: now });
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
import { getSettings, getUserSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||
import { fromJson } from '../../utils.js';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.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) {
|
||||
fastify.get('/', async (request) => {
|
||||
const userId = request.session.currentUser;
|
||||
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
settings[r.name] = fromJson(r.value, null);
|
||||
}
|
||||
return settings;
|
||||
return getUserSettings(userId);
|
||||
});
|
||||
|
||||
fastify.get('/autocomplete', async (request, reply) => {
|
||||
|
||||
@@ -155,6 +155,12 @@ export function createMcpServer() {
|
||||
),
|
||||
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'),
|
||||
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 (
|
||||
{
|
||||
@@ -170,6 +176,7 @@ export function createMcpServer() {
|
||||
maxPrice,
|
||||
sortField,
|
||||
sortDir,
|
||||
status,
|
||||
},
|
||||
extra,
|
||||
) => {
|
||||
@@ -192,6 +199,7 @@ export function createMcpServer() {
|
||||
maxPrice: maxPrice ?? null,
|
||||
sortField: sortField ?? null,
|
||||
sortDir: sortDir ?? 'desc',
|
||||
statusFilter: status,
|
||||
userId: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
@@ -124,10 +124,10 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
|
||||
md += '\n\n';
|
||||
|
||||
if (listings.length > 0) {
|
||||
md += `| ID | Title | Address | Price | Size | Provider | Active | Created | Job |\n`;
|
||||
md += `|----|-------|---------|-------|------|----------|--------|---------|-----|\n`;
|
||||
md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
|
||||
md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
|
||||
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`;
|
||||
} else {
|
||||
@@ -156,6 +156,10 @@ export function normalizeGetListing(listing) {
|
||||
md += `- **Link:** ${listing.link || '–'}\n`;
|
||||
md += `- **Image:** ${listing.image_url || '–'}\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 += `- **Job:** ${listing.job_name || '–'}\n`;
|
||||
if (listing.latitude != null && listing.longitude != null) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
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}`;
|
||||
return fetch(server, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
|
||||
|
||||
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
|
||||
/* 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 [
|
||||
Promise.resolve(
|
||||
console.info(
|
||||
|
||||
@@ -7,6 +7,7 @@ import fetch from 'node-fetch';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import { markdown2Html } from '../../services/markdown.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
|
||||
@@ -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 = {
|
||||
title: title,
|
||||
color: generateColorFromString(jobKey),
|
||||
url: listing.link,
|
||||
fields: fields,
|
||||
fields,
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -119,7 +120,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
}).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}`));
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
|
||||
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 += 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 (
|
||||
`| [${o.title}](${o.link}) | ` +
|
||||
[o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') +
|
||||
|
||||
@@ -14,7 +14,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
|
||||
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 = `
|
||||
Address: ${newListing.address}
|
||||
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
||||
|
||||
@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
||||
const results = await Promise.all(
|
||||
newListings.map(async (newListing) => {
|
||||
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 form = new FormData();
|
||||
|
||||
@@ -39,7 +39,7 @@ const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||
if (baseUrl && p.id) {
|
||||
blocks.push({
|
||||
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>` },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||
if (baseUrl && p.id) {
|
||||
blocks.push({
|
||||
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>` },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,41 +12,45 @@ import logger from '../../services/logger.js';
|
||||
import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js';
|
||||
|
||||
const RATE_LIMIT_INTERVAL = 1000;
|
||||
const THROTTLE_MAX_IDLE_MS = RATE_LIMIT_INTERVAL + 2000;
|
||||
const chatThrottleMap = new Map();
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const now = Date.now();
|
||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||
const toBeDeleted = [];
|
||||
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.
|
||||
* 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 {T} call - async function (endpoint: string, body: any) => Promise<Response>
|
||||
* @returns {T}
|
||||
* @param {Function} call - async function (endpoint: string, body: any) => Promise<Response>
|
||||
* @returns {Function}
|
||||
*/
|
||||
function getThrottled(chatId, call) {
|
||||
cleanupOldThrottles();
|
||||
const now = Date.now();
|
||||
const chatThrottle = chatThrottleMap.get(chatId);
|
||||
if (chatThrottle) {
|
||||
chatThrottle.lastUsedAt = now;
|
||||
return chatThrottle.throttled;
|
||||
const existing = chatThrottleMap.get(chatId);
|
||||
if (existing) {
|
||||
existing.lastUsedAt = Date.now();
|
||||
return existing.throttled;
|
||||
}
|
||||
const throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(call);
|
||||
chatThrottleMap.set(chatId, { lastUsedAt: now, throttled });
|
||||
return throttled;
|
||||
const entry = { lastUsedAt: Date.now(), throttled: null };
|
||||
chatThrottleMap.set(chatId, entry);
|
||||
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} serviceName
|
||||
* @param {Object} o - Listing object
|
||||
* @param {string} [o.title]
|
||||
* @param {string} [o.address]
|
||||
* @param {string|number} [o.price]
|
||||
* @param {string|number} [o.size]
|
||||
* @param {string} [o.link]
|
||||
* @param {string} [baseUrl]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildCaption(jobName, serviceName, o, baseUrl) {
|
||||
function buildHtmlBody(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 `<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>` : '';
|
||||
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` +
|
||||
@@ -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} serviceName
|
||||
* @param {Object} o - Listing object
|
||||
* @param baseUrl
|
||||
* @param {string} [baseUrl]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildCaptionPlain(jobName, serviceName, o, baseUrl) {
|
||||
function buildPlainCaption(jobName, serviceName, o, baseUrl) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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} serviceName
|
||||
* @param {Object} o - Listing object
|
||||
* @param {string} [baseUrl]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildTextPlain(jobName, serviceName, o, baseUrl) {
|
||||
function buildPlainText(jobName, serviceName, o, baseUrl) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* - 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");
|
||||
}
|
||||
|
||||
const chatIds = String(chatId)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Optional Telegram topic/thread support (supergroups)
|
||||
let message_thread_id;
|
||||
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
|
||||
@@ -177,70 +261,16 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
const job = getJob(jobKey);
|
||||
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([]);
|
||||
|
||||
const promises = newListings.map(async (o) => {
|
||||
const img = normalizeImageUrl(o.image);
|
||||
const textPayload = {
|
||||
chat_id: chatId,
|
||||
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
|
||||
...(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;
|
||||
});
|
||||
});
|
||||
const allPromises = chatIds.flatMap((id) => {
|
||||
const caller = makeTelegramCaller(token, jobName);
|
||||
const throttledCall = getThrottled(id, caller);
|
||||
const opts = { jobName, serviceName, baseUrl, plainText, message_thread_id };
|
||||
return newListings.map((listing) => sendListingToChat(throttledCall, listing, id, opts));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
return Promise.all(allPromises);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -261,7 +291,8 @@ export const config = {
|
||||
chatId: {
|
||||
type: 'chatId',
|
||||
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: {
|
||||
type: 'text',
|
||||
|
||||
@@ -21,6 +21,8 @@ Steps:
|
||||
- Private chats: `chat.id` is a positive 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 bot’s 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)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import logger from '../services/logger.js';
|
||||
const path = './adapter';
|
||||
|
||||
/** Read every integration existing in ./adapter **/
|
||||
@@ -23,7 +24,13 @@ const findAdapter = (notificationAdapter) => {
|
||||
export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => {
|
||||
//this is not being used in tests, therefore adapter are always set
|
||||
return notificationConfig
|
||||
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
|
||||
.map((notificationAdapter) => findAdapter(notificationAdapter))
|
||||
.map((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 }));
|
||||
};
|
||||
|
||||
@@ -105,14 +105,11 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
return;
|
||||
}
|
||||
settings.lastRun = now;
|
||||
const jobs = jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => job.enabled)
|
||||
.filter((job) => {
|
||||
if (!context) return true; // startup/cron → all
|
||||
if (context.isAdmin) return true; // admin → all
|
||||
return context.userId ? job.userId === context.userId : false; // user → own
|
||||
});
|
||||
const jobs = jobStorage.getJobs().filter((job) => {
|
||||
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) {
|
||||
await executeJob(job);
|
||||
|
||||
@@ -16,7 +16,7 @@ import logger from '../../services/logger.js';
|
||||
* Concurrency: network-bound checks are executed with a configurable concurrency limit.
|
||||
*
|
||||
* @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>}
|
||||
*/
|
||||
export default async function runActiveChecker(opts = {}) {
|
||||
|
||||
@@ -3,10 +3,27 @@
|
||||
* 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 { 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.
|
||||
* Useful to de-duplicate before inserting new listings.
|
||||
@@ -43,18 +60,14 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
|
||||
|
||||
const placeholders = jobIds.map(() => '?').join(',');
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) OVER() AS active_count,
|
||||
price
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})
|
||||
AND manually_deleted = 0
|
||||
GROUP BY
|
||||
id`,
|
||||
`SELECT is_active, price
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})
|
||||
AND manually_deleted = 0`,
|
||||
jobIds,
|
||||
);
|
||||
|
||||
const activeCount = rows[0]?.active_count ?? 0;
|
||||
const activeCount = rows.filter((r) => r.is_active === 1).length;
|
||||
|
||||
const prices = rows
|
||||
.map((r) => r.price)
|
||||
@@ -244,6 +257,7 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
* @param {object} [params.jobNameFilter]
|
||||
* @param {object} [params.providerFilter]
|
||||
* @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 {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
|
||||
@@ -260,6 +274,7 @@ export const queryListings = ({
|
||||
jobIdFilter,
|
||||
providerFilter,
|
||||
watchListFilter,
|
||||
statusFilter,
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
@@ -289,13 +304,15 @@ export const queryListings = ({
|
||||
}
|
||||
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
||||
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
|
||||
if (activityFilter === true) {
|
||||
whereParts.push('(is_active = 1)');
|
||||
whereParts.push('(l.is_active = 1)');
|
||||
} 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)
|
||||
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)
|
||||
if (providerFilter && String(providerFilter).trim().length > 0) {
|
||||
params.providerName = String(providerFilter).trim();
|
||||
whereParts.push('(provider = @providerName)');
|
||||
whereParts.push('(l.provider = @providerName)');
|
||||
}
|
||||
// watchListFilter: when true -> only watched listings, false -> only unwatched
|
||||
if (watchListFilter === true) {
|
||||
@@ -317,14 +334,26 @@ export const queryListings = ({
|
||||
} else if (watchListFilter === false) {
|
||||
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)
|
||||
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
||||
params.createdAfter = createdAfter;
|
||||
whereParts.push('(created_at >= @createdAfter)');
|
||||
whereParts.push('(l.created_at >= @createdAfter)');
|
||||
}
|
||||
if (Number.isFinite(createdBefore) && createdBefore > 0) {
|
||||
params.createdBefore = createdBefore;
|
||||
whereParts.push('(created_at <= @createdBefore)');
|
||||
whereParts.push('(l.created_at <= @createdBefore)');
|
||||
}
|
||||
// Price range filters
|
||||
if (Number.isFinite(minPrice) && minPrice >= 0) {
|
||||
@@ -339,32 +368,22 @@ export const queryListings = ({
|
||||
// Build whereSql (filtering by manually_deleted = 0)
|
||||
whereParts.push('(l.manually_deleted = 0)');
|
||||
|
||||
const whereSql = 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');
|
||||
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
// whitelist sortable fields to avoid SQL injection
|
||||
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
|
||||
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||
// whitelist sortable fields to avoid SQL injection; map to fully-qualified expressions
|
||||
const sortableMap = {
|
||||
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 orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY 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');
|
||||
const orderSqlWithAlias = safeSortExpr ? `ORDER BY ${safeSortExpr} ${safeSortDir}` : 'ORDER BY l.created_at DESC';
|
||||
|
||||
// count total with same WHERE
|
||||
const countRow = SqliteConnection.query(
|
||||
@@ -391,7 +410,7 @@ export const queryListings = ({
|
||||
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.userId]
|
||||
* @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 } = {}) => {
|
||||
const baseWhereParts = [
|
||||
@@ -626,7 +645,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
|
||||
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))`;
|
||||
}
|
||||
return (
|
||||
return parseListingStatus(
|
||||
SqliteConnection.query(
|
||||
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||
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
|
||||
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
|
||||
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.
|
||||
*
|
||||
|
||||
11
lib/services/storage/migrations/sql/18.add-listing-status.js
Normal file
11
lib/services/storage/migrations/sql/18.add-listing-status.js
Normal 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'));
|
||||
`);
|
||||
}
|
||||
10
lib/services/storage/migrations/sql/19.add-listing-notes.js
Normal file
10
lib/services/storage/migrations/sql/19.add-listing-notes.js
Normal 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;
|
||||
`);
|
||||
}
|
||||
@@ -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) {
|
||||
refreshSettingsCache();
|
||||
cachedSettingsConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,25 @@ export const deleteWatch = (listingId, userId) => {
|
||||
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.
|
||||
* @param {string} listingId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "22.2.2",
|
||||
"version": "22.3.3",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
|
||||
@@ -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', () => {
|
||||
it('throws when telegram adapter config is missing', () => {
|
||||
expect(() =>
|
||||
|
||||
193
test/storage/listingStatus.test.js
Normal file
193
test/storage/listingStatus.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,7 @@ export default function FredyApp() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/listings/watchlist" element={<Listings mode="watchlist" />} />
|
||||
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
|
||||
@@ -296,7 +296,7 @@ const JobGrid = () => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
{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>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
} from '@douyinfe/semi-icons';
|
||||
import no_image from '../../../assets/no_image.png';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import StatusControl from '../../listings/StatusControl.jsx';
|
||||
|
||||
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">
|
||||
{listings.map((item) => (
|
||||
<div
|
||||
@@ -49,14 +50,16 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||
<span>Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="listingsGrid__card__star"
|
||||
onClick={(e) => onWatch(e, item)}
|
||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
|
||||
<button
|
||||
type="button"
|
||||
className="listingsGrid__card__star"
|
||||
onClick={(e) => onWatch(e, item)}
|
||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="listingsGrid__card__body">
|
||||
@@ -83,6 +86,12 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
border: 1px solid @color-border !important;
|
||||
border-radius: @radius-card !important;
|
||||
overflow: hidden;
|
||||
transition: transform @transition-card, box-shadow @transition-card;
|
||||
transition: box-shadow @transition-card;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
|
||||
|
||||
import './ListingsOverview.less';
|
||||
|
||||
const ListingsOverview = () => {
|
||||
const ListingsOverview = ({ mode = 'all' }) => {
|
||||
const isWatchlistMode = mode === 'watchlist';
|
||||
const listingsData = useSelector((state) => state.listingsData);
|
||||
const providers = useSelector((state) => state.provider);
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
@@ -46,9 +47,13 @@ const ListingsOverview = () => {
|
||||
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
||||
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
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 = () => {
|
||||
actions.listingsData.getListingsData({
|
||||
page,
|
||||
@@ -56,13 +61,30 @@ const ListingsOverview = () => {
|
||||
sortfield: sortField,
|
||||
sortdir: sortDir,
|
||||
freeTextFilter,
|
||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||
filter: {
|
||||
watchListFilter: effectiveWatchListFilter,
|
||||
jobNameFilter,
|
||||
activityFilter,
|
||||
providerFilter,
|
||||
statusFilter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||
}, [
|
||||
page,
|
||||
sortField,
|
||||
sortDir,
|
||||
freeTextFilter,
|
||||
providerFilter,
|
||||
activityFilter,
|
||||
jobNameFilter,
|
||||
watchListFilter,
|
||||
statusFilter,
|
||||
isWatchlistMode,
|
||||
]);
|
||||
|
||||
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) => {
|
||||
if (listingDeletionPref?.skipPrompt) {
|
||||
confirmDeletion(listingDeletionPref.hardDelete, false, id);
|
||||
@@ -148,20 +181,38 @@ const ListingsOverview = () => {
|
||||
<Radio value="false">Inactive</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||
{!isWatchlistMode && (
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||
onChange={(e) => {
|
||||
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);
|
||||
}}
|
||||
value={statusFilter}
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Watched</Radio>
|
||||
<Radio value="false">Unwatched</Radio>
|
||||
</RadioGroup>
|
||||
<Select.Option value="applied">Applied</Select.Option>
|
||||
<Select.Option value="rejected">Rejected</Select.Option>
|
||||
<Select.Option value="accepted">Accepted</Select.Option>
|
||||
<Select.Option value="none">No status</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Provider"
|
||||
@@ -197,7 +248,13 @@ const ListingsOverview = () => {
|
||||
))}
|
||||
</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="created_at">Listing Date</Select.Option>
|
||||
<Select.Option value="price">Price</Select.Option>
|
||||
@@ -241,9 +298,21 @@ const ListingsOverview = () => {
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__sort {
|
||||
flex-shrink: 0;
|
||||
|
||||
.semi-select-prefix {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.listingsOverview__topbar__search {
|
||||
width: 100%;
|
||||
|
||||
115
ui/src/components/listings/StatusControl.jsx
Normal file
115
ui/src/components/listings/StatusControl.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
ui/src/components/listings/StatusControl.less
Normal file
64
ui/src/components/listings/StatusControl.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,9 +112,9 @@
|
||||
|
||||
.semi-navigation-item {
|
||||
border-radius: @radius-btn !important;
|
||||
border: 1px solid transparent !important;
|
||||
color: @color-muted !important;
|
||||
transition: background @transition-fast, color @transition-fast !important;
|
||||
margin: 2px 8px !important;
|
||||
transition: background @transition-fast, color @transition-fast, border-color @transition-fast !important;
|
||||
|
||||
&:hover {
|
||||
color: @color-text !important;
|
||||
@@ -123,7 +123,7 @@
|
||||
&.semi-navigation-item-selected,
|
||||
&[aria-selected="true"] {
|
||||
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;
|
||||
|
||||
.semi-navigation-item-icon {
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function Navigation({ isAdmin }) {
|
||||
items: [
|
||||
{ itemKey: '/listings', text: 'Overview' },
|
||||
{ itemKey: '/map', text: 'Map View' },
|
||||
{ itemKey: '/listings/watchlist', text: 'Watchlist' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -61,6 +62,22 @@ export default function Navigation({ isAdmin }) {
|
||||
}
|
||||
|
||||
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);
|
||||
return '/' + split[0];
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
||||
</Tag>
|
||||
)}
|
||||
{job.isOnlyShared && (
|
||||
<Tooltip content="Shared with you — read only">
|
||||
<Tooltip content="Shared with you - read only">
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconBriefcase,
|
||||
IconCart,
|
||||
IconDelete,
|
||||
IconLink,
|
||||
IconMapPin,
|
||||
@@ -15,14 +14,16 @@ import {
|
||||
IconEyeOpened,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import no_image from '../../assets/no_image.png';
|
||||
import { formatEuroPrice } from '../../services/price/priceService.js';
|
||||
import * as timeService from '../../services/time/timeService.js';
|
||||
import StatusControl from '../listings/StatusControl.jsx';
|
||||
|
||||
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">
|
||||
{listings.map((item) => (
|
||||
<div
|
||||
@@ -51,12 +52,9 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||
|
||||
<div className="listingsTable__row__price">
|
||||
{item.price ? (
|
||||
<>
|
||||
<IconCart size="small" />
|
||||
{item.price}
|
||||
</>
|
||||
formatEuroPrice(item.price)
|
||||
) : (
|
||||
<span className="listingsTable__row__empty">—</span>
|
||||
<span className="listingsTable__row__empty">---</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +65,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||
{item.address}
|
||||
</>
|
||||
) : (
|
||||
<span className="listingsTable__row__empty">—</span>
|
||||
<span className="listingsTable__row__empty">---</span>
|
||||
)}
|
||||
</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__actions" onClick={(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
<StatusControl
|
||||
status={item.status?.status ?? null}
|
||||
compact
|
||||
onChange={(next) => onStatusChange?.(item, next)}
|
||||
onTriggerClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
|
||||
<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">
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -55,8 +55,11 @@
|
||||
color: @color-success;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__address {
|
||||
@@ -94,26 +97,33 @@
|
||||
}
|
||||
|
||||
&__star {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: rgba(0,0,0,0.28);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background @transition-fast;
|
||||
transition: background @transition-fast, border-color @transition-fast, transform @transition-fast;
|
||||
flex-shrink: 0;
|
||||
|
||||
&: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 {
|
||||
color: @color-accent;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.versionBanner {
|
||||
margin-bottom: 0 !important;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.semi-banner-body {
|
||||
padding: 6px 16px;
|
||||
|
||||
22
ui/src/services/price/priceService.js
Normal file
22
ui/src/services/price/priceService.js
Normal 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)} €`;
|
||||
};
|
||||
@@ -260,6 +260,22 @@ export const useFredyState = create(
|
||||
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: {
|
||||
async getUserSettings() {
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
Banner,
|
||||
Spin,
|
||||
Toast,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
@@ -39,11 +41,13 @@ import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import no_image from '../../assets/no_image.png';
|
||||
import * as timeService from '../../services/time/timeService.js';
|
||||
import { formatEuroPrice } from '../../services/price/priceService.js';
|
||||
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { xhrPost, xhrDelete } from '../../services/xhr.js';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
import StatusControl from '../../components/listings/StatusControl.jsx';
|
||||
import './ListingDetail.less';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -65,6 +69,8 @@ export default function ListingDetail() {
|
||||
const map = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState('');
|
||||
const [notesSaving, setNotesSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchListing() {
|
||||
@@ -82,6 +88,10 @@ export default function ListingDetail() {
|
||||
fetchListing();
|
||||
}, [listingId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotesDraft(listing?.notes ?? '');
|
||||
}, [listing?.id, listing?.notes]);
|
||||
|
||||
const hasGeo =
|
||||
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) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
@@ -281,35 +317,62 @@ export default function ListingDetail() {
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const statusLabel = listing.status?.status
|
||||
? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
|
||||
: null;
|
||||
|
||||
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',
|
||||
value: listing.size ? `${listing.size} m²` : 'N/A',
|
||||
Icon: <IconExpand />,
|
||||
helpText: 'Living space of the listing in square meters.',
|
||||
},
|
||||
{
|
||||
key: 'Rooms',
|
||||
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
|
||||
Icon: <IconGridView />,
|
||||
helpText: 'Number of rooms in the listing.',
|
||||
},
|
||||
{
|
||||
key: 'Job',
|
||||
value: listing.job_name,
|
||||
Icon: <IconBriefcase />,
|
||||
helpText: 'The Fredy job that found this listing.',
|
||||
},
|
||||
{
|
||||
key: 'Provider',
|
||||
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
|
||||
Icon: <IconBriefcase />,
|
||||
helpText: 'The real estate portal where this listing was scraped from.',
|
||||
},
|
||||
{
|
||||
key: 'Added',
|
||||
value: timeService.format(listing.created_at),
|
||||
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 (
|
||||
<div className="listing-detail">
|
||||
<Headline
|
||||
@@ -347,6 +410,7 @@ export default function ListingDetail() {
|
||||
>
|
||||
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
||||
</Button>
|
||||
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
|
||||
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
|
||||
<IconLink style={{ marginRight: 6 }} />
|
||||
Open listing
|
||||
@@ -380,6 +444,32 @@ export default function ListingDetail() {
|
||||
preview={!!listing.image_url}
|
||||
/>
|
||||
</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 span={24} lg={12}>
|
||||
<div className="listing-detail__info-section">
|
||||
@@ -389,10 +479,12 @@ export default function ListingDetail() {
|
||||
<Descriptions column={1}>
|
||||
{data.map((item, index) => (
|
||||
<Descriptions.Item key={index}>
|
||||
<Space>
|
||||
{item.Icon}
|
||||
{item.value}
|
||||
</Space>
|
||||
<Tooltip content={item.helpText} position="left">
|
||||
<span className="listing-detail__details-item">
|
||||
{item.Icon}
|
||||
{item.value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
|
||||
@@ -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 {
|
||||
color: @color-muted !important;
|
||||
border: 1px solid @color-border-bright !important;
|
||||
@@ -128,6 +171,13 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
&__details-item {
|
||||
cursor: help;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__map-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
@@ -149,6 +199,10 @@
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-detail-popup {
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
import ListingsOverview from '../../components/listings/ListingsOverview.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 (
|
||||
<>
|
||||
<Headline text="Listings" />
|
||||
<ListingsOverview />
|
||||
<Headline text={title} />
|
||||
<ListingsOverview mode={mode} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user