diff --git a/lib/mcp/mcpNormalizer.js b/lib/mcp/mcpNormalizer.js index f4c69c4..e22c949 100644 --- a/lib/mcp/mcpNormalizer.js +++ b/lib/mcp/mcpNormalizer.js @@ -127,7 +127,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) { md += `| ID | Title | Address | Price | Size | Provider | Active | Status | 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'} | ${cell(l.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; + md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; } md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`; } else { @@ -156,7 +156,10 @@ export function normalizeGetListing(listing) { md += `- **Link:** ${listing.link || '–'}\n`; md += `- **Image:** ${listing.image_url || '–'}\n`; md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`; - md += `- **Status:** ${listing.status || '–'}\n`; + md += `- **Status:** ${listing.status?.status || '–'}\n`; + if (listing.status?.setAt) { + md += `- **Status set at:** ${formatDate(listing.status.setAt)}\n`; + } md += `- **Created:** ${formatDate(listing.created_at)}\n`; md += `- **Job:** ${listing.job_name || '–'}\n`; if (listing.latitude != null && listing.longitude != null) { diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index a388b6c..86a2801 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -3,10 +3,27 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import { nullOrEmpty } from '../../utils.js'; +import { nullOrEmpty, fromJson } from '../../utils.js'; import SqliteConnection from './SqliteConnection.js'; import { nanoid } from 'nanoid'; +/** + * Parse the JSON `status` column of a listing row in place. + * + * The DB stores status as a JSON payload `{ status, setAt }` (or NULL). + * Consumers expect an object/null, so we normalize before returning. + * + * @param {Object|null|undefined} row - A raw row from the listings table. + * @returns {Object|null|undefined} The same row with `status` parsed. + */ +const parseListingStatus = (row) => { + if (row == null) return row; + if (typeof row.status === 'string') { + row.status = fromJson(row.status, null); + } + return row; +}; + /** * Return a list of known listing hashes for a given job and provider. * Useful to de-duplicate before inserting new listings. @@ -319,7 +336,9 @@ export const queryListings = ({ } else if (watchListFilter === false) { whereParts.push('(wl.id IS NULL)'); } - // statusFilter: 'applied'|'rejected'|'accepted' -> equality; 'none' -> NULL + // statusFilter: 'applied'|'rejected'|'accepted' -> equality on JSON status field; 'none' -> NULL. + // The status column is a JSON payload `{ status, setAt }`, so we extract the inner + // status string for comparison instead of matching the raw text. if (statusFilter === 'none') { whereParts.push('(l.status IS NULL)'); } else if ( @@ -327,7 +346,7 @@ export const queryListings = ({ ['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase()) ) { params.statusValue = statusFilter.toLowerCase(); - whereParts.push('(l.status = @statusValue)'); + whereParts.push(`(json_extract(l.status, '$.status') = @statusValue)`); } // Time range filters (unix timestamps in milliseconds) if (Number.isFinite(createdAfter) && createdAfter > 0) { @@ -403,7 +422,7 @@ export const queryListings = ({ params, ); - return { totalNumber, page: safePage, result: rows }; + return { totalNumber, page: safePage, result: rows.map(parseListingStatus) }; }; /** @@ -638,7 +657,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => { if (!isAdmin) { whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`; } - return ( + return parseListingStatus( SqliteConnection.query( `SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched FROM listings l @@ -646,7 +665,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => { LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`, params, - )[0] || null + )[0] || null, ); }; @@ -674,6 +693,10 @@ export const setListingNotes = (id, notes) => { /** * Set or clear the status of a single listing. * + * The status column stores a JSON payload `{ status, setAt }` so consumers + * can show both the user's decision and when it was made. Passing `null` + * clears the column. + * * @param {string} id - The listing ID. * @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear. * @returns {number} Number of rows affected (0 if listing not found). @@ -685,9 +708,10 @@ export const setListingStatus = (id, status) => { if (normalized != null && !allowed.includes(normalized)) { throw new Error(`Invalid listing status: ${status}`); } + const payload = normalized == null ? null : JSON.stringify({ status: normalized, setAt: Date.now() }); const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, { id, - status: normalized, + status: payload, }); return res?.changes ?? 0; }; diff --git a/lib/services/storage/migrations/sql/18.add-listing-status.js b/lib/services/storage/migrations/sql/18.add-listing-status.js index 9513fcf..9552cdc 100644 --- a/lib/services/storage/migrations/sql/18.add-listing-status.js +++ b/lib/services/storage/migrations/sql/18.add-listing-status.js @@ -5,7 +5,7 @@ export function up(db) { db.exec(` - ALTER TABLE listings ADD COLUMN status TEXT; - CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (status); + ALTER TABLE listings ADD COLUMN status JSON; + CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (json_extract(status, '$.status')); `); } diff --git a/test/storage/listingStatus.test.js b/test/storage/listingStatus.test.js index fd3aee5..6e21fcf 100644 --- a/test/storage/listingStatus.test.js +++ b/test/storage/listingStatus.test.js @@ -42,15 +42,21 @@ describe('listingsStorage.setListingStatus', () => { listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); }); - it('runs an UPDATE with the normalized status and listing id', () => { + it('runs an UPDATE storing a JSON payload with status and setAt', () => { + const before = Date.now(); const changes = listingsStorage.setListingStatus('listing-1', 'Applied'); + const after = Date.now(); expect(changes).toBe(1); expect(calls.execute).toHaveLength(1); expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/); - expect(calls.execute[0].params).toEqual({ id: 'listing-1', status: 'applied' }); + expect(calls.execute[0].params.id).toBe('listing-1'); + const parsed = JSON.parse(calls.execute[0].params.status); + expect(parsed.status).toBe('applied'); + expect(parsed.setAt).toBeGreaterThanOrEqual(before); + expect(parsed.setAt).toBeLessThanOrEqual(after); }); - it('accepts null to clear the status', () => { + it('accepts null to clear the status (no JSON wrapping)', () => { listingsStorage.setListingStatus('listing-2', null); expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null }); }); @@ -87,10 +93,10 @@ describe('listingsStorage.queryListings statusFilter', () => { expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/); }); - it("adds 'l.status = @statusValue' for a concrete status", () => { + it('extracts the inner status field via json_extract for a concrete status', () => { listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true }); const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql)); - expect(pageQuery.sql).toMatch(/\(l\.status = @statusValue\)/); + expect(pageQuery.sql).toMatch(/json_extract\(l\.status, '\$\.status'\) = @statusValue/); expect(pageQuery.params.statusValue).toBe('applied'); }); @@ -99,6 +105,49 @@ describe('listingsStorage.queryListings statusFilter', () => { const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql)); expect(pageQuery.sql).not.toMatch(/status/i); }); + + it('parses the JSON status payload of returned rows into an object', () => { + sqliteMock.__queryHandler = (sql) => { + if (/COUNT\(1\)/.test(sql)) return [{ cnt: 2 }]; + return [ + { id: 'a', status: JSON.stringify({ status: 'applied', setAt: 1700000000000 }) }, + { id: 'b', status: null }, + ]; + }; + const result = listingsStorage.queryListings({ userId: 'u1', isAdmin: true }); + expect(result.result[0].status).toEqual({ status: 'applied', setAt: 1700000000000 }); + expect(result.result[1].status).toBeNull(); + }); +}); + +describe('listingsStorage.getListingById', () => { + let listingsStorage; + + beforeEach(async () => { + calls.execute.length = 0; + calls.query.length = 0; + listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); + }); + + it('parses the JSON status payload of the returned row', () => { + sqliteMock.__queryHandler = () => [ + { id: 'a', status: JSON.stringify({ status: 'rejected', setAt: 1700000000001 }) }, + ]; + const row = listingsStorage.getListingById('a', 'u1', true); + expect(row.status).toEqual({ status: 'rejected', setAt: 1700000000001 }); + }); + + it('returns null status untouched', () => { + sqliteMock.__queryHandler = () => [{ id: 'a', status: null }]; + const row = listingsStorage.getListingById('a', 'u1', true); + expect(row.status).toBeNull(); + }); + + it('returns null when no row is found', () => { + sqliteMock.__queryHandler = () => []; + const row = listingsStorage.getListingById('missing', 'u1', true); + expect(row).toBeNull(); + }); }); describe('watchListStorage.ensureWatch', () => { diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index b31ed06..b931df1 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -87,7 +87,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
e.stopPropagation()}> onStatusChange?.(item, next)} onTriggerClick={(e) => e.stopPropagation()} diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx index e18cedd..54c06c1 100644 --- a/ui/src/components/table/ListingsTable.jsx +++ b/ui/src/components/table/ListingsTable.jsx @@ -81,7 +81,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
e.stopPropagation()}> onStatusChange?.(item, next)} onTriggerClick={(e) => e.stopPropagation()} diff --git a/ui/src/views/listings/ListingDetail.jsx b/ui/src/views/listings/ListingDetail.jsx index 6110168..3220cce 100644 --- a/ui/src/views/listings/ListingDetail.jsx +++ b/ui/src/views/listings/ListingDetail.jsx @@ -21,6 +21,7 @@ import { Spin, Toast, TextArea, + Tooltip, } from '@douyinfe/semi-ui-19'; import { IconArrowLeft, @@ -315,35 +316,58 @@ export default function ListingDetail() { if (!listing) return null; + const statusLabel = listing.status?.status + ? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1) + : null; + const data = [ - { key: 'Price', value: `${listing.price} €`, Icon: }, + { + key: 'Price', + value: `${listing.price} €`, + Icon: , + helpText: 'The asking price of this listing, as reported by the provider.', + }, { key: 'Size', value: listing.size ? `${listing.size} m²` : 'N/A', Icon: , + helpText: 'Living space of the listing in square meters.', }, { key: 'Rooms', value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A', Icon: , + helpText: 'Number of rooms in the listing.', }, { key: 'Job', value: listing.job_name, Icon: , + helpText: 'The Fredy job that found this listing.', }, { key: 'Provider', value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown', Icon: , + helpText: 'The real estate portal where this listing was scraped from.', }, { key: 'Added', value: timeService.format(listing.created_at), Icon: , + helpText: 'When Fredy first added this listing to your database.', }, ]; + if (statusLabel) { + data.push({ + key: 'Status', + value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel, + Icon: , + helpText: 'The status you marked for this listing and when you set it.', + }); + } + return (