diff --git a/doc/screenshot2.png b/doc/screenshot2.png
index 21e92b8..87b8130 100644
Binary files a/doc/screenshot2.png and b/doc/screenshot2.png differ
diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js
index 5e6e1d5..bf48f03 100644
--- a/lib/api/routes/listingsRouter.js
+++ b/lib/api/routes/listingsRouter.js
@@ -1,19 +1,51 @@
import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js';
+import * as watchListStorage from '../../services/storage/watchListStorage.js';
import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js';
+import { nullOrEmpty } from '../../utils.js';
+import { getJobs } from '../../services/storage/jobStorage.js';
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 {
+ page,
+ pageSize = 50,
+ activityFilter,
+ jobNameFilter,
+ providerFilter,
+ watchListFilter,
+ sortfield = null,
+ sortdir = 'asc',
+ freeTextFilter,
+ } = req.query || {};
+
+ // normalize booleans (accept true, 'true', 1, '1')
+ const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
+ const normalizedActivity = toBool(activityFilter) ? true : null;
+ const normalizedWatch = toBool(watchListFilter) ? true : null;
+
+ let jobFilter = null;
+ let jobIdFilter = null;
+ const jobs = getJobs();
+ if (!nullOrEmpty(jobNameFilter)) {
+ const job = jobs.find((j) => j.id === jobNameFilter);
+ jobFilter = job != null ? job.name : null;
+ jobIdFilter = job != null ? job.id : null;
+ }
res.body = listingStorage.queryListings({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
- filter: filter || undefined,
+ freeTextFilter: freeTextFilter || null,
+ activityFilter: normalizedActivity,
+ jobNameFilter: jobFilter,
+ jobIdFilter: jobIdFilter,
+ providerFilter,
+ watchListFilter: normalizedWatch,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser,
@@ -22,6 +54,25 @@ listingsRouter.get('/table', async (req, res) => {
res.send();
});
+// Toggle watch state for the current user on a listing
+listingsRouter.post('/watch', async (req, res) => {
+ try {
+ const { listingId } = req.body || {};
+ const userId = req.session?.currentUser;
+ if (!listingId || !userId) {
+ res.statusCode = 400;
+ res.body = { message: 'listingId or user not provided' };
+ return res.send();
+ }
+ watchListStorage.toggleWatch(listingId, userId);
+ } catch (error) {
+ logger.error(error);
+ res.statusCode = 500;
+ res.body = { message: 'Failed to toggle watch' };
+ }
+ res.send();
+});
+
listingsRouter.delete('/job', async (req, res) => {
const { jobId } = req.body;
try {
diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js
index 6ee648b..47748c5 100755
--- a/lib/services/storage/listingsStorage.js
+++ b/lib/services/storage/listingsStorage.js
@@ -48,7 +48,8 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
return SqliteConnection.query(
`SELECT hash
FROM listings
- WHERE job_id = @jobId AND provider = @providerId`,
+ WHERE job_id = @jobId
+ AND provider = @providerId`,
{ jobId, providerId },
).map((r) => r.hash);
};
@@ -63,7 +64,9 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query(
`SELECT *
FROM listings
- WHERE is_active is null OR is_active = 1 ORDER BY provider`,
+ WHERE is_active is null
+ OR is_active = 1
+ ORDER BY provider`,
);
};
@@ -173,7 +176,11 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {Object} params
* @param {number} [params.pageSize=50]
* @param {number} [params.page=1]
- * @param {string} [params.filter]
+ * @param {string} [params.freeTextFilter]
+ * @param {object} [params.activityFilter]
+ * @param {object} [params.jobNameFilter]
+ * @param {object} [params.providerFilter]
+ * @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 {string} [params.userId] - Current user id used to scope listings (ignored for admins).
@@ -183,7 +190,12 @@ export const storeListings = (jobId, providerId, listings) => {
export const queryListings = ({
pageSize = 50,
page = 1,
- filter,
+ activityFilter,
+ jobNameFilter,
+ jobIdFilter,
+ providerFilter,
+ watchListFilter,
+ freeTextFilter,
sortField = null,
sortDir = 'asc',
userId = null,
@@ -197,15 +209,39 @@ export const queryListings = ({
// build WHERE filter across common text columns
const whereParts = [];
const params = { limit: safePageSize, offset };
+ // always provide userId param for watched-flag evaluation (null -> no matches)
+ params.userId = userId || '__NO_USER__';
// 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()}%`;
+ if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
+ params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
}
+ // activityFilter: when true -> only active listings (is_active = 1)
+ if (activityFilter === true) {
+ whereParts.push('(is_active = 1)');
+ }
+ // Prefer filtering by job id when provided (unambiguous and robust)
+ if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
+ params.jobId = String(jobIdFilter).trim();
+ whereParts.push('(l.job_id = @jobId)');
+ } else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
+ // Fallback to exact job name match
+ params.jobName = String(jobNameFilter).trim();
+ whereParts.push('(j.name = @jobName)');
+ }
+ // providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
+ if (providerFilter && String(providerFilter).trim().length > 0) {
+ params.providerName = String(providerFilter).trim();
+ whereParts.push('(provider = @providerName)');
+ }
+ // watchListFilter: when true -> only watched listings
+ if (watchListFilter === true) {
+ whereParts.push('(wl.id IS NOT NULL)');
+ }
+
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
@@ -213,10 +249,13 @@ export const queryListings = ({
.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');
+ .replace(/\bis_active\b/g, 'l.is_active')
+ .replace(/\bj\.user_id\b/g, 'j.user_id')
+ .replace(/\bj\.name\b/g, 'j.name')
+ .replace(/\bwl\.id\b/g, 'wl.id');
// whitelist sortable fields to avoid SQL injection
- const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
+ const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
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';
@@ -226,25 +265,31 @@ export const queryListings = ({
.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');
+ .replace(/\bjob_name\b/g, 'j.name')
+ // Sort by computed watch flag when requested
+ .replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
// count total with same WHERE
const countRow = SqliteConnection.query(
`SELECT COUNT(1) as cnt
FROM listings l
- LEFT JOIN jobs j ON j.id = l.job_id
- ${whereSqlWithAlias}`,
+ LEFT JOIN jobs j ON j.id = l.job_id
+ LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
+ ${whereSqlWithAlias}`,
params,
);
const totalNumber = countRow?.[0]?.cnt ?? 0;
// fetch page
const rows = SqliteConnection.query(
- `SELECT l.*, j.name AS job_name
+ `SELECT l.*,
+ j.name AS job_name,
+ CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
- LEFT JOIN jobs j ON j.id = l.job_id
- ${whereSqlWithAlias}
- ${orderSqlWithAlias}
+ LEFT JOIN jobs j ON j.id = l.job_id
+ LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
+ ${whereSqlWithAlias}
+ ${orderSqlWithAlias}
LIMIT @limit OFFSET @offset`,
params,
);
@@ -260,7 +305,12 @@ export const queryListings = ({
*/
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
- return SqliteConnection.execute(`DELETE FROM listings WHERE job_id = @jobId`, { jobId });
+ return SqliteConnection.execute(
+ `DELETE
+ FROM listings
+ WHERE job_id = @jobId`,
+ { jobId },
+ );
};
/**
@@ -272,5 +322,10 @@ export const deleteListingsByJobId = (jobId) => {
export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
- return SqliteConnection.execute(`DELETE FROM listings WHERE id IN (${placeholders})`, ids);
+ return SqliteConnection.execute(
+ `DELETE
+ FROM listings
+ WHERE id IN (${placeholders})`,
+ ids,
+ );
};
diff --git a/lib/services/storage/migrations/sql/3.changeset-for-listings.js b/lib/services/storage/migrations/sql/3.changeset-for-listings.js
new file mode 100644
index 0000000..9fd35ac
--- /dev/null
+++ b/lib/services/storage/migrations/sql/3.changeset-for-listings.js
@@ -0,0 +1,8 @@
+// Migration: Adding a changeset field to the listings table in preparation for
+// a price watch feature
+
+export function up(db) {
+ db.exec(`
+ ALTER TABLE listings ADD COLUMN change_set jsonb;
+ `);
+}
diff --git a/lib/services/storage/migrations/sql/4.watch-list.js b/lib/services/storage/migrations/sql/4.watch-list.js
new file mode 100644
index 0000000..1ccda28
--- /dev/null
+++ b/lib/services/storage/migrations/sql/4.watch-list.js
@@ -0,0 +1,15 @@
+// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
+
+export function up(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS watch_list
+ (
+ id TEXT PRIMARY KEY,
+ listing_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id);
+ `);
+}
diff --git a/lib/services/storage/watchListStorage.js b/lib/services/storage/watchListStorage.js
new file mode 100644
index 0000000..49d4f30
--- /dev/null
+++ b/lib/services/storage/watchListStorage.js
@@ -0,0 +1,64 @@
+import SqliteConnection from './SqliteConnection.js';
+import { nanoid } from 'nanoid';
+
+/**
+ * Create a watch entry. Idempotent due to unique index (listing_id, user_id).
+ * @param {string} listingId
+ * @param {string} userId
+ * @returns {{created:boolean}}
+ */
+export const createWatch = (listingId, userId) => {
+ if (!listingId || !userId) return { created: false };
+ try {
+ SqliteConnection.execute(
+ `INSERT INTO watch_list (id, listing_id, user_id)
+ VALUES (@id, @listing_id, @user_id)
+ ON CONFLICT(listing_id, user_id) DO NOTHING`,
+ { id: nanoid(), listing_id: listingId, user_id: userId },
+ );
+ // check whether it exists now
+ const row = SqliteConnection.query(
+ `SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
+ { listing_id: listingId, user_id: userId },
+ );
+ return { created: row.length > 0 };
+ } catch {
+ return { created: false };
+ }
+};
+
+/**
+ * Delete a watch entry.
+ * @param {string} listingId
+ * @param {string} userId
+ * @returns {{deleted:boolean}}
+ */
+export const deleteWatch = (listingId, userId) => {
+ if (!listingId || !userId) return { deleted: false };
+ const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, {
+ listing_id: listingId,
+ user_id: userId,
+ });
+ return { deleted: Boolean(res?.changes) };
+};
+
+/**
+ * Toggle a watch entry. If exists -> delete, otherwise create.
+ * @param {string} listingId
+ * @param {string} userId
+ * @returns {{watched:boolean}}
+ */
+export const toggleWatch = (listingId, userId) => {
+ if (!listingId || !userId) return { watched: false };
+ const exists =
+ SqliteConnection.query(
+ `SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
+ { listing_id: listingId, user_id: userId },
+ ).length > 0;
+ if (exists) {
+ deleteWatch(listingId, userId);
+ return { watched: false };
+ }
+ createWatch(listingId, userId);
+ return { watched: true };
+};
diff --git a/package.json b/package.json
index d279c0d..c0750e7 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fredy",
- "version": "14.0.1",
+ "version": "14.1.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
diff --git a/ui/src/components/table/listings/ListingsFilter.jsx b/ui/src/components/table/listings/ListingsFilter.jsx
new file mode 100644
index 0000000..8c36bff
--- /dev/null
+++ b/ui/src/components/table/listings/ListingsFilter.jsx
@@ -0,0 +1,45 @@
+import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
+import React from 'react';
+import { useSelector } from '../../../services/state/store.js';
+import { Typography } from '@douyinfe/semi-ui';
+
+import './ListingsFilter.less';
+
+export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
+ const jobs = useSelector((state) => state.jobs.jobs);
+ const provider = useSelector((state) => state.provider);
+ const { Title } = Typography;
+ return (
+
+