mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4878dc98e3 | ||
|
|
dc2704997d | ||
|
|
e107b0fb00 | ||
|
|
6c08675fee | ||
|
|
34c4de7267 | ||
|
|
b64a118a18 | ||
|
|
03cb4d18cb | ||
|
|
be5c4af3cf | ||
|
|
a460b813c1 | ||
|
|
4596442f64 | ||
|
|
0bcfa1d4ad | ||
|
|
0cad05124a | ||
|
|
eb53b68d45 | ||
|
|
ba0732e1f6 | ||
|
|
aa67647bbb | ||
|
|
7a9d49899b | ||
|
|
9a87c58d3e | ||
|
|
fdd7e835e8 | ||
|
|
00d6a12b30 | ||
|
|
05218800d2 | ||
|
|
19d4721f9f | ||
|
|
a794645393 | ||
|
|
fd7e228972 | ||
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
tools/release/config.json
|
||||
|
||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -1,94 +0,0 @@
|
||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
||||
|
||||
---
|
||||
|
||||
###### [V5.5.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- fixing provider
|
||||
- allow multiple instances of 1 provider
|
||||
- **BREAKING**: Minimum node version is now 16
|
||||
|
||||
###### [V5.4.6]
|
||||
|
||||
- Adding Instana node.js monitoring
|
||||
-
|
||||
|
||||
###### [V5.4.5]
|
||||
|
||||
- Adding Instana node.js monitoring
|
||||
|
||||
###### [V5.4.4]
|
||||
|
||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
||||
- Telegram: Use job name instead of ID and link in title
|
||||
- Fix race condition if user ID is in session but not in user store
|
||||
- Allow visiting the original provider URL
|
||||
|
||||
###### [V5.4.3]
|
||||
|
||||
- re-writing readme
|
||||
- improving docker build
|
||||
- using github's actions to build docker and test automatically
|
||||
|
||||
###### [V5.4.2]
|
||||
|
||||
- Fixing prod build
|
||||
|
||||
###### [V5.4.1]
|
||||
|
||||
- Upgrading dependencies
|
||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
||||
|
||||
```
|
||||
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
|
||||
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
|
||||
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
|
||||
```
|
||||
|
||||
###### [V5.3.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
||||
- Fixing Immowelt scraping
|
||||
|
||||
###### [V5.2.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- Adding new similarity check layer (Duplicates are being removed now)
|
||||
- Adding paging for search results
|
||||
|
||||
###### [V5.1.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- NodeJS 12.13 is now the minimum supported version
|
||||
- Adding general settings as new configuration page to ui
|
||||
- Adding new feature working hours
|
||||
|
||||
###### [V5.0.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- NodeJS 12 is now the minimum supported version
|
||||
|
||||
###### [V4.0.0]
|
||||
|
||||
Bringing back Immoscout :tada:
|
||||
|
||||
###### [V3.0.0]
|
||||
|
||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
||||
on the new ui and use the values from your previous config file if needed.
|
||||
|
||||
```
|
||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
||||
```
|
||||
|
||||
###### [V2.0.0]
|
||||
|
||||
```
|
||||
- Fredy can now run multiple search job on one instance
|
||||
- Changed lot's of the structure of Fredy to make this happen
|
||||
[BREAKING CHANGES]
|
||||
- The config has been changed, the config of V1.x will not work any longer
|
||||
- Sources have been renamed to provider
|
||||
```
|
||||
@@ -35,6 +35,7 @@ WORKDIR /fredy
|
||||
RUN apk add --no-cache chromium curl
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
IS_DOCKER=true \
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
|
||||
@@ -154,6 +154,13 @@ to Slack + Telegram."\
|
||||
Jobs run automatically at the interval you configure (see
|
||||
`/conf/config.json`).
|
||||
|
||||
### MCP Server 🤖
|
||||
|
||||
Starting with **V20**, Fredy ships with a built-in **MCP Server **. This allows you to connect Fredy to LLMs (like Claude, ChatGPT, or local models via LM Studio) and query your real estate data using natural language.
|
||||
The local LLM can even enrich existing listings by checking the listing online.
|
||||
|
||||
For more information on how to set it up and use it, please refer to the [MCP Readme](lib/mcp/README.md).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Immoscout
|
||||
|
||||
@@ -8,20 +8,20 @@ import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
import react from 'eslint-plugin-react';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
prettier,
|
||||
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
parser: babelParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2021,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
@@ -32,70 +32,14 @@ export default [
|
||||
after: 'readonly',
|
||||
it: 'readonly',
|
||||
},
|
||||
parserOptions: { requireConfigFile: false },
|
||||
},
|
||||
plugins: { react },
|
||||
rules: {
|
||||
eqeqeq: [2, 'allow-null'],
|
||||
strict: 0,
|
||||
'no-redeclare': [2, { builtinGlobals: false }],
|
||||
'class-methods-use-this': 'off',
|
||||
indent: ['off', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
|
||||
semi: ['error', 'always'],
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'jsx-quotes': ['error', 'prefer-double'],
|
||||
'react/display-name': 'off',
|
||||
'react/forbid-prop-types': 'off',
|
||||
'react/jsx-closing-bracket-location': 'off',
|
||||
'react/jsx-curly-spacing': 'off',
|
||||
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
|
||||
'react/jsx-indent-props': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/jsx-max-props-per-line': 'off',
|
||||
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
|
||||
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
|
||||
'react/jsx-no-literals': 'off',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
|
||||
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
|
||||
'react/jsx-sort-prop-types': 'off',
|
||||
'react/jsx-sort-props': 'off',
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
'react/no-danger': 'warn',
|
||||
'react/no-deprecated': 'error',
|
||||
'react/no-did-mount-set-state': 'error',
|
||||
'react/no-did-update-set-state': 'warn',
|
||||
'react/no-direct-mutation-state': 'off',
|
||||
'react/no-is-mounted': 'error',
|
||||
'react/no-set-state': 'off',
|
||||
'react/no-string-refs': 'warn',
|
||||
'react/no-unknown-property': 'error',
|
||||
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-extension': 'off',
|
||||
'react/require-render-return': 'error',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/sort-comp': 'off',
|
||||
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
|
||||
'react/wrap-multilines': 'off',
|
||||
'react/jsx-first-prop-new-line': 'off',
|
||||
'react/jsx-equals-spacing': ['warn', 'never'],
|
||||
'react/jsx-no-target-blank': 'error',
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||
'react/jsx-no-comment-textnodes': 'error',
|
||||
'react/no-comment-textnodes': 'off',
|
||||
'react/no-render-return-value': 'error',
|
||||
'react/require-optimization': ['off', { allowDecorators: [] }],
|
||||
'react/no-find-dom-node': 'warn',
|
||||
'react/forbid-component-props': ['off', { forbid: [] }],
|
||||
'react/no-danger-with-children': 'error',
|
||||
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
|
||||
'react/style-prop-object': 'error',
|
||||
'react/no-children-prop': 'warn',
|
||||
},
|
||||
settings: { react: { version: 'detect' } },
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
|
||||
prettier,
|
||||
];
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { NoNewListingsWarning } from './errors.js';
|
||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||
import {
|
||||
storeListings,
|
||||
getKnownListingHashesForJobAndProvider,
|
||||
deleteListingsById,
|
||||
} from './services/storage/listingsStorage.js';
|
||||
import { getJob } from './services/storage/jobStorage.js';
|
||||
import * as notify from './notification/notify.js';
|
||||
import Extractor from './services/extractor/extractor.js';
|
||||
@@ -14,6 +18,7 @@ import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Listing
|
||||
@@ -58,18 +63,21 @@ class FredyPipelineExecutioner {
|
||||
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
||||
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
||||
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
||||
*
|
||||
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
||||
* @param {Object} spatialFilter Optional spatial filter configuration.
|
||||
* @param {string} providerId The ID of the provider currently in use.
|
||||
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||
* @param browser
|
||||
*/
|
||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
||||
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
|
||||
this._providerConfig = providerConfig;
|
||||
this._notificationConfig = notificationConfig;
|
||||
this._spatialFilter = spatialFilter;
|
||||
this._providerId = providerId;
|
||||
this._jobKey = jobKey;
|
||||
this._similarityCache = similarityCache;
|
||||
this._browser = browser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +96,7 @@ class FredyPipelineExecutioner {
|
||||
.then(this._save.bind(this))
|
||||
.then(this._calculateDistance.bind(this))
|
||||
.then(this._filterBySimilarListings.bind(this))
|
||||
.then(this._filterByArea.bind(this))
|
||||
.then(this._notify.bind(this))
|
||||
.catch(this._handleError.bind(this));
|
||||
}
|
||||
@@ -111,6 +120,47 @@ class FredyPipelineExecutioner {
|
||||
return newListings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter listings by area using the provider's area filter if available.
|
||||
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
||||
*
|
||||
* @param {Listing[]} newListings New listings to filter by area.
|
||||
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
|
||||
*/
|
||||
_filterByArea(newListings) {
|
||||
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
||||
|
||||
// If no area filter is set, return all listings
|
||||
if (!polygonFeatures?.length) {
|
||||
return newListings;
|
||||
}
|
||||
|
||||
const filteredIds = [];
|
||||
// Filter listings by area - keep only those within the polygon
|
||||
const keptListings = newListings.filter((listing) => {
|
||||
// If listing doesn't have coordinates, keep it (don't filter out)
|
||||
if (listing.latitude == null || listing.longitude == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the point is inside the polygons
|
||||
const point = [listing.longitude, listing.latitude]; // GeoJSON format: [lon, lat]
|
||||
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
||||
|
||||
if (!isInPolygon) {
|
||||
filteredIds.push(listing.id);
|
||||
}
|
||||
|
||||
return isInPolygon;
|
||||
});
|
||||
|
||||
if (filteredIds.length > 0) {
|
||||
deleteListingsById(filteredIds);
|
||||
}
|
||||
|
||||
return keptListings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch listings from the provider, using the default Extractor flow unless
|
||||
* a provider-specific getListings override is supplied.
|
||||
@@ -119,7 +169,7 @@ class FredyPipelineExecutioner {
|
||||
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||
*/
|
||||
_getListings(url) {
|
||||
const extractor = new Extractor();
|
||||
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
||||
return new Promise((resolve, reject) => {
|
||||
extractor
|
||||
.execute(url, this._providerConfig.waitForSelector)
|
||||
@@ -250,7 +300,8 @@ class FredyPipelineExecutioner {
|
||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||
*/
|
||||
_filterBySimilarListings(listings) {
|
||||
return listings.filter((listing) => {
|
||||
const filteredIds = [];
|
||||
const keptListings = listings.filter((listing) => {
|
||||
const similar = this._similarityCache.checkAndAddEntry({
|
||||
title: listing.title,
|
||||
address: listing.address,
|
||||
@@ -260,9 +311,16 @@ class FredyPipelineExecutioner {
|
||||
logger.debug(
|
||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||
);
|
||||
filteredIds.push(listing.id);
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
|
||||
if (filteredIds.length > 0) {
|
||||
deleteListingsById(filteredIds);
|
||||
}
|
||||
|
||||
return keptListings;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export const FEATURES = {
|
||||
export const TRACKING_POIS = {
|
||||
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||
WELCOME_FINISHED: 'WELCOME_FINISHED',
|
||||
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
|
||||
};
|
||||
@@ -23,6 +23,8 @@ import { listingsRouter } from './routes/listingsRouter.js';
|
||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
@@ -36,6 +38,7 @@ service.use('/api/version', authInterceptor());
|
||||
service.use('/api/listings', authInterceptor());
|
||||
service.use('/api/dashboard', authInterceptor());
|
||||
service.use('/api/user/settings', authInterceptor());
|
||||
service.use('/api/tracking', authInterceptor());
|
||||
|
||||
// /admin can only be accessed when user is having admin permissions
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
@@ -50,9 +53,13 @@ service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
service.use('/api/dashboard', dashboardRouter);
|
||||
service.use('/api/tracking', trackingRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
|
||||
registerMcpRoutes(service);
|
||||
|
||||
service.start(PORT).then(() => {
|
||||
logger.debug(`Started API service on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -163,7 +163,16 @@ jobRouter.post('/:jobId/run', async (req, res) => {
|
||||
});
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||
const {
|
||||
provider,
|
||||
notificationAdapter,
|
||||
name,
|
||||
blacklist = [],
|
||||
jobId,
|
||||
enabled,
|
||||
shareWithUsers = [],
|
||||
spatialFilter = null,
|
||||
} = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
let jobFromDb = jobStorage.getJob(jobId);
|
||||
@@ -187,6 +196,7 @@ jobRouter.post('/', async (req, res) => {
|
||||
provider,
|
||||
notificationAdapter,
|
||||
shareWithUsers,
|
||||
spatialFilter,
|
||||
});
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
|
||||
@@ -107,7 +107,7 @@ listingsRouter.post('/watch', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/job', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const { jobId, hardDelete = false } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
if (settings.demoMode) {
|
||||
@@ -115,7 +115,7 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
listingStorage.deleteListingsByJobId(jobId);
|
||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
@@ -124,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/', async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
const { ids, hardDelete = false } = req.body;
|
||||
try {
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
listingStorage.deleteListingsById(ids);
|
||||
listingStorage.deleteListingsById(ids, hardDelete);
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import restana from 'restana';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const notificationAdapterRouter = service.newRouter();
|
||||
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
||||
@@ -34,11 +36,14 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
||||
serviceName: 'TestCall',
|
||||
newListings: [
|
||||
{
|
||||
price: '42 €',
|
||||
title: 'This is a test listing',
|
||||
address: 'some address',
|
||||
size: '666 2m',
|
||||
link: 'https://www.orange-coding.net',
|
||||
address: 'Heidestrasse 17, 51147 Köln',
|
||||
description: exampleDescription,
|
||||
id: '1',
|
||||
imageUrl: 'https://placehold.co/600x400/png',
|
||||
price: '1.000 €',
|
||||
size: '76 m²',
|
||||
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
||||
url: 'https://www.orange-coding.net',
|
||||
},
|
||||
],
|
||||
notificationConfig,
|
||||
@@ -46,6 +51,7 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
||||
});
|
||||
res.send();
|
||||
} catch (Exception) {
|
||||
logger.error('Error during notification adapter test:', Exception);
|
||||
res.send(new Error(Exception));
|
||||
}
|
||||
});
|
||||
@@ -54,3 +60,51 @@ notificationAdapterRouter.get('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
export { notificationAdapterRouter };
|
||||
|
||||
const exampleDescription = `
|
||||
Wohnungstyp: Etagenwohnung
|
||||
Nutzfläche: 76 m²
|
||||
Etage: 2 von 3
|
||||
Schlafzimmer: 1
|
||||
Badezimmer: 1
|
||||
Bezugsfrei ab: 1.4.2026
|
||||
Haustiere: Nein
|
||||
Garage/Stellplatz: Tiefgarage
|
||||
Anzahl Garage/Stellplatz: 1
|
||||
Kaltmiete (zzgl. Nebenkosten): 1.000 €
|
||||
Preis/m²: 13,16 €/m²
|
||||
Nebenkosten: 230 €
|
||||
Heizkosten in Nebenkosten enthalten: Ja
|
||||
Gesamtmiete: 1.230 €
|
||||
Kaution: 3.000,00
|
||||
Preis pro Parkfläche: 60 €
|
||||
Baujahr: 2000
|
||||
Objektzustand: Modernisiert
|
||||
Qualität der Ausstattung: Gehoben
|
||||
Heizungsart: Fernwärme
|
||||
Energieausweistyp: Verbrauchsausweis
|
||||
Energieausweis: liegt vor
|
||||
Endenergieverbrauch: 72 kWh/(m²∙a)
|
||||
Baujahr laut Energieausweis: 2000
|
||||
|
||||
Diese moderne 3-Zimmer-Wohnung liegt direkt neben einem Park und nur wenige Minuten von der S-Bahn-Haltestelle entfernt. Das Stadtzentrum sowie Freizeiteinrichtungen sind 1,5 km entfernt.
|
||||
|
||||
Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
|
||||
|
||||
Ausstattung:
|
||||
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
||||
- sonniger Balkon (Süd)
|
||||
- Tiefgaragenstellplatz
|
||||
- Kellerabteil
|
||||
- gepflegtes Mehrfamilienhaus
|
||||
|
||||
Die Küche ist vom Mieter nach eigenen Wünschen einzurichten.
|
||||
|
||||
Vermietung direkt vom Eigentümer - provisionsfrei!
|
||||
|
||||
Lage:
|
||||
• Park: 1 Minute zu Fuß
|
||||
• S-Bahn Station: 2 Minuten zu Fuß
|
||||
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
||||
• Gute Anbindung Richtung Großstadt und Flughafen
|
||||
`;
|
||||
|
||||
37
lib/api/routes/trackingRoute.js
Normal file
37
lib/api/routes/trackingRoute.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const trackingRouter = service.newRouter();
|
||||
|
||||
trackingRouter.get('/trackingPois', async (req, res) => {
|
||||
res.body = TRACKING_POIS;
|
||||
res.send();
|
||||
});
|
||||
|
||||
trackingRouter.post('/poi', async (req, res) => {
|
||||
const { poi } = req.body;
|
||||
if (!poi) {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'Feature name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await trackPoi(poi);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error tracking feature', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { trackingRouter };
|
||||
@@ -10,8 +10,8 @@ import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/li
|
||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||
import { fromJson } from '../../utils.js';
|
||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||
import { FEATURES } from '../../features.js';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||
|
||||
@@ -53,7 +53,7 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
|
||||
try {
|
||||
if (home_address) {
|
||||
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
|
||||
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
||||
const coords = await geocodeAddress(home_address);
|
||||
if (coords && coords.lat !== -1) {
|
||||
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||
@@ -76,4 +76,46 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { news_hash } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ news_hash }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating news hash', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { immoscout_details } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating immoscout details setting', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { userSettingsRouter };
|
||||
|
||||
275
lib/mcp/README.md
Normal file
275
lib/mcp/README.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Fredy MCP Server
|
||||
|
||||
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
|
||||
|
||||
- **Stdio**: for local LLM clients (Claude Desktop, LM Studio, llm-cli, mcp-cli, etc.)
|
||||
- **Streamable HTTP**: for remote LLM clients (ChatGPT, cloud-hosted agents, etc.)
|
||||
|
||||
## Authentication
|
||||
|
||||
All MCP access is **token-based** based. Every Fredy user is automatically assigned a **permanent, non-expiring MCP token** when their account is created. This token is a secret and should be treated like a password.
|
||||
|
||||
### Where to find your token
|
||||
|
||||
MCP tokens are displayed in the **User Management** list (Admin → Users). Each user's token is shown in the **"MCP Token"** column.
|
||||
|
||||
> **Important:** MCP tokens never expire. They are permanent secrets tied to each user account. If a token is compromised, you must change the token! If you chose to use a token from an admin account, the LLM can query information from ALL jobs/listings.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|--------------------------------------------------------------------------------|
|
||||
| `list_jobs` | List real estate search jobs with pagination and text filtering |
|
||||
| `get_job` | Get detailed information about a specific job |
|
||||
| `list_listings` | Search and list real estate listings with pagination, text search, and filters |
|
||||
| `get_listing` | Get full details of a single listing |
|
||||
| `get_current_date_time` | Gets the current date/time for the llm to be used |
|
||||
|
||||
### Tool Details
|
||||
|
||||
#### list_jobs
|
||||
- `page` (number, optional) – Page number (default: 1)
|
||||
- `pageSize` (number, optional) – Results per page (default: 50, max: 1000). Use pagination to fetch more.
|
||||
- `filter` (string, optional) – Free-text filter on job name
|
||||
|
||||
Response: markdown table with columns ID, Name, Enabled, Active Listings. Includes summary and pagination info.
|
||||
|
||||
#### get_job
|
||||
- `jobId` (string, required) – The job ID to retrieve
|
||||
|
||||
#### list_listings
|
||||
- `page` (number, optional) – Page number (default: 1)
|
||||
- `pageSize` (number, optional) – Results per page (default: 50, max: 1000). Use pagination to fetch more.
|
||||
- `filter` (string, optional) – Free-text search across title, address, provider, link
|
||||
- `jobId` (string, optional) – Filter listings by job ID
|
||||
- `activeOnly` (boolean, optional) – When true, only show active listings
|
||||
- `provider` (string, optional) – Filter by provider name
|
||||
- `createdAfter` (number, optional) – Only include listings created at or after this unix timestamp in milliseconds (e.g. `1772008362564`). Useful for queries like "give me all listings from today".
|
||||
- `createdBefore` (number, optional) – Only include listings created at or before this unix timestamp in milliseconds (e.g. `1772008362564`).
|
||||
- `minPrice` (number, optional) – Only include listings with price >= this value (e.g. `500`). Numeric, no currency symbol.
|
||||
- `maxPrice` (number, optional) – Only include listings with price <= this value (e.g. `1500`). Numeric, no currency symbol.
|
||||
- `sortField` (string, optional) – Sort by: created_at, price, size, provider, title, is_active
|
||||
- `sortDir` (string, optional) – Sort direction: asc or desc
|
||||
|
||||
Response: markdown table with columns ID, Title, Address, Price, Size, Provider, Active, Created, Job. Includes summary and pagination info. Use `get_listing` for full details.
|
||||
|
||||
> **Note:** All timestamps are **unix timestamps in milliseconds** (e.g. `1772008362564`), not seconds.
|
||||
|
||||
#### get_listing
|
||||
- `listingId` (string, required) – The listing ID to retrieve
|
||||
|
||||
## Usage with Local LLM (stdio transport)
|
||||
|
||||
The stdio transport communicates over stdin/stdout and is ideal for local LLM tools.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
MCP_TOKEN=fredy_<your-token> node mcp/stdio.js
|
||||
# or
|
||||
MCP_TOKEN=fredy_<your-token> yarn mcp:stdio
|
||||
```
|
||||
|
||||
### Testing with MCP Inspector
|
||||
|
||||
The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) lets you interactively test your MCP server in a browser UI.
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector -e MCP_TOKEN=fredy_<your-token> -- node mcp/stdio.js
|
||||
```
|
||||
|
||||
Once the inspector is running, open the URL shown in your terminal (usually `http://localhost:6274`). You can then:
|
||||
1. Click **Connect** to establish the stdio connection
|
||||
2. Go to the **Tools** tab to see all available tools
|
||||
3. Select a tool, fill in parameters, and click **Run** to test it
|
||||
|
||||
### LM Studio Configuration
|
||||
|
||||
[LM Studio](https://lmstudio.ai/) supports MCP servers natively, allowing your local LLM to access Fredy's jobs and listings data.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Open **LM Studio** and load a model that supports tool use (e.g., Qwen 2.5, Llama 3.1, Mistral, etc.)
|
||||
2. In the right side under **Integrations** click on "# install" and "edit mcp.json"
|
||||
3. Edit the LM Studio MCP config file directly (`~/.lmstudio/config/mcp.json` or via the UI export):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fredy": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/fredy/mcp/stdio.js"],
|
||||
"env": {
|
||||
"MCP_TOKEN": "fredy_<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Toggle the server **on**: LM Studio will spawn the stdio process and connect
|
||||
5. You should see the Fredy tools appear as available tools
|
||||
|
||||
#### Suggestion on LLM
|
||||
After testing numerous LLM's, I got the best results with Qwen 3.5 or Qwen 2.5.. E.g. `Qwen2.5-14B-Instruct-1M-8bit`.
|
||||
|
||||
#### Usage
|
||||
|
||||
Once connected, simply ask your LLM about your real estate data in natural language:
|
||||
|
||||
- *"Show me all my active search jobs"*
|
||||
- *"List the latest listings from my Berlin apartment search"*
|
||||
- *"Get details for listing XYZ"*
|
||||
- *"What are the cheapest listings across all my jobs?"*
|
||||
|
||||
The LLM will automatically call the appropriate Fredy MCP tools and present the results.
|
||||
|
||||
> **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied).
|
||||
|
||||
## Usage with Remote LLM (Streamable HTTP transport)
|
||||
|
||||
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
|
||||
|
||||
```
|
||||
POST /api/mcp – JSON-RPC messages (initialize, tool calls)
|
||||
GET /api/mcp – SSE stream for server-initiated notifications
|
||||
DELETE /api/mcp – Terminate session
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All requests must include the token as a Bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer fredy_<your-token>
|
||||
```
|
||||
|
||||
### Example: Initialize a session
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9998/api/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer fredy_<your-token>" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test-client", "version": "1.0.0" }
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: Call a tool
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9998/api/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer fredy_<your-token>" \
|
||||
-H "Mcp-Session-Id: <session-id-from-init-response>" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_jobs",
|
||||
"arguments": { "page": 1, "pageSize": 10 }
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Every user is automatically assigned a permanent MCP token at account creation – **tokens never expire**
|
||||
- Tokens are cryptographically random (256-bit) and prefixed with `fredy_`
|
||||
- Each token is scoped to a single user – the LLM can only access that user's data
|
||||
- Non-admin users only see their own jobs and jobs shared with them
|
||||
- Tokens are stored in the `mcp_token` column of the `users` table
|
||||
- Tokens are deleted automatically when the owning user is removed
|
||||
- The `/api/mcp` endpoint uses Bearer token auth (independent of cookie-session)
|
||||
- Treat MCP tokens like passwords – do not share them publicly
|
||||
|
||||
## Response Format
|
||||
|
||||
All tool responses use **markdown** instead of JSON for maximum LLM readability and token efficiency:
|
||||
|
||||
- **List responses** (list_jobs, list_listings) use markdown tables with a summary line and pagination footer
|
||||
- **Detail responses** (get_job, get_listing) use markdown key-value lists
|
||||
- **Error responses** include the tool name and error message
|
||||
|
||||
Example list response:
|
||||
|
||||
```
|
||||
**Tool:** list_listings | **Status:** OK
|
||||
|
||||
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available — use page=2 to continue.
|
||||
|
||||
| ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|
||||
|----|-------|---------|-------|------|----------|--------|---------|-----|
|
||||
| abc123 | Nice flat | Berlin | 1200 | 70 | immoscout | yes | 2026-02-25 10:30:00 | My Search |
|
||||
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
|
||||
|
||||
Use **get_listing** with an ID for full details (description, link, image).
|
||||
|
||||
**Page:** 1/2 | **Has more:** yes
|
||||
```
|
||||
|
||||
Example detail response:
|
||||
|
||||
```
|
||||
**Tool:** get_listing | **Status:** OK
|
||||
|
||||
### Listing: Nice flat
|
||||
|
||||
- **ID:** abc123
|
||||
- **Title:** Nice flat
|
||||
- **Address:** Berlin
|
||||
- **Price:** 1200
|
||||
- **Size:** 70
|
||||
- **Provider:** immoscout
|
||||
- **Link:** https://...
|
||||
- **Active:** yes
|
||||
- **Created:** 2026-02-25 10:30:00
|
||||
```
|
||||
|
||||
Markdown is used because it is significantly more token-efficient than JSON (~40-60% fewer tokens for tabular data) and natively understood by all LLMs.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ stdio ┌──────────────┐
|
||||
│ Local LLM │◄──────────────►│ mcp/stdio.js│
|
||||
│ (LM Studio, │ │ (transport) │
|
||||
│ Claude, etc.) │ │ │
|
||||
└─────────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌─────────────────┐ Streamable HTTP ┌────┴────────┐
|
||||
│ Remote LLM │◄───────────────►│ /api/mcp │
|
||||
│ │ (Bearer token) │ (transport) │
|
||||
└─────────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ mcpAuthentication │
|
||||
│ (token validation, │
|
||||
│ access control) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ mcpAdapter.js │
|
||||
│ (tool routing │
|
||||
│ + data fetch) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ mcpNormalizer.js│
|
||||
│ (markdown │
|
||||
│ formatting) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Fredy DB │
|
||||
│ (SQLite) │
|
||||
└──────────────┘
|
||||
```
|
||||
231
lib/mcp/mcpAdapter.js
Normal file
231
lib/mcp/mcpAdapter.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { queryJobs, getJob } from '../services/storage/jobStorage.js';
|
||||
import { queryListings, getListingById } from '../services/storage/listingsStorage.js';
|
||||
import { authenticateToolCall, checkJobAccess } from './mcpAuthentication.js';
|
||||
import {
|
||||
normalizeListJobs,
|
||||
normalizeGetJob,
|
||||
normalizeListListings,
|
||||
normalizeGetListing,
|
||||
normalizeError,
|
||||
} from './mcpNormalizer.js';
|
||||
|
||||
/**
|
||||
* Create a configured MCP server instance with all Fredy tools registered.
|
||||
*
|
||||
* The adapter fetches raw data from storage and delegates response formatting
|
||||
* to the normalizer layer (mcpNormalizer.js) which produces a consistent
|
||||
* { ok, summary, data, meta } envelope for every tool response.
|
||||
*
|
||||
* Each tool call requires a userId (resolved from the MCP token before invocation).
|
||||
* Tools respect user scoping: non-admin users only see their own jobs/listings.
|
||||
*
|
||||
* @returns {McpServer}
|
||||
*/
|
||||
export function createMcpServer() {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'fredy-mcp',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions:
|
||||
'Fredy MCP Server – query real estate jobs and listings. ' +
|
||||
'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' +
|
||||
'Use list_jobs to browse jobs, get_job for details, ' +
|
||||
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
|
||||
'and get_listing for full details of a single listing. ' +
|
||||
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
|
||||
'Always present results to the user as soon as you have them — do NOT call the tool again unless you need additional pages or different data.',
|
||||
},
|
||||
);
|
||||
|
||||
// ── list_jobs ───────────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'list_jobs',
|
||||
'List real estate search jobs for the authenticated user. ' +
|
||||
'Returns up to 50 jobs per page by default. Use pagination (page parameter) to fetch more. ' +
|
||||
'Check meta.hasMore to know if there are additional pages.',
|
||||
{
|
||||
page: z.number().optional().describe('Page number (default: 1)'),
|
||||
pageSize: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'),
|
||||
filter: z.string().optional().describe('Free-text filter on job name'),
|
||||
},
|
||||
async ({ page, pageSize, filter }, extra) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'list_jobs');
|
||||
if (error) return normalizeError(error, 'list_jobs');
|
||||
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 50;
|
||||
|
||||
const result = queryJobs({
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
freeTextFilter: filter,
|
||||
userId: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
return normalizeListJobs(result, { page: safePage, pageSize: safePageSize });
|
||||
},
|
||||
);
|
||||
|
||||
// ── get_job ─────────────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'get_job',
|
||||
'Get detailed information about a specific job by its ID.',
|
||||
{
|
||||
jobId: z.string().describe('The job ID to retrieve'),
|
||||
},
|
||||
async ({ jobId }, extra) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'get_job');
|
||||
if (error) return normalizeError(error, 'get_job');
|
||||
|
||||
const job = getJob(jobId);
|
||||
if (!job) {
|
||||
return normalizeError('Job not found.', 'get_job');
|
||||
}
|
||||
|
||||
if (!checkJobAccess(user, job)) {
|
||||
return normalizeError('Access denied.', 'get_job');
|
||||
}
|
||||
|
||||
return normalizeGetJob(job);
|
||||
},
|
||||
);
|
||||
|
||||
// ── list_listings ───────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'list_listings',
|
||||
'Search and list real estate listings. Returns up to 50 listings per page by default. ' +
|
||||
'Use pagination (page parameter) to fetch more. Check meta.hasMore in the response. ' +
|
||||
'Supports text search, time filtering, and various filters. ' +
|
||||
'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' +
|
||||
'Use createdAfter/createdBefore to filter by time, e.g. "give me all listings from today". ' +
|
||||
'Use get_listing to get full details (description, link, image) for a specific listing.',
|
||||
{
|
||||
page: z.number().optional().describe('Page number (default: 1)'),
|
||||
pageSize: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'),
|
||||
filter: z.string().optional().describe('Free-text search across title, address, provider, link'),
|
||||
jobId: z.string().optional().describe('Filter listings by job ID'),
|
||||
activeOnly: z.boolean().optional().describe('When true, only show active listings'),
|
||||
provider: z.string().optional().describe('Filter by provider name'),
|
||||
createdAfter: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings created at or after this unix timestamp in milliseconds (e.g. 1772008362564). Useful for queries like "listings from today".',
|
||||
),
|
||||
createdBefore: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings created at or before this unix timestamp in milliseconds (e.g. 1772008362564).',
|
||||
),
|
||||
minPrice: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings with price >= this value (e.g. 500). Price is a numeric value (no currency symbol).',
|
||||
),
|
||||
maxPrice: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings with price <= this value (e.g. 1500). Price is a numeric value (no currency symbol).',
|
||||
),
|
||||
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'),
|
||||
},
|
||||
async (
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
filter,
|
||||
jobId,
|
||||
activeOnly,
|
||||
provider,
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sortField,
|
||||
sortDir,
|
||||
},
|
||||
extra,
|
||||
) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'list_listings');
|
||||
if (error) return normalizeError(error, 'list_listings');
|
||||
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 50;
|
||||
|
||||
const result = queryListings({
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
freeTextFilter: filter,
|
||||
jobIdFilter: jobId,
|
||||
activityFilter: activeOnly === true ? true : activeOnly === false ? false : undefined,
|
||||
providerFilter: provider,
|
||||
createdAfter: createdAfter ?? null,
|
||||
createdBefore: createdBefore ?? null,
|
||||
minPrice: minPrice ?? null,
|
||||
maxPrice: maxPrice ?? null,
|
||||
sortField: sortField ?? null,
|
||||
sortDir: sortDir ?? 'desc',
|
||||
userId: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
return normalizeListListings(result, { page: safePage, pageSize: safePageSize });
|
||||
},
|
||||
);
|
||||
|
||||
// ── get_listing ─────────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'get_listing',
|
||||
'Get full details of a single listing by its ID.',
|
||||
{
|
||||
listingId: z.string().describe('The listing ID to retrieve'),
|
||||
},
|
||||
async ({ listingId }, extra) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'get_listing');
|
||||
if (error) return normalizeError(error, 'get_listing');
|
||||
|
||||
const listing = getListingById(listingId, user.id, user.isAdmin);
|
||||
if (!listing) {
|
||||
return normalizeError('Listing not found or access denied.', 'get_listing');
|
||||
}
|
||||
|
||||
return normalizeGetListing(listing);
|
||||
},
|
||||
);
|
||||
|
||||
// ── get_current_date_ime ─────────────────────────────────────────────────────
|
||||
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Timestring: ${new Date().toLocaleString()}, MS since 1970: ${Date.now()}` }],
|
||||
};
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
66
lib/mcp/mcpAuthentication.js
Normal file
66
lib/mcp/mcpAuthentication.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Authentication Layer
|
||||
*
|
||||
* Centralizes all authentication and authorization logic for MCP tool calls
|
||||
* and HTTP requests. Ensures consistent access control across all transports.
|
||||
*/
|
||||
|
||||
import { getUser, validateMcpToken } from '../services/storage/userStorage.js';
|
||||
|
||||
/**
|
||||
* Authenticate an MCP tool call by extracting and validating the user from authInfo.
|
||||
*
|
||||
* @param {{ authInfo?: { userId?: string } }} extra - The extra context passed by the MCP SDK.
|
||||
* @returns {{ user: object|null, error: string|null }}
|
||||
* - On success: { user: <userObject>, error: null }
|
||||
* - On failure: { user: null, error: <errorMessage> }
|
||||
*/
|
||||
export function authenticateToolCall(extra) {
|
||||
const userId = extra?.authInfo?.userId;
|
||||
if (!userId) {
|
||||
return { user: null, error: 'Authentication required. Please provide a valid MCP API token.' };
|
||||
}
|
||||
|
||||
const user = getUser(userId);
|
||||
if (!user) {
|
||||
return { user: null, error: 'Authentication required. Please provide a valid MCP API token.' };
|
||||
}
|
||||
|
||||
return { user, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user has access to a specific job.
|
||||
* Admins have access to all jobs. Non-admins can only access their own jobs
|
||||
* or jobs explicitly shared with them.
|
||||
*
|
||||
* @param {object} user - The authenticated user object.
|
||||
* @param {object} job - The job object from storage.
|
||||
* @returns {boolean} True if the user is allowed to access this job.
|
||||
*/
|
||||
export function checkJobAccess(user, job) {
|
||||
if (user.isAdmin) return true;
|
||||
if (job.userId === user.id) return true;
|
||||
if (Array.isArray(job.shared_with_user) && job.shared_with_user.includes(user.id)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an HTTP request by extracting and validating the Bearer token
|
||||
* from the Authorization header.
|
||||
*
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {{ userId: string } | null} The authenticated user info, or null if invalid.
|
||||
*/
|
||||
export function authenticateRequest(req) {
|
||||
const authHeader = req.headers['authorization'] || '';
|
||||
if (!authHeader.startsWith('Bearer ')) return null;
|
||||
const token = authHeader.slice(7).trim();
|
||||
if (!token) return null;
|
||||
return validateMcpToken(token);
|
||||
}
|
||||
131
lib/mcp/mcpHttpRoute.js
Normal file
131
lib/mcp/mcpHttpRoute.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { createMcpServer } from './mcpAdapter.js';
|
||||
import { authenticateRequest } from './mcpAuthentication.js';
|
||||
import logger from '../services/logger.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Active transports keyed by session id.
|
||||
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
|
||||
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
||||
*/
|
||||
const sessions = new Map();
|
||||
|
||||
/**
|
||||
* Get or create a session for the given session id with authentication.
|
||||
* @param {string|undefined} sessionId
|
||||
* @param {{ userId: string }} auth
|
||||
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
|
||||
*/
|
||||
function getOrCreateSession(sessionId, auth) {
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (sid) => {
|
||||
sessions.set(sid, entry);
|
||||
logger.debug(`MCP session created: ${sid}`);
|
||||
},
|
||||
});
|
||||
|
||||
const server = createMcpServer();
|
||||
const entry = { server, transport, userId: auth.userId };
|
||||
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid) {
|
||||
sessions.delete(sid);
|
||||
logger.debug(`MCP session closed: ${sid}`);
|
||||
}
|
||||
};
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register MCP Streamable HTTP routes on a restana service.
|
||||
*
|
||||
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol:
|
||||
* - POST /api/mcp – JSON-RPC messages (initialize, tool calls, etc.)
|
||||
* - GET /api/mcp – SSE stream for server-initiated notifications
|
||||
* - DELETE /api/mcp – session termination
|
||||
*
|
||||
* All endpoints require a valid Bearer token in the Authorization header.
|
||||
*
|
||||
* @param {import('restana').Service} service - The restana service instance.
|
||||
*/
|
||||
export function registerMcpRoutes(service) {
|
||||
// POST – main JSON-RPC endpoint
|
||||
service.post('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
const { server, transport } = getOrCreateSession(sessionId, auth);
|
||||
|
||||
// Connect server to transport if not already connected
|
||||
if (!transport.onmessage) {
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
// Inject authInfo so tools can access the authenticated user
|
||||
req.auth = { userId: auth.userId };
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
// GET – SSE stream for server-initiated messages
|
||||
service.get('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.statusCode = 400;
|
||||
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
||||
}
|
||||
|
||||
const { transport } = sessions.get(sessionId);
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// DELETE – terminate session
|
||||
service.delete('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.statusCode = 404;
|
||||
return res.send({ error: 'Session not found.' });
|
||||
}
|
||||
|
||||
const { transport } = sessions.get(sessionId);
|
||||
await transport.close();
|
||||
sessions.delete(sessionId);
|
||||
res.statusCode = 200;
|
||||
res.send({ ok: true });
|
||||
});
|
||||
|
||||
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
||||
}
|
||||
180
lib/mcp/mcpNormalizer.js
Normal file
180
lib/mcp/mcpNormalizer.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Response Normalizer
|
||||
*
|
||||
* Transforms raw adapter data into LLM-friendly markdown responses.
|
||||
* Markdown is significantly better than JSON for LLM consumption because:
|
||||
* - LLMs are trained extensively on markdown text
|
||||
* - Markdown tables are ~40-60% more token-efficient than JSON arrays
|
||||
* - Less syntactic noise (no quotes, brackets, commas around every value)
|
||||
* - Natively readable and structured
|
||||
*
|
||||
* Each response follows a consistent structure:
|
||||
* 1. Status line (OK/ERROR + tool name)
|
||||
* 2. Summary (human-readable description)
|
||||
* 3. Data (markdown table for lists, key-value for single items)
|
||||
* 4. Pagination info (for list responses)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wrap a markdown string as an MCP text content result.
|
||||
* @param {string} markdown
|
||||
* @param {boolean} [isError=false]
|
||||
* @returns {{ content: Array, isError?: boolean }}
|
||||
*/
|
||||
function toMcpResponse(markdown, isError = false) {
|
||||
const result = {
|
||||
content: [{ type: 'text', text: markdown }],
|
||||
};
|
||||
if (isError) result.isError = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a unix timestamp (ms) as a human-readable date string.
|
||||
* @param {number|null|undefined} ts
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDate(ts) {
|
||||
if (ts == null) return '–';
|
||||
return new Date(ts)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape pipe characters in table cell values.
|
||||
* @param {*} val
|
||||
* @returns {string}
|
||||
*/
|
||||
function cell(val) {
|
||||
if (val == null) return '–';
|
||||
return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a list_jobs response.
|
||||
* @param {{ totalNumber: number, page: number, result: object[] }} queryResult
|
||||
* @param {{ page: number, pageSize: number }} params
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeListJobs(queryResult, { page, pageSize }) {
|
||||
const maxPage = Math.max(1, Math.ceil(queryResult.totalNumber / pageSize));
|
||||
const hasMore = page < maxPage;
|
||||
const jobs = queryResult.result;
|
||||
|
||||
let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
|
||||
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
|
||||
if (hasMore) md += ` More pages available — use page=${page + 1} to continue.`;
|
||||
md += '\n\n';
|
||||
|
||||
if (jobs.length > 0) {
|
||||
md += `| ID | Name | Enabled | Active Listings |\n`;
|
||||
md += `|----|------|---------|----------------|\n`;
|
||||
for (const j of jobs) {
|
||||
md += `| ${cell(j.id)} | ${cell(j.name)} | ${j.enabled ? 'yes' : 'no'} | ${j.numberOfFoundListings ?? 0} |\n`;
|
||||
}
|
||||
} else {
|
||||
md += `No jobs found.\n`;
|
||||
}
|
||||
|
||||
md += `\n**Page:** ${page}/${maxPage} | **Has more:** ${hasMore ? 'yes' : 'no'}`;
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a get_job response.
|
||||
* @param {object} job - The job object from storage.
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeGetJob(job) {
|
||||
const providers = (job.provider ?? []).map((p) => p.id || p);
|
||||
|
||||
let md = `**Tool:** get_job | **Status:** OK\n\n`;
|
||||
md += `### Job: ${job.name || job.id}\n\n`;
|
||||
md += `- **ID:** ${job.id}\n`;
|
||||
md += `- **Name:** ${job.name || '–'}\n`;
|
||||
md += `- **Enabled:** ${job.enabled ? 'yes' : 'no'}\n`;
|
||||
md += `- **Active Listings:** ${job.numberOfFoundListings ?? 0}\n`;
|
||||
md += `- **Providers:** ${providers.length > 0 ? providers.join(', ') : '–'}\n`;
|
||||
md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : '–'}\n`;
|
||||
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a list_listings response.
|
||||
* @param {{ totalNumber: number, page: number, result: object[] }} queryResult
|
||||
* @param {{ page: number, pageSize: number }} params
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeListListings(queryResult, { page, pageSize }) {
|
||||
const maxPage = Math.max(1, Math.ceil(queryResult.totalNumber / pageSize));
|
||||
const hasMore = page < maxPage;
|
||||
const listings = queryResult.result;
|
||||
|
||||
let md = `**Tool:** list_listings | **Status:** OK\n\n`;
|
||||
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
|
||||
if (hasMore) md += ` More pages available — use page=${page + 1} to continue.`;
|
||||
md += '\n\n';
|
||||
|
||||
if (listings.length > 0) {
|
||||
md += `| ID | Title | Address | Price | Size | Provider | Active | 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 += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
|
||||
} else {
|
||||
md += `No listings found.\n`;
|
||||
}
|
||||
|
||||
md += `\n**Page:** ${page}/${maxPage} | **Has more:** ${hasMore ? 'yes' : 'no'}`;
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a get_listing response.
|
||||
* @param {object} listing - The listing object from storage.
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeGetListing(listing) {
|
||||
let md = `**Tool:** get_listing | **Status:** OK\n\n`;
|
||||
md += `### Listing: ${listing.title || listing.id}\n\n`;
|
||||
md += `- **ID:** ${listing.id}\n`;
|
||||
md += `- **Title:** ${listing.title || '–'}\n`;
|
||||
md += `- **Description:** ${listing.description || '–'}\n`;
|
||||
md += `- **Address:** ${listing.address || '–'}\n`;
|
||||
md += `- **Price:** ${listing.price ?? '–'}\n`;
|
||||
md += `- **Size:** ${listing.size ?? '–'}\n`;
|
||||
md += `- **Provider:** ${listing.provider || '–'}\n`;
|
||||
md += `- **Link:** ${listing.link || '–'}\n`;
|
||||
md += `- **Image:** ${listing.image_url || '–'}\n`;
|
||||
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
|
||||
md += `- **Created:** ${formatDate(listing.created_at)}\n`;
|
||||
md += `- **Job:** ${listing.job_name || '–'}\n`;
|
||||
if (listing.latitude != null && listing.longitude != null) {
|
||||
md += `- **Location:** ${listing.latitude}, ${listing.longitude}\n`;
|
||||
}
|
||||
if (listing.distance_to_destination != null) {
|
||||
md += `- **Distance to destination:** ${listing.distance_to_destination}\n`;
|
||||
}
|
||||
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an error response.
|
||||
* @param {string} message - The error message.
|
||||
* @param {string} [tool] - Optional tool name for context.
|
||||
* @returns {{ content: Array, isError: boolean }}
|
||||
*/
|
||||
export function normalizeError(message, tool) {
|
||||
const md = `**Tool:** ${tool ?? 'unknown'} | **Status:** ERROR\n\n${message}`;
|
||||
return toMcpResponse(md, true);
|
||||
}
|
||||
76
lib/mcp/stdio.js
Normal file
76
lib/mcp/stdio.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
/**
|
||||
* Fredy MCP Server – stdio transport
|
||||
*
|
||||
* Launches the MCP server over stdin/stdout so that local LLM clients
|
||||
* (e.g. Claude Desktop, llm-cli, mcp-cli) can connect directly.
|
||||
*
|
||||
* Usage:
|
||||
* MCP_TOKEN=fredy_<your-token> node mcp/stdio.js
|
||||
*
|
||||
* The MCP_TOKEN environment variable must contain a valid Fredy MCP token.
|
||||
* Each user has a permanent, non-expiring token shown in the user management list.
|
||||
*/
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import SqliteConnection from '../services/storage/SqliteConnection.js';
|
||||
import { runMigrations } from '../services/storage/migrations/migrate.js';
|
||||
import { createMcpServer } from './mcpAdapter.js';
|
||||
import { validateMcpToken } from '../services/storage/userStorage.js';
|
||||
|
||||
// Ensure cwd is the project root so that relative DB/config paths resolve correctly
|
||||
// (LM Studio and other MCP hosts may spawn this process from an arbitrary directory)
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
process.chdir(path.resolve(__dirname, '..', '..'));
|
||||
|
||||
// Initialize the database (required for standalone usage)
|
||||
await SqliteConnection.init();
|
||||
await runMigrations();
|
||||
|
||||
const token = process.env.MCP_TOKEN;
|
||||
if (!token) {
|
||||
process.stderr.write('Error: MCP_TOKEN environment variable is required.\n');
|
||||
process.stderr.write('Each user has a permanent MCP token shown in the user management list.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const auth = validateMcpToken(token);
|
||||
if (!auth) {
|
||||
process.stderr.write('Error: Invalid MCP_TOKEN. Token not found or user no longer exists.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mcpServer = createMcpServer();
|
||||
|
||||
// Wrap the stdio transport to inject authInfo into every message
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
// Patch: the MCP SDK passes authInfo through the transport's onmessage extra param.
|
||||
// For stdio we inject the resolved user from the token.
|
||||
const patchedTransport = new Proxy(transport, {
|
||||
set(target, prop, value) {
|
||||
if (prop === 'onmessage') {
|
||||
target.onmessage = (message, extra) => {
|
||||
value(message, { ...extra, authInfo: { userId: auth.userId } });
|
||||
};
|
||||
return true;
|
||||
}
|
||||
target[prop] = value;
|
||||
return true;
|
||||
},
|
||||
get(target, prop) {
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
|
||||
await mcpServer.connect(patchedTransport);
|
||||
process.stderr.write('Fredy MCP Server running on stdio\n');
|
||||
@@ -16,8 +16,8 @@ const mapListing = (listing) => ({
|
||||
url: listing.link,
|
||||
});
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
|
||||
const listings = newListings.map(mapListing);
|
||||
const body = {
|
||||
@@ -34,11 +34,20 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return fetch(endpointUrl, {
|
||||
let fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
headers,
|
||||
timeout: 10000,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
if (selfSignedCerts === true) {
|
||||
fetchOptions.dispatcher = new (await import('undici')).Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(endpointUrl, fetchOptions);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
@@ -52,6 +61,10 @@ export const config = {
|
||||
label: 'Endpoint URL',
|
||||
type: 'text',
|
||||
},
|
||||
selfSignedCerts: {
|
||||
label: 'Self-signed certificates',
|
||||
type: 'boolean',
|
||||
},
|
||||
authToken: {
|
||||
description: "Your application's auth token, if required by your endpoint.",
|
||||
label: 'Auth token (optional)',
|
||||
|
||||
88
lib/notification/adapter/resend.js
Executable file
88
lib/notification/adapter/resend.js
Executable file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Handlebars from 'handlebars';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getDirName, normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
const __dirname = getDirName();
|
||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||
const emailTemplate = Handlebars.compile(template);
|
||||
|
||||
const mapListings = (serviceName, jobKey, listings) =>
|
||||
listings.map((l) => {
|
||||
const image = normalizeImageUrl(l.image);
|
||||
return {
|
||||
title: l.title || '',
|
||||
link: l.link || '',
|
||||
address: l.address || '',
|
||||
size: l.size || '',
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
|
||||
const to = receiver
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const resend = new Resend(apiKey);
|
||||
|
||||
const listings = mapListings(serviceName, jobKey, newListings);
|
||||
|
||||
const html = emailTemplate({
|
||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||
numberOfListings: listings.length,
|
||||
listings,
|
||||
});
|
||||
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to,
|
||||
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
|
||||
html,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const config = {
|
||||
id: 'resend',
|
||||
name: 'Resend',
|
||||
description: 'Resend is being used to send new listings via mail.',
|
||||
readme: markdown2Html('lib/notification/adapter/resend.md'),
|
||||
fields: {
|
||||
apiKey: {
|
||||
type: 'text',
|
||||
label: 'Api Key',
|
||||
description: 'The Resend API key used to send emails.',
|
||||
},
|
||||
receiver: {
|
||||
type: 'email',
|
||||
label: 'Receiver Email',
|
||||
description: 'Comma-separated email addresses Fredy will send notifications to.',
|
||||
},
|
||||
from: {
|
||||
type: 'email',
|
||||
label: 'Sender Email',
|
||||
description: 'The verified email address or domain you send from in Resend.',
|
||||
},
|
||||
},
|
||||
};
|
||||
17
lib/notification/adapter/resend.md
Normal file
17
lib/notification/adapter/resend.md
Normal file
@@ -0,0 +1,17 @@
|
||||
### Resend Adapter
|
||||
|
||||
Resend is a modern email delivery service that Fredy can use to send notifications.
|
||||
|
||||
Setup:
|
||||
- Create a Resend account: https://resend.com/
|
||||
- Create an API key and add it to Fredy's configuration.
|
||||
- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it.
|
||||
- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains.
|
||||
|
||||
Multiple recipients:
|
||||
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||
|
||||
Notes & Troubleshooting:
|
||||
- Ensure the `from` address is verified or belongs to a verified domain in Resend.
|
||||
- If emails don't arrive, check your spam folder and Resend dashboard logs.
|
||||
- The template displays listing images via their public URLs; make sure images are reachable.
|
||||
@@ -9,7 +9,9 @@ import checkIfListingIsActive from '../services/listings/listingActiveTester.js'
|
||||
let appliedBlackList = [];
|
||||
|
||||
function shortenLink(link) {
|
||||
return link.substring(0, link.indexOf('?'));
|
||||
if (!link) return '';
|
||||
const index = link.indexOf('?');
|
||||
return index === -1 ? link : link.substring(0, index);
|
||||
}
|
||||
|
||||
function parseId(shortenedLink) {
|
||||
@@ -23,7 +25,7 @@ function normalize(o) {
|
||||
const title = o.title || 'No title available';
|
||||
const address = o.address || null;
|
||||
const shortLink = shortenLink(o.link);
|
||||
const link = `${baseUrl}/${shortLink}`;
|
||||
const link = baseUrl + shortLink;
|
||||
const image = baseUrl + o.image;
|
||||
const id = buildHash(parseId(shortLink), o.price);
|
||||
return Object.assign(o, { id, price, size, title, address, link, image });
|
||||
@@ -37,18 +39,18 @@ function applyBlacklist(o) {
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '._ref',
|
||||
crawlContainer: 'a:has(div.list_entry)',
|
||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||
waitForSelector: 'body',
|
||||
crawlFields: {
|
||||
id: '@href', //will be transformed later
|
||||
price: '.list_entry .immo_preis .label_info',
|
||||
size: '.list_entry .flaeche .label_info | removeNewline | trim',
|
||||
title: '.list_entry .part_text h3 span',
|
||||
description: '.list_entry .description | trim',
|
||||
price: '.immo_preis .label_info',
|
||||
size: '.flaeche .label_info | removeNewline | trim',
|
||||
title: 'h3 span',
|
||||
description: '.description | trim',
|
||||
link: '@href',
|
||||
address: '.list_entry .place',
|
||||
image: '.list_entry img@src',
|
||||
address: '.place',
|
||||
image: 'img@src',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||
const price = o.price.replace('Kaufpreis ', '');
|
||||
const address = o.address?.split(' • ')?.pop() ?? null;
|
||||
const title = o.title || 'No title available';
|
||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||
const id = buildHash(title, price);
|
||||
return Object.assign(o, { id, address, price, size, title, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: 'div[data-testid="serp-core-classified-card-testid"]',
|
||||
sortByDateParam: 'sortby=19',
|
||||
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
||||
crawlFields: {
|
||||
id: 'button@title |trim',
|
||||
title: 'button@title |trim',
|
||||
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||
link: 'button@data-base',
|
||||
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immonet',
|
||||
baseUrl: 'https://www.immonet.de/',
|
||||
id: 'immonet',
|
||||
};
|
||||
export { config };
|
||||
@@ -46,7 +46,9 @@ import {
|
||||
convertWebToMobile,
|
||||
} from '../services/immoscout/immoscout-web-translator.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { getUserSettings } from '../services/storage/settingsStorage.js';
|
||||
let appliedBlackList = [];
|
||||
let currentUserId = null;
|
||||
|
||||
async function getListings(url) {
|
||||
const response = await fetch(url, {
|
||||
@@ -66,23 +68,86 @@ async function getListings(url) {
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
return responseBody.resultListItems
|
||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||
.map((expose) => {
|
||||
const item = expose.item;
|
||||
const [price, size] = item.attributes;
|
||||
const image = item?.titlePicture?.preview ?? null;
|
||||
return {
|
||||
id: item.id,
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
};
|
||||
});
|
||||
return Promise.all(
|
||||
responseBody.resultListItems
|
||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||
.map(async (expose) => {
|
||||
const item = expose.item;
|
||||
const [price, size] = item.attributes;
|
||||
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||
let listing = {
|
||||
id: item.id,
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
};
|
||||
if (currentUserId) {
|
||||
const userSettings = getUserSettings(currentUserId);
|
||||
if (userSettings.immoscout_details) {
|
||||
return await pushDetails(listing);
|
||||
}
|
||||
}
|
||||
return listing;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function pushDetails(listing) {
|
||||
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!detailed.ok) {
|
||||
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
|
||||
return listing;
|
||||
}
|
||||
const detailBody = await detailed.json();
|
||||
|
||||
listing.description = buildDescription(detailBody);
|
||||
|
||||
return listing;
|
||||
}
|
||||
|
||||
function buildDescription(detailBody) {
|
||||
const sections = detailBody.sections || [];
|
||||
const contact = detailBody.contact || {};
|
||||
const cData = contact?.contactData || {};
|
||||
const agentName = cData?.agent?.name || '';
|
||||
const agentCompany = cData?.agent?.company || '';
|
||||
const stars = cData?.agent?.rating?.numberOfStars || '';
|
||||
const phoneNumbers = contact?.phoneNumbers || [];
|
||||
const phoneNumbersMapped = phoneNumbers
|
||||
.map((p) => `${p.label}: ${p.text}`)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const attributes = sections
|
||||
.filter((s) => s.type === 'ATTRIBUTE_LIST')
|
||||
.flatMap((s) => s.attributes)
|
||||
.filter((attr) => attr.label && attr.text)
|
||||
.map((attr) => `${attr.label} ${attr.text}`)
|
||||
.join('\n');
|
||||
|
||||
const freeText = sections
|
||||
.filter((s) => s.type === 'TEXT_AREA')
|
||||
.map((s) => {
|
||||
return `${s.title}\n${s.text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return (
|
||||
`Agent: ${agentName ? agentName : 'Unbekannt'} ${agentCompany ? `(${agentCompany}) ` : ''}${stars ? `- ${stars} stars` : ''}\n` +
|
||||
(phoneNumbersMapped ? `Phone Numbers:\n${phoneNumbersMapped}` : '') +
|
||||
'\n\n' +
|
||||
attributes.trim() +
|
||||
'\n\n' +
|
||||
freeText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
async function isListingActive(link) {
|
||||
@@ -137,6 +202,7 @@ export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = convertWebToMobile(sourceConfig.url);
|
||||
appliedBlackList = blacklist || [];
|
||||
currentUserId = sourceConfig.userId || null;
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immoscout',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/li
|
||||
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
||||
import { getJobs } from '../storage/jobStorage.js';
|
||||
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
export async function runGeoCordTask() {
|
||||
const listings = getListingsToGeocode();
|
||||
@@ -32,6 +34,11 @@ export async function runGeoCordTask() {
|
||||
}
|
||||
|
||||
export async function initGeocodingCron() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
logger.info('Do not start geo service as we are in demo mode');
|
||||
return;
|
||||
}
|
||||
// run directly on start
|
||||
await runGeoCordTask();
|
||||
// then every 6 hours
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
import cron from 'node-cron';
|
||||
import runActiveChecker from '../listings/listingActiveService.js';
|
||||
import logger from '../logger.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
async function runTask() {
|
||||
await runActiveChecker();
|
||||
}
|
||||
|
||||
export async function initActiveCheckerCron() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
logger.info('Do not start listing active checker as we are in demo mode');
|
||||
return;
|
||||
}
|
||||
//run directly on start
|
||||
await runTask();
|
||||
// then every day at 1 am
|
||||
|
||||
@@ -19,52 +19,80 @@ import path from 'path';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
export async function launchBrowser(url, options) {
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
const launchArgs = [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
preCfg.langArg,
|
||||
preCfg.windowSizeArg,
|
||||
...preCfg.extraArgs,
|
||||
];
|
||||
if (options?.proxyUrl) {
|
||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||
}
|
||||
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
if (options && options.userDataDir) {
|
||||
userDataDir = options.userDataDir;
|
||||
} else {
|
||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||
userDataDir = fs.mkdtempSync(prefix);
|
||||
removeUserDataDir = true;
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 45_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath,
|
||||
});
|
||||
|
||||
browser.__fredy_userDataDir = userDataDir;
|
||||
browser.__fredy_removeUserDataDir = removeUserDataDir;
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function closeBrowser(browser) {
|
||||
if (!browser) return;
|
||||
const userDataDir = browser.__fredy_userDataDir;
|
||||
const removeUserDataDir = browser.__fredy_removeUserDataDir;
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (removeUserDataDir && userDataDir) {
|
||||
try {
|
||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser = options?.browser;
|
||||
let isExternalBrowser = !!browser;
|
||||
let page;
|
||||
let result;
|
||||
try {
|
||||
debug(`Sending request to ${url} using Puppeteer.`);
|
||||
|
||||
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
|
||||
if (options && options.userDataDir) {
|
||||
userDataDir = options.userDataDir;
|
||||
removeUserDataDir = !!options.cleanupUserDataDir;
|
||||
} else {
|
||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||
userDataDir = fs.mkdtempSync(prefix);
|
||||
removeUserDataDir = true;
|
||||
if (!isExternalBrowser) {
|
||||
browser = await launchBrowser(url, options);
|
||||
}
|
||||
|
||||
const launchArgs = [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
];
|
||||
if (options?.proxyUrl) {
|
||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||
}
|
||||
// Prepare bot prevention pre-launch config
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
launchArgs.push(preCfg.langArg);
|
||||
launchArgs.push(preCfg.windowSizeArg);
|
||||
launchArgs.push(...preCfg.extraArgs);
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 30_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath, // allow using system Chrome
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
await applyBotPreventionToPage(page, preCfg);
|
||||
// Provide languages value before navigation
|
||||
await applyLanguagePersistence(page, preCfg);
|
||||
@@ -104,7 +132,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
result = pageSource || (await page.content());
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.message?.includes('Timeout')) {
|
||||
if (error?.name?.includes('Timeout')) {
|
||||
logger.debug('Error executing with puppeteer executor', error);
|
||||
} else {
|
||||
logger.warn('Error executing with puppeteer executor', error);
|
||||
@@ -118,19 +146,8 @@ export default async function execute(url, waitForSelector, options) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (browser != null) {
|
||||
await browser.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (removeUserDataDir && userDataDir) {
|
||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
if (browser != null && !isExternalBrowser) {
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -67,6 +67,7 @@ async function doGeocode(address) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
|
||||
@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
|
||||
price: 'price',
|
||||
constructionyear: 'constructionyear',
|
||||
apartmenttypes: 'apartmenttypes',
|
||||
buildingtypes: 'buildingtypes',
|
||||
ground: 'ground',
|
||||
pricetype: 'pricetype',
|
||||
floor: 'floor',
|
||||
geocodes: 'geocodes',
|
||||
@@ -86,6 +88,7 @@ const PARAM_NAME_MAP = {
|
||||
shape: 'shape',
|
||||
sorting: 'sorting',
|
||||
newbuilding: 'newbuilding',
|
||||
fulltext: 'fulltext',
|
||||
};
|
||||
|
||||
const EQUIPMENT_MAP = {
|
||||
@@ -97,6 +100,7 @@ const EQUIPMENT_MAP = {
|
||||
guesttoilet: 'guestToilet',
|
||||
balcony: 'balcony',
|
||||
handicappedaccessible: 'handicappedAccessible',
|
||||
lodgerflat: 'lodgerflat',
|
||||
};
|
||||
|
||||
const REAL_ESTATE_TYPE = {
|
||||
@@ -106,6 +110,10 @@ const REAL_ESTATE_TYPE = {
|
||||
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||
'haus-kaufen': 'housebuy',
|
||||
'haus-mit-keller-kaufen': 'housebuy',
|
||||
'luxushaus-kaufen': 'housebuy',
|
||||
'villa-kaufen': 'housebuy',
|
||||
'neubauhaus-kaufen': 'housebuy',
|
||||
};
|
||||
|
||||
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
|
||||
import * as similarityCache from '../similarity-check/similarityCache.js';
|
||||
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||
import { sendToUsers } from '../sse/sse-broker.js';
|
||||
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
||||
|
||||
/**
|
||||
* Initializes the job execution service.
|
||||
@@ -94,7 +95,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
||||
* @returns {void}
|
||||
*/
|
||||
function runAll(respectWorkingHours = true, context = undefined) {
|
||||
async function runAll(respectWorkingHours = true, context = undefined) {
|
||||
if (settings.demoMode) return;
|
||||
const now = Date.now();
|
||||
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
||||
@@ -103,15 +104,18 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
return;
|
||||
}
|
||||
settings.lastRun = now;
|
||||
jobStorage
|
||||
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
|
||||
})
|
||||
.forEach((job) => executeJob(job));
|
||||
});
|
||||
|
||||
for (const job of jobs) {
|
||||
await executeJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,28 +158,43 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
} catch (err) {
|
||||
logger.warn('Failed to emit start status for job', job.id, err);
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const jobProviders = job.provider.filter(
|
||||
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||
);
|
||||
const executions = jobProviders.map(async (prov) => {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
await new FredyPipelineExecutioner(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
prov.id,
|
||||
job.id,
|
||||
similarityCache,
|
||||
).execute();
|
||||
});
|
||||
const results = await Promise.allSettled(executions);
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') {
|
||||
logger.error(r.reason);
|
||||
for (const prov of jobProviders) {
|
||||
try {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
||||
|
||||
if (browser && !browser.isConnected()) {
|
||||
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
||||
await puppeteerExtractor.closeBrowser(browser);
|
||||
browser = null;
|
||||
}
|
||||
|
||||
if (!browser && matchedProvider.config.getListings == null) {
|
||||
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
||||
}
|
||||
|
||||
await new FredyPipelineExecutioner(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
job.spatialFilter,
|
||||
prov.id,
|
||||
job.id,
|
||||
similarityCache,
|
||||
browser,
|
||||
).execute();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) {
|
||||
await puppeteerExtractor.closeBrowser(browser);
|
||||
}
|
||||
markFinished(job.id);
|
||||
try {
|
||||
bus.emit('jobs:status', { jobId: job.id, running: false });
|
||||
|
||||
@@ -34,9 +34,8 @@ class SqliteConnection {
|
||||
|
||||
static async init() {
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
readConfigFromStorage().then((c) => {
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
});
|
||||
const c = await readConfigFromStorage();
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ export const upsertJob = ({
|
||||
notificationAdapter,
|
||||
userId,
|
||||
shareWithUsers = [],
|
||||
spatialFilter = null,
|
||||
}) => {
|
||||
const id = jobId || nanoid();
|
||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||
@@ -37,12 +38,13 @@ export const upsertJob = ({
|
||||
if (existing) {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE jobs
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers,
|
||||
spatial_filter = @spatialFilter
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
@@ -52,12 +54,13 @@ export const upsertJob = ({
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
|
||||
{
|
||||
id,
|
||||
user_id: ownerId,
|
||||
@@ -67,6 +70,7 @@ export const upsertJob = ({
|
||||
provider: toJson(provider ?? []),
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -87,10 +91,11 @@ export const getJob = (jobId) => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
{ id: jobId },
|
||||
)[0];
|
||||
if (!row) return null;
|
||||
@@ -101,6 +106,7 @@ export const getJob = (jobId) => {
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,9 +156,11 @@ export const getJobs = () => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.enabled = 1
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
@@ -161,6 +169,7 @@ export const getJobs = () => {
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -189,7 +198,7 @@ export const queryJobs = ({
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -250,11 +259,12 @@ export const queryJobs = ({
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
j.spatial_filter AS spatialFilter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
${whereSql}
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
@@ -265,6 +275,7 @@ export const queryJobs = ({
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
}));
|
||||
|
||||
return { totalNumber, page: safePage, result };
|
||||
|
||||
@@ -242,6 +242,8 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
* @param {object} [params.watchListFilter]
|
||||
* @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).
|
||||
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
|
||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||
@@ -257,11 +259,15 @@ export const queryListings = ({
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
createdAfter = null,
|
||||
createdBefore = null,
|
||||
minPrice = null,
|
||||
maxPrice = null,
|
||||
userId = null,
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -307,6 +313,24 @@ export const queryListings = ({
|
||||
} else if (watchListFilter === false) {
|
||||
whereParts.push('(wl.id IS NULL)');
|
||||
}
|
||||
// Time range filters (unix timestamps in milliseconds)
|
||||
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
||||
params.createdAfter = createdAfter;
|
||||
whereParts.push('(created_at >= @createdAfter)');
|
||||
}
|
||||
if (Number.isFinite(createdBefore) && createdBefore > 0) {
|
||||
params.createdBefore = createdBefore;
|
||||
whereParts.push('(created_at <= @createdBefore)');
|
||||
}
|
||||
// Price range filters
|
||||
if (Number.isFinite(minPrice) && minPrice >= 0) {
|
||||
params.minPrice = minPrice;
|
||||
whereParts.push('(l.price >= @minPrice)');
|
||||
}
|
||||
if (Number.isFinite(maxPrice) && maxPrice >= 0) {
|
||||
params.maxPrice = maxPrice;
|
||||
whereParts.push('(l.price <= @maxPrice)');
|
||||
}
|
||||
|
||||
// Build whereSql (filtering by manually_deleted = 0)
|
||||
whereParts.push('(l.manually_deleted = 0)');
|
||||
@@ -370,10 +394,18 @@ export const queryListings = ({
|
||||
* Delete all listings for a given job id.
|
||||
*
|
||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsByJobId = (jobId) => {
|
||||
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
||||
if (!jobId) return;
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
@@ -386,11 +418,19 @@ export const deleteListingsByJobId = (jobId) => {
|
||||
* Delete listings by a list of listing IDs.
|
||||
*
|
||||
* @param {string[]} ids - Array of listing IDs to delete.
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsById = (ids) => {
|
||||
export const deleteListingsById = (ids, hardDelete = false) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE id IN (${placeholders})`,
|
||||
ids,
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
|
||||
@@ -88,7 +88,7 @@ export function up(db) {
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, let it throw to rollback the migration
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function up(db) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
11
lib/services/storage/migrations/sql/11.add-spatial-filter.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
|
||||
*/
|
||||
|
||||
// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL;
|
||||
`);
|
||||
}
|
||||
25
lib/services/storage/migrations/sql/11.mcp-tokens.js
Normal file
25
lib/services/storage/migrations/sql/11.mcp-tokens.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Migration: Add mcp_token column to users table.
|
||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
||||
// Tokens are auto-generated for all existing users during this migration.
|
||||
export function up(db) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
|
||||
|
||||
// Backfill all existing users that don't have a token yet
|
||||
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||
const update = db.prepare(`UPDATE users SET mcp_token = @token WHERE id = @id`);
|
||||
for (const user of users) {
|
||||
const token = `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
update.run({ id: user.id, token });
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,18 @@
|
||||
|
||||
import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Generate a permanent, non-expiring MCP API token.
|
||||
* These tokens are secrets that never expire and are used for MCP authentication.
|
||||
* @returns {string}
|
||||
*/
|
||||
const generateMcpToken = () => `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
@@ -21,7 +29,7 @@ import { inDevMode } from '../../utils.js';
|
||||
*/
|
||||
export const getUsers = (withPassword) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
ORDER BY u.username`,
|
||||
@@ -41,7 +49,7 @@ export const getUsers = (withPassword) => {
|
||||
*/
|
||||
export const getUser = (id) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
WHERE u.id = @id
|
||||
@@ -88,14 +96,15 @@ export const upsertUser = ({ username, password, userId, isAdmin }) => {
|
||||
}
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin)`,
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin, @mcp_token)`,
|
||||
{
|
||||
id,
|
||||
username,
|
||||
password: hasher.hash(password || ''),
|
||||
last_login: null,
|
||||
is_admin: isAdmin ? 1 : 0,
|
||||
mcp_token: generateMcpToken(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -150,9 +159,9 @@ export const ensureDemoUserExists = async () => {
|
||||
const existing = SqliteConnection.query(`SELECT id FROM users WHERE username = 'demo' LIMIT 1`);
|
||||
if (existing.length === 0) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'demo', @password, NULL, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo') },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'demo', @password, NULL, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo'), mcp_token: generateMcpToken() },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -167,13 +176,25 @@ export const ensureDemoUserExists = async () => {
|
||||
* Security: On a fresh instance, a default admin/admin is created; change this password immediately.
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Validate an MCP API token and return the associated user id.
|
||||
* MCP tokens are permanent secrets stored in the users table that never expire.
|
||||
* @param {string} token - The raw token string (e.g. fredy_...).
|
||||
* @returns {{ userId: string } | null} The user id or null if invalid.
|
||||
*/
|
||||
export const validateMcpToken = (token) => {
|
||||
if (!token) return null;
|
||||
const row = SqliteConnection.query(`SELECT id FROM users WHERE mcp_token = @token LIMIT 1`, { token })[0];
|
||||
return row ? { userId: row.id } : null;
|
||||
};
|
||||
|
||||
export const ensureAdminUserExists = () => {
|
||||
const anyUser = SqliteConnection.query(`SELECT id FROM users LIMIT 1`).length > 0;
|
||||
if (!anyUser) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now() },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now(), mcp_token: generateMcpToken() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,92 +14,89 @@ import { getSettings } from '../storage/settingsStorage.js';
|
||||
const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
const isDocker = process.env.IS_DOCKER != null;
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
const staticTrackingData = {
|
||||
operatingSystem: os.platform(),
|
||||
osVersion: os.release(),
|
||||
isDocker,
|
||||
arch: process.arch,
|
||||
language: process.env.LANG || 'en',
|
||||
nodeVersion: process.version || 'N/A',
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
|
||||
const shouldTrack = async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.analyticsEnabled && !inDevMode();
|
||||
};
|
||||
|
||||
const sendTrackingData = async (endpoint, payload) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackFeature = async (feature) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
export const trackMainEvent = async () => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/feature`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error tracking feature', error);
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await sendTrackingData('/main', trackingObj);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackPoi = async (poi) => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature: poi,
|
||||
});
|
||||
|
||||
await sendTrackingData('/feature', trackingObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export async function trackDemoAccessed() {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
}
|
||||
const trackingObj = await enrichTrackingObject({});
|
||||
await sendTrackingData('/demo/accessed', trackingObj);
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichTrackingObject(trackingObject) {
|
||||
const settings = await getSettings();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
const language = process.env.LANG || 'en';
|
||||
const nodeVersion = process.version || 'N/A';
|
||||
|
||||
return {
|
||||
...trackingObject,
|
||||
...staticTrackingData,
|
||||
isDemo: settings.demoMode,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
nodeVersion,
|
||||
language,
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
13553
package-lock.json
generated
13553
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.3.2",
|
||||
"version": "20.0.3",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -14,10 +14,12 @@
|
||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
||||
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
||||
"lint": "eslint .",
|
||||
"mcp:stdio": "node lib/mcp/stdio.js",
|
||||
"lint:fix": "yarn lint --fix",
|
||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
||||
"copyright": "node ./copyright.js"
|
||||
"copyright": "node ./copyright.js",
|
||||
"release": "node ./tools/release/release.js"
|
||||
},
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
@@ -59,27 +61,30 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.90.13",
|
||||
"@douyinfe/semi-ui": "2.90.13",
|
||||
"@douyinfe/semi-ui-19": "^2.90.13",
|
||||
"@douyinfe/semi-icons": "^2.92.2",
|
||||
"@douyinfe/semi-ui": "2.92.2",
|
||||
"@douyinfe/semi-ui-19": "^2.92.2",
|
||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"body-parser": "2.2.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.17.0",
|
||||
"maplibre-gl": "^5.20.0",
|
||||
"nanoid": "5.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
"puppeteer": "^24.39.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
@@ -87,32 +92,36 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-range-slider-input": "^3.3.2",
|
||||
"react-router": "7.13.0",
|
||||
"react-router-dom": "7.13.0",
|
||||
"react-router": "7.13.1",
|
||||
"react-router-dom": "7.13.1",
|
||||
"resend": "^6.9.3",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"semver": "^7.7.4",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.1",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-env": "7.28.6",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"chai": "6.2.2",
|
||||
"eslint": "9.39.2",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.3",
|
||||
"globals": "^17.4.0",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.5.1",
|
||||
"lint-staged": "16.2.7",
|
||||
"less": "4.6.2",
|
||||
"lint-staged": "16.3.3",
|
||||
"mocha": "11.7.5",
|
||||
"nodemon": "^3.1.11",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ export function getUserSettings(userId) {
|
||||
export const updateListingDistance = (id, distance) => {
|
||||
// noop
|
||||
};
|
||||
export const deletedIds = [];
|
||||
export const deleteListingsById = (ids) => {
|
||||
deletedIds.push(...ids);
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
89
test/pipeline_filtering.test.js
Normal file
89
test/pipeline_filtering.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { mockFredy } from './utils.js';
|
||||
import * as mockStore from './mocks/mockStore.js';
|
||||
|
||||
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
|
||||
it('should call deleteListingsById when listings are filtered by similarity', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => true, // always similar
|
||||
};
|
||||
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
|
||||
normalize: (l) => l,
|
||||
filter: () => true,
|
||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
|
||||
|
||||
// Clear deletedIds before test
|
||||
mockStore.deletedIds.length = 0;
|
||||
|
||||
try {
|
||||
await fredy.execute();
|
||||
} catch {
|
||||
// Might throw NoNewListingsWarning if all are filtered out
|
||||
}
|
||||
|
||||
expect(mockStore.deletedIds).to.include('1');
|
||||
});
|
||||
|
||||
it('should call deleteListingsById when listings are filtered by area', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => false, // never similar
|
||||
};
|
||||
|
||||
const spatialFilter = {
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () =>
|
||||
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
|
||||
normalize: (l) => l,
|
||||
filter: () => true,
|
||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
|
||||
|
||||
// Clear deletedIds before test
|
||||
mockStore.deletedIds.length = 0;
|
||||
|
||||
try {
|
||||
await fredy.execute();
|
||||
} catch {
|
||||
// Might throw NoNewListingsWarning if all are filtered out
|
||||
}
|
||||
|
||||
expect(mockStore.deletedIds).to.include('2');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,14 @@ describe('#einsAImmobilien testsuite()', () => {
|
||||
it('should test einsAImmobilien provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'einsAImmobilien',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#immobilien.de testsuite()', () => {
|
||||
it('should test immobilien.de provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/immonet.js';
|
||||
|
||||
describe('#immonet testsuite()', () => {
|
||||
it('should test immonet provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immonet, [], []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immonet');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ describe('#immoscout provider testsuite()', () => {
|
||||
it('should test immoscout provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, '', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#immoswp testsuite()', () => {
|
||||
it('should test immoswp provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#immowelt testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immowelt, [], []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
|
||||
@@ -14,7 +14,14 @@ describe('#kleinanzeigen testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'kleinanzeigen',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#mcMakler testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.mcMakler, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
|
||||
@@ -14,7 +14,14 @@ describe('#neubauKompass testsuite()', () => {
|
||||
it('should test neubauKompass provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'neubauKompass',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#ohneMakler testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.ohneMakler, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('#regionalimmobilien24 testsuite()', () => {
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'regionalimmobilien24',
|
||||
similarityCache,
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#sparkasse testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.sparkasse, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
"url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
|
||||
"enabled": true
|
||||
},
|
||||
"immonet": {
|
||||
"url": "https://www.immonet.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2112&order=Default&m=homepage_new_search_classified_search_result",
|
||||
"enabled": true
|
||||
},
|
||||
"immowelt": {
|
||||
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||
"enabled": true
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('#wgGesucht testsuite()', () => {
|
||||
it('should test wgGesucht provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,7 +14,14 @@ describe('#wohnungsboerse testsuite()', () => {
|
||||
it('should test wohnungsboerse provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wohnungsboerse', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'wohnungsboerse',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
"shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0",
|
||||
"id": "wgGesucht"
|
||||
},
|
||||
|
||||
{
|
||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?sortby=0&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
|
||||
"shouldBecome": "https://www.immonet.de/immobiliensuche/sel.do?sortby=19&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
|
||||
"id": "immonet"
|
||||
},
|
||||
{
|
||||
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
|
||||
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",
|
||||
|
||||
196
tools/release/release.js
Normal file
196
tools/release/release.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import fetch from 'node-fetch';
|
||||
import chalk from 'chalk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Release Tool for Fredy
|
||||
*
|
||||
* This tool automates the process of creating a GitHub release.
|
||||
* It fetches the latest release, compares it with the current master branch,
|
||||
* allows manual editing of commit messages, and creates a new release on GitHub.
|
||||
*/
|
||||
|
||||
// Define __dirname for ESM
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Configuration and Paths
|
||||
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||
const PACKAGE_JSON_PATH = path.join(__dirname, '../../package.json');
|
||||
const REPO = 'orangecoding/fredy';
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
const GITHUB_TOKEN = config.github_token;
|
||||
|
||||
/**
|
||||
* Main function to execute the release process
|
||||
*/
|
||||
async function createRelease() {
|
||||
/* eslint-disable no-console */
|
||||
try {
|
||||
console.log(chalk.cyan('🚀 Starting release process...'));
|
||||
|
||||
// 1. Load Configuration
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.error(chalk.red('❌ Error: config.json not found in tools/release/'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!GITHUB_TOKEN) {
|
||||
console.error(chalk.red('❌ Error: GitHub token not configured.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Get current version from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8'));
|
||||
const version = packageJson.version;
|
||||
const tag = version; // Using version as tag
|
||||
|
||||
console.log(chalk.blue(`📦 Target version: ${version}`));
|
||||
|
||||
// 3. Check if release already exists
|
||||
console.log(chalk.yellow('🔍 Checking if release already exists...'));
|
||||
const existingReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingReleaseResponse.status === 200) {
|
||||
console.error(chalk.red(`❌ Error: A release with tag ${tag} already exists.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Fetch latest release to find the starting point for the diff
|
||||
console.log(chalk.yellow('📡 Fetching latest release from GitHub...'));
|
||||
const latestReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!latestReleaseResponse.ok) {
|
||||
console.error(chalk.red('❌ Error fetching latest release.'));
|
||||
const errorData = await latestReleaseResponse.json();
|
||||
console.error(chalk.red(JSON.stringify(errorData)));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const latestRelease = await latestReleaseResponse.json();
|
||||
const latestTag = latestRelease.tag_name;
|
||||
console.log(chalk.green(`✅ Latest release found: ${latestTag}`));
|
||||
|
||||
// 5. Ensure the latest tag is available locally
|
||||
console.log(chalk.yellow(`📡 Fetching tag ${latestTag} from remote...`));
|
||||
try {
|
||||
execSync(`git fetch origin tag ${latestTag} --no-tags`);
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`❌ Error fetching tag ${latestTag} from origin.`));
|
||||
console.error(error.message);
|
||||
// We don't exit here, maybe it's already there but fetch failed for some reason
|
||||
}
|
||||
|
||||
// 6. Get commit messages between latest tag and current HEAD
|
||||
console.log(chalk.yellow(`Git diff: ${latestTag} .. HEAD`));
|
||||
let commitMessages;
|
||||
try {
|
||||
commitMessages = execSync(`git log ${latestTag}..HEAD --pretty=format:"- %s"`).toString().trim();
|
||||
} catch (error) {
|
||||
console.error(chalk.red('❌ Error running git log. Make sure the latest tag is available locally.'), error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!commitMessages) {
|
||||
console.log(chalk.magenta('⚠️ No new commits found since last release.'));
|
||||
commitMessages = '- No changes recorded';
|
||||
}
|
||||
|
||||
// 7. Open commit messages in editor for manual adjustment
|
||||
const tempFilePath = path.join(__dirname, 'CHANGELOG_EDIT.tmp');
|
||||
const initialContent = `# Release Notes for ${version}\n# Edit the messages below. Lines starting with # will be ignored.\n\n${commitMessages}`;
|
||||
fs.writeFileSync(tempFilePath, initialContent);
|
||||
|
||||
console.log(chalk.blue('📝 Opening editor for release notes (using nano or $EDITOR)...'));
|
||||
await openInEditor(tempFilePath);
|
||||
|
||||
// 8. Read edited content
|
||||
let editedContent = fs
|
||||
.readFileSync(tempFilePath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('#'))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
fs.unlinkSync(tempFilePath); // Clean up temp file
|
||||
|
||||
if (!editedContent) {
|
||||
console.error(chalk.red('❌ Release notes are empty. Aborting release.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 9. Create the new release
|
||||
console.log(chalk.cyan(`🚀 Creating release ${version} on GitHub...`));
|
||||
const createResponse = await fetch(`https://api.github.com/repos/${REPO}/releases`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name: version,
|
||||
body: editedContent,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createResponse.status === 201) {
|
||||
const data = await createResponse.json();
|
||||
console.log(chalk.green('🎉 Release successfully created!'));
|
||||
console.log(chalk.green(`🔗 URL: ${data.html_url}`));
|
||||
} else {
|
||||
const errorData = await createResponse.json();
|
||||
console.error(chalk.red('❌ Failed to create release.'));
|
||||
console.error(chalk.red(JSON.stringify(errorData, null, 2)));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red('💥 An unexpected error occurred:'));
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to open a file in a terminal editor
|
||||
* @param {string} filePath
|
||||
*/
|
||||
function openInEditor(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const editor = process.env.EDITOR || 'nano';
|
||||
const child = spawn(editor, [filePath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Editor exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await createRelease();
|
||||
/* eslint-enable no-console */
|
||||
@@ -29,6 +29,7 @@ import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||
import NewsModal from './components/news/NewsModal.jsx';
|
||||
|
||||
export default function FredyApp() {
|
||||
const actions = useActions();
|
||||
@@ -48,6 +49,7 @@ export default function FredyApp() {
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.userSettings.getUserSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
await actions.tracking.getTrackingPois();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -88,6 +90,7 @@ export default function FredyApp() {
|
||||
</>
|
||||
)}
|
||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||
{!settings.demoMode && <NewsModal />}
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
padding: 24px;
|
||||
background-color: var(--semi-color-bg-0);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||
|
||||
11
ui/src/assets/news/news.json
Normal file
11
ui/src/assets/news/news.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Fredy goes AI",
|
||||
"text": "With Fredy v20.0.0, we are introducing Fredy’s own MCP server. This brings a powerful new capability: you can connect your local LLM directly to Fredy and explore the data it collects in a much more flexible way.<br/><br/>The MCP server exposes Fredy’s tools and findings through a structured interface, allowing your LLM to query listings, inspect collected details, and analyze results programmatically. Instead of manually searching through the data, you can simply ask your model questions and let it dig into what Fredy has discovered for you.<br/><br/>In practice, this means your local LLM can interact with Fredy almost like an assistant: investigating properties, summarizing listings, filtering results, or helping you identify interesting opportunities based on the data Fredy gathered.",
|
||||
"media": "news.mp4"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
ui/src/assets/news/news.mp4
Normal file
BIN
ui/src/assets/news/news.mp4
Normal file
Binary file not shown.
70
ui/src/components/ListingDeletionModal.jsx
Normal file
70
ui/src/components/ListingDeletionModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ListingDeletionModal = ({
|
||||
visible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title = 'Delete Listings',
|
||||
showOptions = true,
|
||||
message = 'How would you like to delete the selected listing(s)?',
|
||||
}) => {
|
||||
const [deleteType, setDeleteType] = useState('soft');
|
||||
|
||||
const handleOk = () => {
|
||||
onConfirm(!showOptions || deleteType === 'hard');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText="Confirm"
|
||||
cancelText="Cancel"
|
||||
style={{ maxWidth: '500px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text>{message}</Text>
|
||||
</div>
|
||||
{showOptions && (
|
||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||
scraping session.
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Remove from database (Hard Delete)</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are completely removed from the database.
|
||||
<br />
|
||||
<Text type="warning">
|
||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||
previously found.
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingDeletionModal;
|
||||
@@ -2,30 +2,29 @@
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition: transform 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
|
||||
&.blue {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
|
||||
}
|
||||
&.orange {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
|
||||
}
|
||||
&.green {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-success);
|
||||
}
|
||||
&.purple {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-info);
|
||||
}
|
||||
&.gray {
|
||||
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
||||
opacity: 0;
|
||||
animation: pulse 5s infinite ease-in-out;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
@@ -46,22 +45,36 @@
|
||||
}
|
||||
|
||||
&.blue {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-primary);
|
||||
--pulse-color: var(--semi-color-primary);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
|
||||
--pulse-color: var(--semi-color-warning);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.green {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-success);
|
||||
--pulse-color: var(--semi-color-success);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-info);
|
||||
--pulse-color: var(--semi-color-info);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
|
||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||
import './DashboardCard.less';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './FredyFooter.less';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
IconPlusCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -60,6 +61,9 @@ const JobGrid = () => {
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||
|
||||
const pendingJobIdRef = useRef(null);
|
||||
const evtSourceRef = useRef(null);
|
||||
|
||||
@@ -125,24 +129,35 @@ const JobGrid = () => {
|
||||
};
|
||||
}, [handleFilterChange]);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job successfully removed');
|
||||
loadData();
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
const onJobRemoval = (jobId) => {
|
||||
setPendingDeletion({ type: 'job', jobId });
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const onListingRemoval = async (jobId) => {
|
||||
const onListingRemoval = (jobId) => {
|
||||
setPendingDeletion({ type: 'listings', jobId });
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete) => {
|
||||
const { type, jobId } = pendingDeletion;
|
||||
try {
|
||||
await xhrDelete('/api/listings/job', { jobId });
|
||||
Toast.success('Listings successfully removed');
|
||||
if (type === 'job') {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job and listings successfully removed');
|
||||
} else if (type === 'listings') {
|
||||
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||
Toast.success('Listings successfully removed');
|
||||
}
|
||||
loadData();
|
||||
if (type === 'job') {
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
Toast.error(error.message || 'Error performing deletion');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -410,6 +425,21 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||
showOptions={pendingDeletion?.type !== 'job'}
|
||||
message={
|
||||
pendingDeletion?.type === 'job'
|
||||
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||
: 'How would you like to delete the selected listing(s)?'
|
||||
}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
IconEyeOpened,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
@@ -65,6 +66,9 @@ const ListingsGrid = () => {
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
const loadData = () => {
|
||||
actions.listingsData.getListingsData({
|
||||
page,
|
||||
@@ -106,6 +110,19 @@ const ListingsGrid = () => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||
Toast.success('Listing successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cap = (val) => {
|
||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||
};
|
||||
@@ -312,15 +329,10 @@ const ListingsGrid = () => {
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
setListingToDelete(item.id);
|
||||
setDeleteModalVisible(true);
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
@@ -341,6 +353,14 @@ const ListingsGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function Headline({ text, size = 3 } = {}) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import logo from '../../assets/logo.png';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
|
||||
213
ui/src/components/map/Map.jsx
Normal file
213
ui/src/components/map/Map.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
|
||||
import './Map.less';
|
||||
|
||||
export const GERMANY_BOUNDS = [
|
||||
[5.866, 47.27], // Southwest coordinates
|
||||
[15.042, 55.059], // Northeast coordinates
|
||||
];
|
||||
|
||||
export const STYLES = {
|
||||
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||
SATELLITE: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'satellite-tiles': {
|
||||
type: 'raster',
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||
},
|
||||
'satellite-labels': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-tiles',
|
||||
type: 'raster',
|
||||
source: 'satellite-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'satellite-labels',
|
||||
type: 'raster',
|
||||
source: 'satellite-labels',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
style = 'STANDARD',
|
||||
show3dBuildings = false,
|
||||
onMapReady = null,
|
||||
enableDrawing = false,
|
||||
initialSpatialFilter = null,
|
||||
onDrawingChange = null,
|
||||
}) {
|
||||
const mapContainerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const drawRef = useRef(null);
|
||||
|
||||
// Initialize map - ONLY when container changes, never reinitialize
|
||||
useEffect(() => {
|
||||
if (mapRef.current) return; // Map already exists, don't reinitialize
|
||||
|
||||
mapRef.current = new maplibregl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: STYLES[style],
|
||||
center: [10.4515, 51.1657], // Center of Germany
|
||||
zoom: 4,
|
||||
maxBounds: GERMANY_BOUNDS,
|
||||
antialias: true,
|
||||
});
|
||||
|
||||
mapRef.current.addControl(
|
||||
new maplibregl.NavigationControl({
|
||||
showCompass: true,
|
||||
visualizePitch: true,
|
||||
visualizeRoll: true,
|
||||
}),
|
||||
'top-right',
|
||||
);
|
||||
|
||||
mapRef.current.addControl(
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize drawing extension only if enabled
|
||||
if (enableDrawing) {
|
||||
fixMapboxDrawCompatibility();
|
||||
drawRef.current = addDrawingControl(mapRef.current);
|
||||
}
|
||||
|
||||
// Call onMapReady callback if provided
|
||||
if (onMapReady) {
|
||||
onMapReady(mapRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [mapContainerRef]); // ONLY depend on mapContainerRef - nothing else!
|
||||
|
||||
// Load spatial filter and setup area filter event listeners
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !drawRef.current || !enableDrawing) return;
|
||||
|
||||
// Load initial spatial filter if provided
|
||||
if (initialSpatialFilter) {
|
||||
try {
|
||||
drawRef.current.set(initialSpatialFilter);
|
||||
} catch (error) {
|
||||
console.error('Error loading spatial filter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drawing event listeners
|
||||
const cleanup = setupAreaFilterEventListeners(mapRef.current, drawRef.current, onDrawingChange);
|
||||
|
||||
return cleanup;
|
||||
}, [initialSpatialFilter, onDrawingChange, enableDrawing]);
|
||||
|
||||
// Handle style changes
|
||||
useEffect(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.setStyle(STYLES[style]);
|
||||
}
|
||||
}, [style]);
|
||||
|
||||
// Handle 3D buildings layer
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
|
||||
const add3dLayer = () => {
|
||||
if (!mapRef.current || !mapRef.current.isStyleLoaded()) return;
|
||||
if (show3dBuildings) {
|
||||
if (!mapRef.current.getSource('openfreemap')) {
|
||||
mapRef.current.addSource('openfreemap', {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
});
|
||||
}
|
||||
if (!mapRef.current.getLayer('3d-buildings')) {
|
||||
const layers = mapRef.current.getStyle().layers;
|
||||
let labelLayerId;
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
|
||||
labelLayerId = layers[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mapRef.current.addLayer(
|
||||
{
|
||||
id: '3d-buildings',
|
||||
source: 'openfreemap',
|
||||
'source-layer': 'building',
|
||||
type: 'fill-extrusion',
|
||||
minzoom: 15,
|
||||
filter: ['!=', ['get', 'hide_3d'], true],
|
||||
paint: {
|
||||
'fill-extrusion-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'render_height'],
|
||||
0,
|
||||
'lightgray',
|
||||
200,
|
||||
'royalblue',
|
||||
400,
|
||||
'lightblue',
|
||||
],
|
||||
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
|
||||
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
|
||||
'fill-extrusion-opacity': 0.6,
|
||||
},
|
||||
},
|
||||
labelLayerId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mapRef.current.getLayer('3d-buildings')) {
|
||||
mapRef.current.removeLayer('3d-buildings');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
add3dLayer();
|
||||
}, [show3dBuildings, style]);
|
||||
|
||||
// Handle pitch for 3D
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
mapRef.current.setPitch(show3dBuildings ? 45 : 0);
|
||||
}, [show3dBuildings]);
|
||||
|
||||
return <div ref={mapContainerRef} className="map-container" />;
|
||||
}
|
||||
45
ui/src/components/map/Map.less
Normal file
45
ui/src/components/map/Map.less
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
.map-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Fix Mapbox Draw cursors for MapLibre GL compatibility */
|
||||
.maplibregl-map.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.maplibregl-map.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.maplibregl-map.mouse-add .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.maplibregl-map.mouse-move.mode-direct_select .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-direct_select.feature-vertex.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-direct_select.feature-midpoint.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-direct_select.feature-feature.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-static.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
177
ui/src/components/map/MapDrawingExtension.js
Normal file
177
ui/src/components/map/MapDrawingExtension.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
||||
|
||||
const drawStyles = [
|
||||
{
|
||||
id: 'gl-draw-polygon-fill-inactive',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||
paint: { 'fill-color': '#3bb2d0', 'fill-outline-color': '#3bb2d0', 'fill-opacity': 0.1 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-fill-active',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||
paint: { 'fill-color': '#fbb03b', 'fill-outline-color': '#fbb03b', 'fill-opacity': 0.1 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-midpoint',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
|
||||
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-stroke-inactive',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-stroke-active',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-line-inactive',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-line-active',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-and-line-vertex-stroke-inactive',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
paint: { 'circle-radius': 5, 'circle-color': '#fff' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-and-line-vertex-inactive',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-point-stroke-inactive',
|
||||
type: 'circle',
|
||||
filter: [
|
||||
'all',
|
||||
['==', 'active', 'false'],
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'mode', 'static'],
|
||||
],
|
||||
paint: { 'circle-radius': 5, 'circle-opacity': 1, 'circle-color': '#fff' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-inactive',
|
||||
type: 'circle',
|
||||
filter: [
|
||||
'all',
|
||||
['==', 'active', 'false'],
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'mode', 'static'],
|
||||
],
|
||||
paint: { 'circle-radius': 3, 'circle-color': '#3bb2d0' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-stroke-active',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
|
||||
paint: { 'circle-radius': 7, 'circle-color': '#fff' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-active',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
|
||||
paint: { 'circle-radius': 5, 'circle-color': '#fbb03b' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-fill-static',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||
paint: { 'fill-color': '#404040', 'fill-outline-color': '#404040', 'fill-opacity': 0.1 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-stroke-static',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#404040', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-line-static',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#404040', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-static',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']],
|
||||
paint: { 'circle-radius': 5, 'circle-color': '#404040' },
|
||||
},
|
||||
];
|
||||
|
||||
export function fixMapboxDrawCompatibility() {
|
||||
MapboxDraw.constants.classes.CANVAS = 'maplibregl-canvas';
|
||||
MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
|
||||
MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
|
||||
MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
|
||||
MapboxDraw.constants.classes.ATTRIBUTION = 'maplibregl-ctrl-attrib';
|
||||
}
|
||||
|
||||
export function addDrawingControl(map) {
|
||||
const draw = new MapboxDraw({
|
||||
displayControlsDefault: false,
|
||||
controls: {
|
||||
polygon: true,
|
||||
trash: true,
|
||||
},
|
||||
styles: drawStyles,
|
||||
});
|
||||
|
||||
map.addControl(draw, 'top-left');
|
||||
return draw;
|
||||
}
|
||||
|
||||
export function setupAreaFilterEventListeners(map, draw, onDrawingChange) {
|
||||
if (!map || !draw) return () => {};
|
||||
|
||||
const handleDrawChange = () => {
|
||||
if (draw) {
|
||||
const data = draw.getAll();
|
||||
if (onDrawingChange) {
|
||||
onDrawingChange(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
map.on('draw.create', handleDrawChange);
|
||||
map.on('draw.update', handleDrawChange);
|
||||
map.on('draw.delete', handleDrawChange);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (map) {
|
||||
map.off('draw.create', handleDrawChange);
|
||||
map.off('draw.update', handleDrawChange);
|
||||
map.off('draw.delete', handleDrawChange);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Nav } from '@douyinfe/semi-ui-19';
|
||||
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
|
||||
77
ui/src/components/news/NewsModal.jsx
Normal file
77
ui/src/components/news/NewsModal.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { UserGuide } from '@douyinfe/semi-ui-19';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth';
|
||||
import heart from '../../assets/heart.png';
|
||||
import newsConfig from '../../assets/news/news.json';
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
|
||||
import './NewsModal.less';
|
||||
|
||||
const newsMedia = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||
|
||||
const NewsModal = () => {
|
||||
const screenWidth = useScreenWidth();
|
||||
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
||||
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
||||
const pois = useSelector((state) => state.tracking.pois);
|
||||
const actions = useActions();
|
||||
|
||||
if (newsConfig == null || newsConfig.content == null || newsConfig.content.length === 0 || screenWidth <= 768) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = newsConfig.content.map((item) => ({
|
||||
title: (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={heart} width="30" alt="Fredy Logo" style={{ marginRight: '10px' }} />
|
||||
<b>{item.title}</b>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
{item.media &&
|
||||
newsMedia[`../../assets/news/${item.media}`] &&
|
||||
(item.media.includes('mp4') ? (
|
||||
<video controls width="500">
|
||||
<source src={newsMedia[`../../assets/news/${item.media}`]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<img
|
||||
src={newsMedia[`../../assets/news/${item.media}`]}
|
||||
alt={item.title}
|
||||
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
<p dangerouslySetInnerHTML={{ __html: item.text }} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const handleClose = (poi) => {
|
||||
actions.userSettings.setNewsHash(newsConfig.key);
|
||||
if (poi) {
|
||||
actions.tracking.trackPoi(poi);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserGuide
|
||||
mode="modal"
|
||||
mask={true}
|
||||
steps={steps}
|
||||
visible={userSettingsLoaded && newsHash !== newsConfig.key}
|
||||
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
|
||||
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
|
||||
modalProps={{
|
||||
width: '850px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsModal;
|
||||
3
ui/src/components/news/NewsModal.less
Normal file
3
ui/src/components/news/NewsModal.less
Normal file
@@ -0,0 +1,3 @@
|
||||
.semi-userGuide-modal-body-title {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||
|
||||
export default function InsufficientPermission() {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export default function PermissionAwareRoute({ currentUser, children }) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import './Placeholder.less';
|
||||
|
||||
function getPlaceholder(rowCount, className) {
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import './SegmentParts.less';
|
||||
|
||||
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
|
||||
export const SegmentPart = ({ name, Icon = null, children, helpText = null, className = '' }) => {
|
||||
const { Meta } = Card;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="segmentParts"
|
||||
className={`segmentParts ${className}`}
|
||||
title={
|
||||
(helpText || name) && (
|
||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
|
||||
@@ -39,6 +37,17 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
|
||||
title: 'Number of jobs',
|
||||
dataIndex: 'numberOfJobs',
|
||||
},
|
||||
{
|
||||
title: 'MCP Token',
|
||||
dataIndex: 'mcpToken',
|
||||
render: (value) => {
|
||||
return (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.85em', wordBreak: 'break-all' }}>
|
||||
{value || '---'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui-19';
|
||||
import Logo from '../logo/Logo.jsx';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { xhrGet } from '../xhr.js';
|
||||
import { xhrGet, xhrPost } from '../xhr.js';
|
||||
import queryString from 'query-string';
|
||||
|
||||
const logger = (config) => (set, get, api) =>
|
||||
@@ -27,10 +27,21 @@ const logger = (config) => (set, get, api) =>
|
||||
api,
|
||||
);
|
||||
|
||||
/**
|
||||
* Middleware to track loading state of async actions.
|
||||
*/
|
||||
const loadingTracker = (config) => (set, get, api) => {
|
||||
const wrappedSet = (partial, replace) => {
|
||||
set(partial, replace);
|
||||
};
|
||||
|
||||
return config(wrappedSet, get, api);
|
||||
};
|
||||
|
||||
// Create the Zustand store with slices and actions
|
||||
export const useFredyState = create(
|
||||
logger(
|
||||
(set) => {
|
||||
loadingTracker((set) => {
|
||||
// Async actions that directly set state (no separate reducer concept)
|
||||
const effects = {
|
||||
dashboard: {
|
||||
@@ -169,6 +180,23 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
tracking: {
|
||||
async getTrackingPois() {
|
||||
try {
|
||||
const response = await xhrGet('/api/tracking/trackingPois');
|
||||
set((state) => ({ tracking: { ...state.tracking, pois: Object.freeze(response.json) } }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/tracking. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async trackPoi(poi) {
|
||||
try {
|
||||
await xhrPost('/api/tracking/poi', { poi });
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to track poi. Error:', Exception);
|
||||
}
|
||||
},
|
||||
},
|
||||
listingsData: {
|
||||
async getListingsData({
|
||||
page = 1,
|
||||
@@ -234,9 +262,60 @@ export const useFredyState = create(
|
||||
async getUserSettings() {
|
||||
try {
|
||||
const response = await xhrGet('/api/user/settings');
|
||||
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
|
||||
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
|
||||
// Mark as loaded even on error to prevent blocking the UI
|
||||
set((state) => ({ userSettings: { ...state.userSettings, loaded: true } }));
|
||||
}
|
||||
},
|
||||
async setNewsHash(newsHash) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash });
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: { ...state.userSettings.settings, news_hash: newsHash },
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update news hash. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setHomeAddress(address) {
|
||||
try {
|
||||
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
|
||||
if (response.status === 200) {
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: {
|
||||
...state.userSettings.settings,
|
||||
home_address: { address, coords: response.json.coords },
|
||||
},
|
||||
},
|
||||
}));
|
||||
return response.json;
|
||||
}
|
||||
throw response;
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update home address. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setImmoscoutDetails(enabled) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: { ...state.userSettings.settings, immoscout_details: enabled },
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update immoscout details setting. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -255,9 +334,10 @@ export const useFredyState = create(
|
||||
maxPrice: 0,
|
||||
},
|
||||
generalSettings: { settings: {} },
|
||||
userSettings: { settings: {} },
|
||||
userSettings: { settings: {}, loaded: false },
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
tracking: { pois: {} },
|
||||
provider: [],
|
||||
jobsData: {
|
||||
jobs: [],
|
||||
@@ -276,6 +356,7 @@ export const useFredyState = create(
|
||||
generalSettings: { ...effects.generalSettings },
|
||||
demoMode: { ...effects.demoMode },
|
||||
versionUpdate: { ...effects.versionUpdate },
|
||||
tracking: { ...effects.tracking },
|
||||
listingsData: { ...effects.listingsData },
|
||||
provider: { ...effects.provider },
|
||||
jobsData: { ...effects.jobsData },
|
||||
@@ -283,12 +364,34 @@ export const useFredyState = create(
|
||||
userSettings: { ...effects.userSettings },
|
||||
};
|
||||
|
||||
// Wrap actions to track loading state
|
||||
const wrappedActions = {};
|
||||
Object.keys(actions).forEach((slice) => {
|
||||
wrappedActions[slice] = {};
|
||||
Object.keys(actions[slice]).forEach((actionName) => {
|
||||
const originalAction = actions[slice][actionName];
|
||||
if (typeof originalAction === 'function') {
|
||||
wrappedActions[slice][actionName] = async (...args) => {
|
||||
const fullActionName = `${slice}.${actionName}`;
|
||||
set((state) => ({ loading: { ...state.loading, [fullActionName]: true } }));
|
||||
try {
|
||||
return await originalAction(...args);
|
||||
} finally {
|
||||
set((state) => ({ loading: { ...state.loading, [fullActionName]: false } }));
|
||||
}
|
||||
};
|
||||
} else {
|
||||
wrappedActions[slice][actionName] = originalAction;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...initial,
|
||||
__actions: { actions },
|
||||
loading: {},
|
||||
__actions: { actions: wrappedActions },
|
||||
};
|
||||
},
|
||||
{ name: 'fredy' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -312,3 +415,27 @@ export function useSelector(selector, equalityFn = shallow) {
|
||||
export function useActions() {
|
||||
return useFredyState((s) => s.__actions.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific action is currently loading.
|
||||
* @param {Function} action - The action function from useActions()
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function useIsLoading(action) {
|
||||
const actions = useActions();
|
||||
const loading = useSelector((state) => state.loading);
|
||||
|
||||
// Find the action name by comparing the function
|
||||
let actionPath = null;
|
||||
for (const slice in actions) {
|
||||
for (const name in actions[slice]) {
|
||||
if (actions[slice][name] === action) {
|
||||
actionPath = `${slice}.${name}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actionPath) break;
|
||||
}
|
||||
|
||||
return !!loading[actionPath];
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||
@@ -39,8 +38,6 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Headline text="Dashboard" size={3} />
|
||||
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="General" Icon={IconTerminal}>
|
||||
@@ -153,7 +150,12 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||
<SegmentPart
|
||||
name="Provider Insights"
|
||||
Icon={IconStar}
|
||||
helpText="Percentage of found listings over all providers"
|
||||
className="dashboard__provider-insights"
|
||||
>
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__row {
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
@@ -7,4 +11,23 @@
|
||||
margin-bottom: 0; // Handled by Row gutter
|
||||
}
|
||||
}
|
||||
|
||||
&__provider-insights {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 !important;
|
||||
|
||||
.semi-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-height: 300px;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||
import './Jobs.less';
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { Fragment, useState, useCallback } from 'react';
|
||||
|
||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||
import ProviderTable from '../../../components/table/ProviderTable';
|
||||
import ProviderMutator from './components/provider/ProviderMutator';
|
||||
import AreaFilter from './components/areaFilter/AreaFilter';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useActions, useSelector } from '../../../services/state/store';
|
||||
import { xhrPost } from '../../../services/xhr';
|
||||
@@ -44,6 +45,7 @@ export default function JobMutator() {
|
||||
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
|
||||
const defaultEnabled = sourceJob?.enabled ?? true;
|
||||
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
|
||||
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
|
||||
|
||||
const [providerToEdit, setProviderToEdit] = useState(null);
|
||||
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
|
||||
@@ -55,9 +57,15 @@ export default function JobMutator() {
|
||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
|
||||
const navigate = useNavigate();
|
||||
const actions = useActions();
|
||||
|
||||
// Memoize the spatial filter change handler to prevent map reinitializations
|
||||
const handleSpatialFilterChange = useCallback((data) => {
|
||||
setSpatialFilter(data);
|
||||
}, []);
|
||||
|
||||
const isSavingEnabled = () => {
|
||||
return Boolean(notificationAdapterData.length && providerData.length && name);
|
||||
};
|
||||
@@ -76,6 +84,7 @@ export default function JobMutator() {
|
||||
shareWithUsers,
|
||||
name,
|
||||
blacklist,
|
||||
spatialFilter,
|
||||
enabled,
|
||||
jobId: jobToBeEdit?.id || null,
|
||||
});
|
||||
@@ -206,6 +215,13 @@ export default function JobMutator() {
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Area Filter"
|
||||
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
|
||||
>
|
||||
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
Icon={IconUser}
|
||||
name="Sharing with user"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import Map from '../../../../../components/map/Map.jsx';
|
||||
import './AreaFilter.less';
|
||||
|
||||
export default function AreaFilter({ spatialFilter = null, onChange = null }) {
|
||||
return (
|
||||
<div className="areaFilter">
|
||||
<Map
|
||||
style="STANDARD"
|
||||
show3dBuildings={false}
|
||||
enableDrawing={true}
|
||||
initialSpatialFilter={spatialFilter}
|
||||
onDrawingChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
.areaFilter {
|
||||
height: 50rem;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||
import { xhrPost } from '../../../../../services/xhr';
|
||||
@@ -27,10 +27,13 @@ const sortAdapter = (a, b) => {
|
||||
const validate = (selectedAdapter) => {
|
||||
const results = [];
|
||||
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
|
||||
if (uiElement.value == null && !uiElement.optional) {
|
||||
if (uiElement.value == null && !uiElement.optional && uiElement.type !== 'boolean') {
|
||||
results.push('All fields are mandatory and must be set.');
|
||||
continue;
|
||||
}
|
||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
||||
uiElement.value = false;
|
||||
}
|
||||
if (uiElement.type === 'number') {
|
||||
const numberValue = parseFloat(uiElement.value);
|
||||
if (isNaN(numberValue) || numberValue < 0) {
|
||||
@@ -153,12 +156,15 @@ export default function NotificationAdapterMutator({
|
||||
return (
|
||||
<Form key={key}>
|
||||
{uiElement.type === 'boolean' ? (
|
||||
<Switch
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Switch
|
||||
checked={uiElement.value || false}
|
||||
onChange={(checked) => {
|
||||
setValue(selectedAdapter, uiElement, key, checked);
|
||||
}}
|
||||
/>
|
||||
{uiElement.label}
|
||||
</div>
|
||||
) : (
|
||||
<Form.Input
|
||||
style={{ width: '100%' }}
|
||||
@@ -197,27 +203,6 @@ export default function NotificationAdapterMutator({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{validationMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="danger"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
||||
/>
|
||||
)}
|
||||
{successMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="success"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{description != null ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
@@ -264,6 +249,28 @@ export default function NotificationAdapterMutator({
|
||||
<br />
|
||||
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
|
||||
<br />
|
||||
|
||||
{validationMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="danger"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
||||
/>
|
||||
)}
|
||||
{successMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="success"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{getFieldsFor(selectedAdapter)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function Help({ readme }) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
|
||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import {
|
||||
@@ -324,7 +324,12 @@ export default function ListingDetail() {
|
||||
<Row>
|
||||
<Col span={24} lg={12}>
|
||||
<div className="listing-detail__image-container">
|
||||
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
|
||||
<Image
|
||||
src={listing.image_url}
|
||||
fallback={no_image}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
preview={true}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={24} lg={12}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user