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 ( + + Filter by: + +
+ + + onWatchListFilter(e.target.checked)}>Only Watch List + + + onActivityFilter(e.target.checked)}>Only Active Listings + + + + + + + + +
+ ); +} diff --git a/ui/src/components/table/listings/ListingsFilter.less b/ui/src/components/table/listings/ListingsFilter.less new file mode 100644 index 0000000..a3a1d23 --- /dev/null +++ b/ui/src/components/table/listings/ListingsFilter.less @@ -0,0 +1,4 @@ +.listingsFilter { + margin-bottom: 1rem; + background: rgb(53, 54, 60); +} \ No newline at end of file diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/listings/ListingsTable.jsx similarity index 55% rename from ui/src/components/table/ListingsTable.jsx rename to ui/src/components/table/listings/ListingsTable.jsx index 9ca717b..b0991de 100644 --- a/ui/src/components/table/ListingsTable.jsx +++ b/ui/src/components/table/listings/ListingsTable.jsx @@ -1,21 +1,87 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Card, Toast } from '@douyinfe/semi-ui'; -import { useActions, useSelector } from '../../services/state/store.js'; -import { IconClose, IconDelete, IconSearch, IconTick } from '@douyinfe/semi-icons'; -import * as timeService from '../../services/time/timeService.js'; +import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui'; +import { useActions, useSelector } from '../../../services/state/store.js'; +import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons'; +import * as timeService from '../../../services/time/timeService.js'; import debounce from 'lodash/debounce'; -import no_image from '../../assets/no_image.jpg'; +import no_image from '../../../assets/no_image.jpg'; import './ListingsTable.less'; -import { format } from '../../services/time/timeService.js'; +import { format } from '../../../services/time/timeService.js'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { xhrDelete } from '../../services/xhr.js'; +import { xhrDelete, xhrPost } from '../../../services/xhr.js'; +import ListingsFilter from './ListingsFilter.jsx'; const columns = [ { title: '#', + width: 100, + dataIndex: 'isWatched', + sorter: true, + render: (id, row) => { + return ( +
+ +
+ ); + }, + }, + { + title: 'State', dataIndex: 'is_active', - width: 58, + width: 84, sorter: true, render: (value) => { return value ? ( @@ -25,7 +91,7 @@ const columns = [ padding: '.4rem', color: 'var(--semi-color-white)', }} - content="Listing still online" + content="Listing is still active" > @@ -37,7 +103,7 @@ const columns = [ padding: '.4rem', color: 'var(--semi-color-white)', }} - content="Listing not online anymore" + content="Listing is inactive" > @@ -48,15 +114,16 @@ const columns = [ { title: 'Job-Name', sorter: true, + ellipsis: true, dataIndex: 'job_name', - width: 170, + width: 150, }, { title: 'Listing date', width: 130, dataIndex: 'created_at', sorter: true, - render: (text) => timeService.format(text), + render: (text) => timeService.format(text, false), }, { title: 'Provider', @@ -107,8 +174,11 @@ export default function ListingsTable() { const [page, setPage] = useState(1); const pageSize = 10; const [sortData, setSortData] = useState({}); - const [filter, setFilter] = useState(null); - const [selectedKeys, setSelectedKeys] = useState([]); + const [freeTextFilter, setFreeTextFilter] = useState(null); + const [watchListFilter, setWatchListFilter] = useState(null); + const [jobNameFilter, setJobNameFilter] = useState(null); + const [activityFilter, setActivityFilter] = useState(null); + const [providerFilter, setProviderFilter] = useState(null); const handlePageChange = (_page) => { setPage(_page); @@ -122,20 +192,21 @@ export default function ListingsTable() { sortfield = sortData.field; sortdir = sortData.direction; } - actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter }); + actions.listingsTable.getListingsTable({ + page, + pageSize, + sortfield, + sortdir, + freeTextFilter, + filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter }, + }); }; useEffect(() => { loadTable(); - }, [page, sortData, filter]); + }, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]); - const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []); - - const rowSelection = { - onChange: (selectedRowKeys) => { - setSelectedKeys(selectedRowKeys); - }, - }; + const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []); const expandRowRender = (record) => { return ( @@ -169,20 +240,14 @@ export default function ListingsTable() { ); }; - const onRemoveSelectedListings = async () => { - if (selectedKeys != null && selectedKeys.length > 0) { - try { - await xhrDelete('/api/listings/', { ids: selectedKeys }); - Toast.success('Listing(s) successfully removed'); - loadTable(); - } catch (error) { - Toast.error(error); - } - } - }; - return (
+ } showClear @@ -190,22 +255,19 @@ export default function ListingsTable() { placeholder="Search" onChange={handleFilterChange} /> - {selectedKeys != null && selectedKeys.length > 0 && ( - - - - )} { + return { + ...row, + reloadTable: loadTable, + }; + })} onChange={(changeSet) => { if (changeSet?.extra?.changeType === 'sorter') { setSortData({ diff --git a/ui/src/components/table/ListingsTable.less b/ui/src/components/table/listings/ListingsTable.less similarity index 100% rename from ui/src/components/table/ListingsTable.less rename to ui/src/components/table/listings/ListingsTable.less diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 60275ff..211249a 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -132,14 +132,22 @@ export const useFredyState = create( }, }, listingsTable: { - async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) { + async getListingsTable({ + page = 1, + pageSize = 20, + freeTextFilter = null, + sortfield = null, + sortdir = 'asc', + filter, + }) { try { const qryString = queryString.stringify({ page, pageSize, - filter, + freeTextFilter, sortfield, sortdir, + ...filter, }); const response = await xhrGet(`/api/listings/table?${qryString}`); set((state) => ({ diff --git a/ui/src/services/time/timeService.js b/ui/src/services/time/timeService.js index ac1fbd7..7f3b29d 100644 --- a/ui/src/services/time/timeService.js +++ b/ui/src/services/time/timeService.js @@ -1,11 +1,12 @@ -export function format(ts) { +export function format(ts, showSeconds = true) { return new Intl.DateTimeFormat('default', { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', - second: 'numeric', + ...(showSeconds ? { second: 'numeric' } : {}), }).format(ts); } + export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60); diff --git a/ui/src/views/listings/Listings.jsx b/ui/src/views/listings/Listings.jsx index 308484d..540789d 100644 --- a/ui/src/views/listings/Listings.jsx +++ b/ui/src/views/listings/Listings.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import ListingsTable from '../../components/table/ListingsTable.jsx'; +import ListingsTable from '../../components/table/listings/ListingsTable.jsx'; export default function Listings() { return (