openvibers/openvibe.rent Commits

Rent: alerts through OpenVibe.Watch (every search is an Atom feed; Get alerts opens Watch with it filled in) (#40)

OP OpenVibers committed 29 days ago · f8734916fe · 5 files, +53 −2

**Rent alerts through OpenVibe.Watch: every search is an Atom feed (`/listings.atom`), and Get alerts opens Watch with it filled in.**

**Why:** the saved searches page said "Alerts that come to you … arrive later through OpenVibe.Watch", and the README said "Alerts by notification come later". Rent's composition in the plan (T19) is Sources + Search + Watch + AI. Watch already follows RSS and Atom feeds and takes prefilled links, the same way Work#38 did it.

**Change:**
- **`GET /listings.atom`** takes the same filters as `/listings` (`q`, `kind`, `city`, `country`, `period`, `min`, `max`).
  - It holds the newest 25 active listings. Each entry has the title with its price, a link to the listing's page, its posted time, and the kind, place and a short description as summary.
  - It is public and cached 5 minutes. Text is XML-escaped and control characters are stripped. robots.txt allows it.
- **Listings page:** the feed in the head (`rel="alternate"`, `application/atom+xml`). **Get alerts for this search** opens `https://openvibe.watch/watches/new?template=feed&url=<the feed>&name=Rent: <the search>`, where the person reviews and creates the watch.
- **Saved searches:** each has **Get alerts** for its own feed. The notice now explains what the button does.
- **Layout:** `page({ feed })` writes the alternate link. A little CSS lays out the alert row.

**Test (`listings-pages.test.js`):**
- the feed's type, caching, rooms-only entries for `kind=room&city=Lisbon`, and its self link;
- the page's head link;
- the Watch link's template, feed URL and name (`Rent: Room in Lisbon`);
- a saved search's Get alerts link.

The perf budget and layout tests pass, as do the 4 test files the change reaches.

**Docs:** README's "What works" table has an Alerts row.

Changes

ddiff --git a/README.md b/README.md
iindex bab2989..17ff7b1 100644
---- a/README.md
++++ b/README.md
@@@ -112,7 +112,8 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
| One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. |
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
--| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. Alerts by notification come later through OpenVibe.Watch. |
++| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. |
++| Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. |
| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
| Home prices | [server/housing/hpi.js](server/housing/hpi.js) | A small card on `/housing` shows the FHFA House Price Index change over the past year and five years for the matching MSA (or the state), as a repeat-sales index — never a price — with the source linked back. |
| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/housing`, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. |
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 604fdc7..da04aba 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -40,6 +40,7 @@ form.inline { display: inline; }
/* Listings: one card per listing — what kind, the title, where, the price on the right. */
.txt { white-space: pre-line; }
.listing-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; }
++.listing-alert { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 10px 0; }
.listing { --k: 212; display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
.listing:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
.listing.kind-room { --k: 265; } .listing.kind-house { --k: 150; } .listing.kind-commercial { --k: 32; } .listing.kind-parking { --k: 190; } .listing.kind-equipment { --k: 340; } .listing.kind-other { --k: 220; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1d839d1..d42efb7 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -349,6 +349,33 @@ ${hudNote(out ? out.fetched_at : null)}`,
housingSearch(req, { statusCode: 200, setHeader: (k, v) => { if (String(k).toLowerCase() === 'retry-after') retryAfter = String(v); }, end: refused }, allowed);
});
++ // ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──
++ const WATCH_NEW = 'https://openvibe.watch/watches/new';
++ const feedPath = (filters) => { const qs = listings.filtersToQuery(filters); return `/listings.atom${qs ? `?${qs}` : ''}`; };
++ /** Watch's new-watch form, filled in to follow one search's feed; the person reviews and creates it on Watch. */
++ const alertHref = (filters) => `${WATCH_NEW}?${new URLSearchParams({ template: 'feed', url: `${base()}${feedPath(filters)}`, name: `Rent: ${savedTitle(filters)}`.slice(0, 80) })}`;
++ const xml = (v) => String(v == null ? '' : v).replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]))
++ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
++ const isoOf = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString() : new Date().toISOString(); };
++
++ r.get('/listings.atom', async (req, res) => {
++ const filters = listings.normalizeFilters(req.query);
++ const out = await store.search(s, filters, {});
++ const rows = out.rows.slice(0, 25);
++ const self = `${base()}${feedPath(filters)}`;
++ const entries = rows.map((row) => `<entry><id>${xml(`${base()}/listings/${row.id}`)}</id><title>${xml(`${row.title} · ${listings.priceText(row)}`)}</title>`
++ + `<link rel="alternate" type="text/html" href="${xml(`${base()}/listings/${row.id}`)}"/><updated>${isoOf(row.created_at)}</updated>`
++ + `<summary>${xml([listings.KIND_TEXT[row.kind] || row.kind, whereOf(row), truncate(row.description || '', 300)].filter(Boolean).join(' — '))}</summary></entry>`);
++ res.set('Cache-Control', 'public, max-age=300');
++ res.type('application/atom+xml; charset=utf-8').send(`<?xml version="1.0" encoding="utf-8"?>
++<feed xmlns="http://www.w3.org/2005/Atom"><id>${xml(self)}</id><title>${xml(`OpenVibe.Rent: ${savedTitle(filters)}`)}</title>`
++ + `<link rel="self" href="${xml(self)}"/><link rel="alternate" type="text/html" href="${xml(`${base()}${feedPath(filters).replace('/listings.atom', '/listings')}`)}"/>`
++ + `<updated>${rows.length ? isoOf(rows[0].created_at) : new Date().toISOString()}</updated><generator>OpenVibe.Rent</generator>
++${entries.join('\n')}
++</feed>
++`);
++ });
++
// ── Search ───────────────────────────────────────────────
r.get('/listings', async (req, res) => {
const filters = listings.normalizeFilters(req.query);
@@@ -360,6 +387,7 @@ ${hudNote(out ? out.fetched_at : null)}`,
const facetLink = (dim, value) => `/listings?${new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(qs)), [dim]: value }).toString()}`;
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ feed: { title: savedTitle(filters), href: feedPath(filters) },
title: 'Listings',
description: 'Search apartments, rooms, houses, commercial space, parking and storage, and equipment for rent — posted by people on OpenVibe.Rent.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings' }],
@@@ -373,6 +401,7 @@ ${facetList('Country', 'country', facets.country, filters.country, facetLink)}
${qs ? html`<div class="small"><a href="/listings">Clear all filters</a>${signedIn(req) ? html` · <form class="inline" method="post" action="/saved">
<input type="hidden" name="q" value="${filters.q || ''}"><input type="hidden" name="kind" value="${filters.kind || ''}"><input type="hidden" name="city" value="${filters.city || ''}"><input type="hidden" name="country" value="${filters.country || ''}"><input type="hidden" name="period" value="${filters.period || ''}"><input type="hidden" name="min" value="${filters.min == null ? '' : filters.min}"><input type="hidden" name="max" value="${filters.max == null ? '' : filters.max}">
<button class="sc-btn" type="submit">Save this search</button></form>` : ''}</div>` : ''}
++<p class="listing-alert"><a class="sc-btn" href="${alertHref(filters)}">Get alerts for this search</a> <span class="small muted">OpenVibe.Watch follows <a href="${feedPath(filters)}">its feed</a> and tells you when a new listing appears.</span></p>
${listingList(out.rows, { empty: html`No listing matches those filters. <a href="/listings">Clear them</a> and try again.` })}
${out.next ? html`<p><a href="${more({ before: out.next })}">Older listings</a>${before ? html` · <a href="/listings${qs ? `?${qs}` : ''}">Newest first</a>` : ''}</p>` : ''}
</section>`,
@@@ -700,10 +729,11 @@ ${items.length
<a href="/listings?${listings.filtersToQuery(it.filters)}"><b>${savedTitle(it.filters)}</b></a>
${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html` <span class="badge">nothing new</span>`}
<span class="small muted"> · saved ${time(it.row.created_at)} · last looked ${time(it.row.last_seen_at)}</span>
++<a class="sc-btn" href="${alertHref(it.filters)}">Get alerts</a>
<form class="inline" method="post" action="/saved/${it.row.id}/delete"><button class="sc-btn" type="submit">Forget it</button></form>
</li>`)}</ul>`
: html`<p class="muted">No saved searches yet. Search the listings, then save the filters to see what is new each time you come back.</p>`}
--<div class="notice">Alerts that come to you — a notification, an email — arrive later through OpenVibe.Watch. For now a saved search is a page you open.</div>`,
++<div class="notice">Get alerts opens OpenVibe.Watch with the search's feed filled in: Watch checks it and tells you (or runs your agent) when a listing that fits appears. Every search has its feed at <code>/listings.atom</code> with the same filters.</div>`,
});
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex ba1eefb..96531a5 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -94,6 +94,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'rent' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
++${o.feed ? `<link rel="alternate" type="application/atom+xml" title="${esc(o.feed.title)}" href="${esc(o.feed.href)}">` : ''}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
ddiff --git a/test/listings-pages.test.js b/test/listings-pages.test.js
iindex ab80c4a..01b937b 100644
---- a/test/listings-pages.test.js
++++ b/test/listings-pages.test.js
@@@ -55,6 +55,23 @@ const listings = require('../server/listings/listings');
assert.match(empty.text, /No listing matches those filters/);
const bad = await t.get('/listings?kind=nonsense&before=nope');
assert.strictEqual(bad.status, 200, 'a filter we do not know is not an error');
++
++ // The search is an Atom feed with the same filters; the page links it and offers alerts through Watch.
++ const feed = await t.get('/listings.atom?kind=room&city=Lisbon');
++ assert.strictEqual(feed.status, 200);
++ assert.match(feed.headers.get('content-type'), /^application\/atom\+xml/);
++ assert.strictEqual(feed.headers.get('cache-control'), 'public, max-age=300');
++ const entries = [...feed.text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => m[1]);
++ assert.ok(entries.length >= 1 && entries.every((e) => /<summary>Room — /.test(e)), 'rooms only');
++ assert.ok(entries.some((e) => e.includes('<title>A room in Lisbon')), 'the match is an entry');
++ assert.ok(!feed.text.includes('A flat on the home page'), 'a filtered listing is not');
++ assert.match(feed.text, /<link rel="self" href="[^"]*\/listings\.atom\?kind=room&amp;city=Lisbon"\/>/);
++ assert.ok(r.text.includes('<link rel="alternate" type="application/atom+xml" title="Room in Lisbon" href="/listings.atom?kind=room&amp;city=Lisbon">'), 'the feed link in the head');
++ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts for this search/.exec(r.text);
++ assert.ok(alert, 'the alerts link');
++ const u = new URL(alert[1].replace(/&amp;/g, '&'));
++ const fu = new URL(u.searchParams.get('url'));
++ assert.deepStrictEqual([u.searchParams.get('template'), fu.pathname + fu.search, u.searchParams.get('name')], ['feed', '/listings.atom?kind=room&city=Lisbon', 'Rent: Room in Lisbon']);
});
await check('/post: sign-in is asked for, and the form is the same rules as the API', async () => {
@@@ -164,6 +181,7 @@ const listings = require('../server/listings/listings');
assert.match(page.text, /1 new/);
assert.match(page.text, /Room in Lisbon/i, 'the filters are shown as a sentence');
assert.match(page.text, /OpenVibe\.Watch/, 'the alert story names OpenVibe.Watch');
++ assert.match(page.text, /href="https:\/\/openvibe\.watch\/watches\/new\?template=feed&amp;url=[^"]*listings\.atom[^"]*">Get alerts</, 'each saved search offers alerts');
const again = await t.get('/saved', { as: kim });
assert.match(again.text, /nothing new/, 'looking at the page is looking at the searches');
assert.ok(!again.text.includes('1 new'));