diff --git a/lib/FredyRuntime.js b/lib/FredyRuntime.js
index 7693916..0b358f2 100755
--- a/lib/FredyRuntime.js
+++ b/lib/FredyRuntime.js
@@ -107,7 +107,7 @@ class FredyRuntime {
}
return !similar;
});
- filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
+ filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
return filteredList;
}
diff --git a/lib/api/api.js b/lib/api/api.js
index 14dc00e..348efd2 100644
--- a/lib/api/api.js
+++ b/lib/api/api.js
@@ -3,11 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js'
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
import { analyticsRouter } from './routes/analyticsRouter.js';
import { providerRouter } from './routes/providerRouter.js';
+import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
-import { config } from '../utils.js';
import { userRouter } from './routes/userRoute.js';
import { jobRouter } from './routes/jobRouter.js';
-import { versionRouter } from './routes/versionRouter.js';
+import { config } from '../utils.js';
import bodyParser from 'body-parser';
import restana from 'restana';
import files from 'serve-static';
@@ -15,6 +15,7 @@ import path from 'path';
import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
+import { listingsRouter } from './routes/listingsRouter.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = config.port || 9998;
@@ -25,6 +26,8 @@ service.use(staticService);
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
+service.use('/api/listings', authInterceptor());
+
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
@@ -35,6 +38,7 @@ service.use('/api/admin/users', userRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
+service.use('/api/listings', listingsRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js
new file mode 100644
index 0000000..96c0570
--- /dev/null
+++ b/lib/api/routes/listingsRouter.js
@@ -0,0 +1,23 @@
+import restana from 'restana';
+import * as listingStorage from '../../services/storage/listingsStorage.js';
+import { isAdmin as isAdminFn } from '../security.js';
+const service = restana();
+
+const listingsRouter = service.newRouter();
+
+listingsRouter.get('/table', async (req, res) => {
+ const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
+
+ const result = listingStorage.queryListings({
+ page: page ? parseInt(page, 10) : 1,
+ pageSize: pageSize ? parseInt(pageSize, 10) : 50,
+ filter: filter || undefined,
+ sortField: sortfield || null,
+ sortDir: sortdir === 'desc' ? 'desc' : 'asc',
+ userId: req.session.currentUser,
+ isAdmin: isAdminFn(req),
+ });
+ res.body = result;
+ res.send();
+});
+export { listingsRouter };
diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js
index 082767b..4bcdc78 100755
--- a/lib/services/storage/listingsStorage.js
+++ b/lib/services/storage/listingsStorage.js
@@ -166,3 +166,88 @@ export const storeListings = (jobId, providerId, listings) => {
return str.replace(/\s*\([^)]*\)/g, '');
}
};
+
+/**
+ * Query listings with pagination, filtering and sorting.
+ *
+ * @param {Object} params
+ * @param {number} [params.pageSize=50]
+ * @param {number} [params.page=1]
+ * @param {string} [params.filter]
+ * @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
+ * @param {('asc'|'desc')} [params.sortDir='asc']
+ * @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[] }}
+ */
+export const queryListings = ({
+ pageSize = 50,
+ page = 1,
+ filter,
+ sortField = null,
+ sortDir = 'asc',
+ userId = null,
+ isAdmin = false,
+} = {}) => {
+ // sanitize inputs
+ const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
+ const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
+ const offset = (safePage - 1) * safePageSize;
+
+ // build WHERE filter across common text columns
+ const whereParts = [];
+ const params = { limit: safePageSize, offset };
+ // user scoping (non-admin only): restrict to listings whose job belongs to user
+ if (!isAdmin) {
+ params.userId = userId || '__NO_USER__';
+ whereParts.push(`(j.user_id = @userId)`);
+ }
+ if (filter && String(filter).trim().length > 0) {
+ params.filter = `%${String(filter).trim()}%`;
+ whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
+ }
+ const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
+ const whereSqlWithAlias = whereSql
+ .replace(/\btitle\b/g, 'l.title')
+ .replace(/\bdescription\b/g, 'l.description')
+ .replace(/\baddress\b/g, 'l.address')
+ .replace(/\bprovider\b/g, 'l.provider')
+ .replace(/\blink\b/g, 'l.link')
+ .replace(/\bj\.user_id\b/g, 'j.user_id');
+
+ // whitelist sortable fields to avoid SQL injection
+ const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
+ const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
+ const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
+ const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
+ const orderSqlWithAlias = orderSql
+ .replace(/\bcreated_at\b/g, 'l.created_at')
+ .replace(/\bprice\b/g, 'l.price')
+ .replace(/\bsize\b/g, 'l.size')
+ .replace(/\bprovider\b/g, 'l.provider')
+ .replace(/\btitle\b/g, 'l.title')
+ .replace(/\bjob_name\b/g, 'j.name');
+
+ // count total with same WHERE
+ const countRow = SqliteConnection.query(
+ `SELECT COUNT(1) as cnt
+ FROM listings l
+ LEFT JOIN jobs j ON j.id = l.job_id
+ ${whereSqlWithAlias}`,
+ params,
+ );
+ const totalNumber = countRow?.[0]?.cnt ?? 0;
+
+ // fetch page
+ const rows = SqliteConnection.query(
+ `SELECT l.*, j.name AS job_name
+ FROM listings l
+ LEFT JOIN jobs j ON j.id = l.job_id
+ ${whereSqlWithAlias}
+ ${orderSqlWithAlias}
+ LIMIT @limit OFFSET @offset`,
+ params,
+ );
+
+ return { totalNumber, page: safePage, result: rows };
+};
diff --git a/package.json b/package.json
index 3e0b61a..f2b6a9f 100755
--- a/package.json
+++ b/package.json
@@ -63,7 +63,7 @@
"@visactor/vchart": "^2.0.5",
"@visactor/vchart-semi-theme": "^1.12.2",
"@vitejs/plugin-react": "5.0.3",
- "better-sqlite3": "^12.3.0",
+ "better-sqlite3": "^12.4.1",
"body-parser": "2.2.0",
"cheerio": "^1.1.2",
"cookie-session": "2.1.1",
@@ -76,14 +76,14 @@
"node-mailjet": "6.0.9",
"p-throttle": "^8.0.0",
"package-up": "^5.0.0",
- "puppeteer": "^24.22.0",
+ "puppeteer": "^24.22.3",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
- "react-router": "7.9.1",
- "react-router-dom": "7.9.1",
+ "react-router": "7.9.2",
+ "react-router-dom": "7.9.2",
"restana": "5.1.0",
"serve-static": "2.2.0",
"slack": "11.0.2",
diff --git a/test/FredyRuntime/FredyRuntime.test.js b/test/FredyRuntime/FredyRuntime.test.js
new file mode 100644
index 0000000..89817d0
--- /dev/null
+++ b/test/FredyRuntime/FredyRuntime.test.js
@@ -0,0 +1,53 @@
+import { expect } from 'chai';
+import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
+import { mockFredy } from '../utils.js';
+
+describe('FredyRuntime', () => {
+ afterEach(() => {
+ similarityCache.invalidateAllForTest();
+ });
+
+ after(() => {
+ similarityCache.stopCacheCleanup();
+ });
+
+ describe('_filterBySimilarListings', () => {
+ let fredyRuntime;
+
+ beforeEach(async () => {
+ const FredyRuntime = await mockFredy();
+ fredyRuntime = new FredyRuntime({}, null, 'dummy-provider', 'dummy-job', similarityCache);
+ });
+
+ it('should filter out listings with similar title and address already in cache', () => {
+ similarityCache.addCacheEntry('Penthouse', 'Mustermann Straße 1');
+
+ const listings = [
+ { id: '1', title: 'Penthouse', address: 'Mustermann Straße 1' },
+ { id: '2', title: 'Nice apartment', address: 'Mustermann Straße 15' },
+ ];
+
+ const result = fredyRuntime._filterBySimilarListings(listings);
+
+ expect(result).to.have.length(1);
+ expect(result[0].id).to.equal('2');
+ expect(result[0].title).to.equal('Nice apartment');
+
+ expect(similarityCache.hasSimilarEntries('Nice apartment', 'Mustermann Straße 15')).to.be.true;
+ });
+
+ it('should handle listings with null or undefined address', () => {
+ const listings = [
+ { id: '1', title: 'Penthouse', address: null },
+ { id: '2', title: 'Nice apartment', address: undefined },
+ ];
+
+ const result = fredyRuntime._filterBySimilarListings(listings);
+
+ expect(result).to.have.length(2);
+
+ expect(similarityCache.hasSimilarEntries('Penthouse', null)).to.be.true;
+ expect(similarityCache.hasSimilarEntries('Nice apartment', undefined)).to.be.true;
+ });
+ });
+});
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 5c6aa77..fbfab60 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -19,6 +19,7 @@ import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui';
import VersionBanner from './components/version/VersionBanner.jsx';
+import Listings from './views/listings/Listings.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -50,14 +51,11 @@ export default function FredyApp() {
const isAdmin = () => currentUser != null && currentUser.isAdmin;
- const login = () => (
+ return loading ? null : needsLogin() ? (
{record.description == null ? 'No description available' : record.description}
+