openvibers/openvibe.work Commits

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

OP OpenVibers committed 29 days ago · 1b298a3e2f · 6 files, +79 −5

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

**Why:** the README's "Not yet" said: "Alerts — being told when a listing that fits appears — come later, through OpenVibe.Watch." Watch already follows RSS and Atom feeds and takes prefilled links from other sites (Host's "Watch this price" uses one). What Work lacked was a feed per search.

**Change:**
- **`GET /jobs.atom`** takes the same filters as `/jobs` (`q`, `remote`, `type`, `location`).
  - It holds the newest 25 matching listings. Each entry has the title and company, a link to the listing's page here, its posted (else read) time, the board as author, and the location and excerpt as summary.
  - It is public and cached 5 minutes. Text is XML-escaped and control characters are stripped. robots.txt already allows it.
- **Search page:**
  - `<link rel="alternate" type="application/atom+xml">` for the search's feed;
  - a **Get alerts for this search** button opening `https://openvibe.watch/watches/new?template=feed&url=<the feed>&name=Jobs: <the search>`. The person reviews it there and creates the watch, and Watch tells them or runs their agent when a new listing appears.
- **Saved searches:** each one has **Get alerts** for its own feed. The "alerts come later" note now says what the button does.
- **Layout:** `page({ feed })` writes the alternate link. A little CSS lays out the alert row.

**Tests:**
- `jobs.test.js`: the feed's content type, caching and Atom header; one entry for `devops` + remote with its title, link and board; the self link; every listing newest first; the page's head link; the Watch link's template, feed URL and name.
- `saved.test.js`: a saved search's Get alerts link opens the feed form for `/jobs.atom`.
- The perf budget and layout tests pass, as do the 4 test files the change reaches.

**Docs:** README's API table gains `/jobs.atom`, and Alerts leaves "Not yet".

Changes

ddiff --git a/README.md b/README.md
iindex cf55521..866c8ed 100644
---- a/README.md
++++ b/README.md
@@@ -114,6 +114,7 @@ export/deletion routes, pushed with this service's own client-credentials token
| `GET /api/v1/ping` | anyone | `{ ok: true, service: "work" }` |
| `GET /api/v1/jobs` | anyone | Search: `q`, `remote`, `type`, `location`, `limit` (to 100), `cursor` (the `next` of the previous page). Newest first, 30 a page, with facets |
| `GET /api/v1/jobs/:id` | anyone | One listing: the board's fields, the excerpt, the source, and `url` — the original to apply on |
++| `GET /jobs.atom` | anyone | The same search as an Atom feed (`q`, `remote`, `type`, `location`): the newest 25 listings, each linking its page here, cached 5 minutes. The search page links it in its head, and **Get alerts** (on the search page and on each saved search) opens OpenVibe.Watch's feed form with it filled in, so Watch tells you, or runs your agent, when a listing that fits appears |
| `GET /api/v1/sources` | anyone | The boards, each one's terms as applied, its listing count and its last fetch |
| `GET /api/v1/saved-searches` | a person | Your saved searches, each with `new_count` — new since you last looked |
| `POST /api/v1/saved-searches` | a person | `{ q?, remote?, type?, location? }` (at least one). 201 with the saved search |
@@@ -262,8 +263,6 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
- **Approval at each step**: the agent help is one-shot today — Work posts a task and hands back its link. An approval
loop (the agent proposing an action and the person approving it) comes with the rest of the agent pillar.
--- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch. Today a saved search is
-- a search you do not have to type again, plus a count of what is new.
- More boards, and a per-source schedule finer than "the same six hours for everyone".
---
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 187e9d0..2918934 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -76,6 +76,8 @@ button.sc-btn.sc-primary { border: 0; }
@media (max-width: 560px) { .job-item { grid-template-columns: 1fr; } .job-logo { display: none; } }
@media (prefers-reduced-motion: reduce) { .job-item { transition: none; } }
.job-pager { display: flex; gap: 12px; align-items: center; margin: 18px 0 8px; }
++.job-alert, .saved-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }
++.saved-actions { margin: 6px 0 0; }
.job-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
.job-facts dd { margin: 0; overflow-wrap: anywhere; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0f12604..0d61f9a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -155,6 +155,45 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc
});
});
++ // ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──
++ const WATCH_NEW = 'https://openvibe.watch/watches/new';
++ const describe = (filters) => [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');
++ const feedPath = (filters) => withFilters(filters, { cursor: null }, '/jobs.atom');
++ /** Watch's new-watch form, filled in to follow one search's feed; the person reviews and creates it on Watch. */
++ function alertHref(filters) {
++ const described = describe(filters);
++ const q = new URLSearchParams({ template: 'feed', url: `${config.baseUrl}${feedPath(filters)}`, name: `Jobs: ${described || 'every new listing'}`.slice(0, 80) });
++ return `${WATCH_NEW}?${q}`;
++ }
++ 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() : null; };
++
++ r.get('/jobs.atom', async (req, res) => {
++ const filters = store.filtersOf(req.query);
++ const p = await store.search(s, filters, { limit: 25 });
++ const described = describe(filters);
++ const self = `${config.baseUrl}${feedPath(filters)}`;
++ const entries = p.rows.map((row) => {
++ const l = listing.meta(row);
++ const at = isoOf(l.posted_at) || isoOf(row.fetched_at) || new Date().toISOString();
++ const where = [l.location, l.remote ? 'Remote' : '', l.job_type].filter(Boolean).join(' · ');
++ return `<entry><id>${xml(`${config.baseUrl}/jobs/${row.id}`)}</id><title>${xml(`${l.title} at ${l.company}`)}</title>`
++ + `<link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}/jobs/${row.id}`)}"/><updated>${at}</updated>`
++ + `<author><name>${xml(sourceOf(row).name)}</name></author>`
++ + `<summary>${xml([where, listing.plainExcerpt(row).slice(0, 300)].filter(Boolean).join(' — '))}</summary></entry>`;
++ });
++ const updated = p.rows.length ? (isoOf(listing.meta(p.rows[0]).posted_at) || isoOf(p.rows[0].fetched_at)) : null;
++ 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.Work: ${described ? `jobs ${described}` : 'new jobs'}`)}</title>`
++ + `<link rel="self" href="${xml(self)}"/><link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}${withFilters(filters, { cursor: null }, '/jobs')}`)}"/>`
++ + `<updated>${updated || new Date().toISOString()}</updated><generator>OpenVibe.Work</generator>
++${entries.join('\n')}
++</feed>
++`);
++ });
++
// ── Search ───────────────────────────────────────────────
r.get('/jobs', async (req, res) => {
const filters = store.filtersOf(req.query);
@@@ -162,15 +201,17 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc
// A cursor we did not write is not a position in the result set; start from the top rather than guess.
const p = await store.search(s, filters, { cursor });
const nextUrl = p.next ? withFilters(filters, { cursor: store.encodeCursor(p.next) }, '/jobs') : null;
-- const described = [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');
++ const described = describe(filters);
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ feed: { title: described ? `Jobs ${described}` : 'New jobs', href: feedPath(filters) },
title: described ? `Jobs: ${described}` : 'Jobs',
description: `Job listings from open boards${described ? ` matching ${described}` : ''}, newest first, each with the source it came from and a link to the original listing.`,
crumbs: [{ label: 'Jobs' }],
styles: [showcase.STYLESHEET],
body: html`<h1>Jobs</h1>
${filterForm(filters, { facets: p.facets })}
++<p class="job-alert"><a class="sc-btn" href="${alertHref(filters)}">Get alerts for this search</a> <span class="muted small">OpenVibe.Watch follows <a href="${feedPath(filters)}">its feed</a> and tells you when a new listing appears.</span></p>
${listing.listingList(p.rows, { sourceOf, empty: 'No listings match this search. Try fewer words, or clear a filter and search again.' })}
<nav class="job-pager" aria-label="Pages">
${cursor ? html`<a class="sc-btn" href="${withFilters(filters, { cursor: null }, '/jobs')}">First page</a>` : ''}
@@@ -270,10 +311,10 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">
<p class="saved-query"><a href="${store.toSavedWire(row).search_path}">${describeSaved(row)}</a> <span class="badge${count ? ' ok' : ''}">${count ? `${count} new` : 'nothing new'}</span></p>
<p class="muted small">Saved ${time(row.created_at)} · last looked ${row.last_seen_at ? time(row.last_seen_at) : 'never'}</p>
--<form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form>
++<div class="saved-actions"><a class="sc-btn" href="${alertHref(store.filtersOf({ q: row.query, remote: row.remote, type: row.type, location: row.location }))}">Get alerts</a> <form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form></div>
</li>`)}</ul>` : ''}
${savedForm(req, initial)}
--<p class="muted small">Alerts — being told when a listing that fits appears — come later, through OpenVibe.Watch. For now a saved search is a search you do not have to type again, and a count of what is new.</p>
++<p class="muted small">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>/jobs.atom</code> with the same filters.</p>
<p class="muted small">In code: <code>GET /api/v1/saved-searches</code>.</p>`,
});
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b6af5b4..356b1d7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -93,6 +93,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'work' })}
<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/jobs.test.js b/test/jobs.test.js
iindex 2b3c74e..0d1594f 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -91,6 +91,31 @@ const first = async (t, path) => (await t.get(path)).json();
assert.match(html, /href="\/jobs\?[^"]*remote=true"/, 'a facet link is missing');
});
++ await check('a search is an Atom feed with the same filters, and its page offers alerts through OpenVibe.Watch', async () => {
++ const r = await t.get('/jobs.atom?q=devops&remote=true');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
++ assert.match(r.text, /^<\?xml version="1\.0" encoding="utf-8"\?>\n<feed xmlns="http:\/\/www\.w3\.org\/2005\/Atom">/);
++ const entries = [...r.text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => m[1]);
++ assert.strictEqual(entries.length, 1, 'only the matching listing');
++ assert.match(entries[0], /<title>DevOps Engineer at Skyward Cloud<\/title>/);
++ assert.match(entries[0], /<link rel="alternate" type="text\/html" href="[^"]+\/jobs\/[^"]+"\/>/);
++ assert.match(entries[0], /<author><name>RemoteOK<\/name><\/author>/);
++ assert.match(r.text, /<link rel="self" href="[^"]*\/jobs\.atom\?q=devops&amp;remote=true"\/>/);
++ const all = await t.get('/jobs.atom');
++ const ids = [...all.text.matchAll(/<updated>([^<]+)<\/updated><author>/g)].map((m) => m[1]);
++ assert.ok(ids.length >= 3 && ids.join() === [...ids].sort().reverse().join(), 'every listing, newest first');
++ // The page links its feed, and Get alerts opens Watch's feed form with it filled in.
++ const page = (await t.get('/jobs?q=devops&remote=true')).text;
++ assert.ok(page.includes('<link rel="alternate" type="application/atom+xml" title="Jobs “devops” remote" href="/jobs.atom?q=devops&amp;remote=true">'), 'the feed link in the head');
++ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts for this search/.exec(page);
++ assert.ok(alert, 'the alerts link');
++ const u = new URL(alert[1].replace(/&amp;/g, '&'));
++ assert.deepStrictEqual([u.searchParams.get('template'), new URL(u.searchParams.get('url')).pathname + new URL(u.searchParams.get('url')).search, u.searchParams.get('name')],
++ ['feed', '/jobs.atom?q=devops&remote=true', 'Jobs: “devops” remote']);
++ });
++
await check('one listing: the board\'s fields, the excerpt, the source and "read the full listing on" link', async () => {
const job = (await first(t, '/api/v1/jobs?q=Platform Engineer')).jobs[0];
const r = await t.get(`/jobs/${job.id}`);
ddiff --git a/test/saved.test.js b/test/saved.test.js
iindex 5c86f9d..912eb4d 100644
---- a/test/saved.test.js
++++ b/test/saved.test.js
@@@ -84,6 +84,12 @@ const { boot, check, done } = require('./helpers/boot');
const page = await t.get('/saved', { as: kim });
assert.strictEqual(page.status, 200);
assert.match(page.text, /1 new/);
++ // Each saved search offers alerts: Watch's feed form with that search's feed filled in.
++ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts</.exec(page.text);
++ assert.ok(alert, 'a Get alerts link');
++ const u = new URL(alert[1].replace(/&amp;/g, '&'));
++ assert.strictEqual(u.searchParams.get('template'), 'feed');
++ assert.match(new URL(u.searchParams.get('url')).pathname, /^\/jobs\.atom$/);
assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches.find((x) => x.id === saved.id).new_count, 0, 'opening /saved did not clear the count');
assert.strictEqual((await t.get('/saved', { as: kim })).text.includes('nothing new'), true);
});