From 3249881771e8d1b55df2c6df2696cd1b861d408d Mon Sep 17 00:00:00 2001 From: orangecoding Date: Thu, 11 Jun 2026 08:24:26 +0200 Subject: [PATCH] ability to restore (soft deleted) listings --- lib/api/routes/listingsRouter.js | 20 ++ lib/services/storage/listingsStorage.js | 23 +- package.json | 2 +- test/storage/listingStatus.test.js | 51 +++ .../components/grid/listings/ListingsGrid.jsx | 48 ++- .../grid/listings/ListingsGrid.less | 19 ++ .../components/listings/ListingsOverview.jsx | 293 ++++++++++++------ .../components/listings/ListingsOverview.less | 9 + ui/src/components/table/ListingsTable.jsx | 56 +++- ui/src/components/table/ListingsTable.less | 19 ++ ui/src/locales/de.json | 12 + ui/src/locales/en.json | 12 + ui/src/services/state/store.js | 8 + 13 files changed, 438 insertions(+), 134 deletions(-) diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index 7328e53..8f2ae22 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -26,6 +26,7 @@ export default async function listingsPlugin(fastify) { providerFilter, watchListFilter, statusFilter, + hiddenOnly, sortfield = null, sortdir = 'asc', freeTextFilter, @@ -38,6 +39,7 @@ export default async function listingsPlugin(fastify) { }; const normalizedActivity = toBool(activityFilter); const normalizedWatch = toBool(watchListFilter); + const normalizedHidden = toBool(hiddenOnly) === true; const allowedStatuses = ['applied', 'rejected', 'accepted', 'none']; const normalizedStatus = typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase()) @@ -62,6 +64,7 @@ export default async function listingsPlugin(fastify) { providerFilter, watchListFilter: normalizedWatch, statusFilter: normalizedStatus, + hiddenOnly: normalizedHidden, sortField: sortfield || null, sortDir: sortdir === 'desc' ? 'desc' : 'asc', userId: request.session.currentUser, @@ -192,4 +195,21 @@ export default async function listingsPlugin(fastify) { } return reply.send(); }); + + fastify.post('/restore', async (request, reply) => { + const { ids } = request.body || {}; + const settings = await getSettings(); + try { + if (settings.demoMode && !isAdminFn(request)) { + return reply.code(403).send({ error: 'Sorry, but you cannot restore listings in demo mode ;)' }); + } + if (Array.isArray(ids) && ids.length > 0) { + listingStorage.restoreListingsById(ids); + } + } catch (error) { + logger.error(error); + return reply.code(500).send({ error: error.message }); + } + return reply.send(); + }); } diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index d409fcf..f00c7f7 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -264,6 +264,7 @@ export const storeListings = (jobId, providerId, listings) => { * @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. + * @param {boolean} [params.hiddenOnly=false] - When true, returns only soft-deleted (manually_deleted = 1) listings. * @returns {{ totalNumber:number, page:number, result:Object[] }} */ export const queryListings = ({ @@ -284,6 +285,7 @@ export const queryListings = ({ maxPrice = null, userId = null, isAdmin = false, + hiddenOnly = false, } = {}) => { // sanitize inputs const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50; @@ -365,8 +367,8 @@ export const queryListings = ({ whereParts.push('(l.price <= @maxPrice)'); } - // Build whereSql (filtering by manually_deleted = 0) - whereParts.push('(l.manually_deleted = 0)'); + // Build whereSql: in normal mode hide soft-deleted; in hiddenOnly mode show only soft-deleted. + whereParts.push(hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)'); const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; @@ -463,6 +465,23 @@ export const deleteListingsById = (ids, hardDelete = false) => { ); }; +/** + * Restore previously soft-deleted listings by clearing their `manually_deleted` flag. + * + * @param {string[]} ids - Array of DB row IDs to restore. + * @returns {any} The result from SqliteConnection.execute. + */ +export const restoreListingsById = (ids) => { + if (!Array.isArray(ids) || ids.length === 0) return; + const placeholders = ids.map(() => '?').join(','); + return SqliteConnection.execute( + `UPDATE listings + SET manually_deleted = 0 + WHERE id IN (${placeholders})`, + ids, + ); +}; + /** * Return all listings that are active, have an address, and do not yet have geocoordinates. * diff --git a/package.json b/package.json index e5df070..68461f2 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "22.7.0", + "version": "22.8.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", diff --git a/test/storage/listingStatus.test.js b/test/storage/listingStatus.test.js index 6e21fcf..a732162 100644 --- a/test/storage/listingStatus.test.js +++ b/test/storage/listingStatus.test.js @@ -120,6 +120,57 @@ describe('listingsStorage.queryListings statusFilter', () => { }); }); +describe('listingsStorage.queryListings hiddenOnly', () => { + let listingsStorage; + + beforeEach(async () => { + calls.execute.length = 0; + calls.query.length = 0; + sqliteMock.__queryHandler = (sql) => { + if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }]; + return []; + }; + listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); + }); + + it('filters by manually_deleted = 0 by default', () => { + listingsStorage.queryListings({ userId: 'u1', isAdmin: true }); + const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql)); + expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 0\)/); + }); + + it('filters by manually_deleted = 1 when hiddenOnly is true', () => { + listingsStorage.queryListings({ userId: 'u1', isAdmin: true, hiddenOnly: true }); + const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql)); + expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 1\)/); + expect(pageQuery.sql).not.toMatch(/\(l\.manually_deleted = 0\)/); + }); +}); + +describe('listingsStorage.restoreListingsById', () => { + let listingsStorage; + + beforeEach(async () => { + calls.execute.length = 0; + calls.query.length = 0; + sqliteMock.__queryHandler = null; + listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); + }); + + it('clears the manually_deleted flag for the given ids', () => { + listingsStorage.restoreListingsById(['a', 'b']); + expect(calls.execute).toHaveLength(1); + expect(calls.execute[0].sql).toMatch(/UPDATE listings\s+SET manually_deleted = 0\s+WHERE id IN \(\?,\?\)/); + expect(calls.execute[0].params).toEqual(['a', 'b']); + }); + + it('is a no-op when ids are missing or empty', () => { + listingsStorage.restoreListingsById([]); + listingsStorage.restoreListingsById(undefined); + expect(calls.execute).toHaveLength(0); + }); +}); + describe('listingsStorage.getListingById', () => { let listingsStorage; diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index 1f3ab15..c6ea39a 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -22,9 +22,9 @@ import './ListingsGrid.less'; import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx'; /** - * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props + * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props */ -const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => { +const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHiddenView = false, onStatusChange }) => { const t = useTranslation(); const locale = useLocale(); return ( @@ -126,18 +126,38 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }} /> - -