openvibers/openvibe.rent Commits
Rent: alerts through OpenVibe.Watch (every search is an Atom feed; Get alerts opens Watch with it filled in) (#40)
Changes
@@@ -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. |@@@ -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; }@@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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>`,});});@@@ -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)}@@@ -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&city=Lisbon"\/>/);++ assert.ok(r.text.includes('<link rel="alternate" type="application/atom+xml" title="Room in Lisbon" href="/listings.atom?kind=room&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(/&/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&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'));
@@ -112,7 +112,8 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
@@ -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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
@@ -40,6 +40,7 @@ form.inline { display: inline; }
@@ -40,6 +40,7 @@ form.inline { display: inline; }
/* Listings: one card per listing — what kind, the title, where, the price on the right. */
/* Listings: one card per listing — what kind, the title, where, the price on the right. */
.txt { white-space: pre-line; }
.txt { white-space: pre-line; }
.listing-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; }
.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 { --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: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; }
.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; }
@@ -349,6 +349,33 @@ ${hudNote(out ? out.fetched_at : null)}`,
@@ -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);
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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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 ───────────────────────────────────────────────
// ── Search ───────────────────────────────────────────────
r.get('/listings', async (req, res) => {
r.get('/listings', async (req, res) => {
const filters = listings.normalizeFilters(req.query);
const filters = listings.normalizeFilters(req.query);
@@ -360,6 +387,7 @@ ${hudNote(out ? out.fetched_at : null)}`,
@@ -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()}`;
const facetLink = (dim, value) => `/listings?${new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(qs)), [dim]: value }).toString()}`;
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
+ feed: { title: savedTitle(filters), href: feedPath(filters) },
title: 'Listings',
title: 'Listings',
description: 'Search apartments, rooms, houses, commercial space, parking and storage, and equipment for rent — posted by people on OpenVibe.Rent.',
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' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings' }],
@@ -373,6 +401,7 @@ ${facetList('Country', 'country', facets.country, filters.country, facetLink)}
@@ -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">
${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}">
<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>` : ''}
<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.` })}
${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>` : ''}
${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>`,
</section>`,
@@ -700,10 +729,11 @@ ${items.length
@@ -700,10 +729,11 @@ ${items.length
<a href="/listings?${listings.filtersToQuery(it.filters)}"><b>${savedTitle(it.filters)}</b></a>
<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>`}
${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>
<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>
<form class="inline" method="post" action="/saved/${it.row.id}/delete"><button class="sc-btn" type="submit">Forget it</button></form>
</li>`)}</ul>`
</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>`}
: 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>`,
});
});
});
});
@@ -94,6 +94,7 @@ function renderPage(o) {
@@ -94,6 +94,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'rent' })}
${appIcon.headTags({ site: 'rent' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<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.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')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
${render(scripts)}
@@ -55,6 +55,23 @@ const listings = require('../server/listings/listings');
@@ -55,6 +55,23 @@ const listings = require('../server/listings/listings');
assert.match(empty.text, /No listing matches those filters/);
assert.match(empty.text, /No listing matches those filters/);
const bad = await t.get('/listings?kind=nonsense&before=nope');
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');
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&city=Lisbon"\/>/);
+ assert.ok(r.text.includes('<link rel="alternate" type="application/atom+xml" title="Room in Lisbon" href="/listings.atom?kind=room&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(/&/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 () => {
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');
@@ -164,6 +181,7 @@ const listings = require('../server/listings/listings');
assert.match(page.text, /1 new/);
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, /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, /OpenVibe\.Watch/, 'the alert story names OpenVibe.Watch');
+ assert.match(page.text, /href="https:\/\/openvibe\.watch\/watches\/new\?template=feed&url=[^"]*listings\.atom[^"]*">Get alerts</, 'each saved search offers alerts');
const again = await t.get('/saved', { as: kim });
const again = await t.get('/saved', { as: kim });
assert.match(again.text, /nothing new/, 'looking at the page is looking at the searches');
assert.match(again.text, /nothing new/, 'looking at the page is looking at the searches');
assert.ok(!again.text.includes('1 new'));
assert.ok(!again.text.includes('1 new'));