openvibers/openvibe.coupons Commits

Coupons: watching a shop is delivered (coupons.watch.matched to each watcher when a code is listed) (#59)

OP OpenVibers committed 29 days ago · 2d080936bd · 8 files, +120 −10

Watching a shop on OpenVibe.Coupons is delivered. Until now the watch was recorded and listed on `/watching`, and the shop page and `/watching` said "notifications are not sent yet".

- **When:** a code for a watched merchant becomes publicly listed, either created published (a member or a source) or approved by staff from review.
- **What Coupons does:** `notifyWatchers` emits one `coupons.watch.matched` v1 event per watcher (Contracts 0.188.0, #148), inside the listing's transaction.
  - The submitter is never told about their own code.
  - Nobody is told twice: each caller runs once per listing, and approval only fires on the pending → published change.
  - The event is internal and names exactly its recipient. It carries the merchant, the coupon URL and title, and the expiry as ISO text (the date alone when known to the day). It carries no code text.
- **Network** turns the event into an inbox notification (its half follows in a Network PR, with the subscription).
- **The pages** now say new codes reach your OpenVibe notifications, unless service notifications are muted there.
- **The watch store comment** describes the delivery.
- Contracts pin: v0.188.0, and `docs/service-manifest-proposal.json` declares the event.

**Tests:**
- `test/watch-delivery.test.js` covers:
  - a member's code reaching the other watcher only, as a valid internal envelope and payload without the code;
  - an AI draft telling nobody until approval, then the two remaining watchers but not one who stopped watching;
  - approving twice telling nobody twice;
  - the new page text.
- `lifecycle.test.js` validates the event and checks it names only its recipient.
- The full suite passes (23/23).

Changes

ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 2072720..a61b3ae 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -23,6 +23,7 @@
],
"eventsProduced": [
"coupons.coupon.created",
++ "coupons.watch.matched",
"coupons.coupon.updated",
"coupons.coupon.expired",
"coupons.coupon.disabled",
ddiff --git a/package-lock.json b/package-lock.json
iindex 19dc25a..7453721 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.186.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
@@@ -873,9 +873,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.186.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.186.0",
-- "integrity": "sha512-iUWuAYSIKvud+l8Q9dfjhGNt3HcWwsKCCBBiyOgks3kutEO1Jdbdg7ruYTl3KHN7NryMDWGbun2RK+6u4fQl6A==",
++ "version": "0.188.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.0",
++ "integrity": "sha512-9hMg8nO+XXgHzSsVw6i35ecMqMQulzfvK8Bgm3xTe6qUOA3aBJcozQ5BiNTut2tbXbq5G7Wp07ewU/1NssTeHA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 41fbb5f..a65c60b 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.186.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex 7e07e6c..1641dea 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -191,6 +191,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
WHERE c.status NOT IN ('expired','disabled') AND (c.confidence IS NOT NULL OR c.status <> 'unknown' OR r.updated_at > ?) LIMIT 2000`),
pendingReview: db.prepare(`SELECT c.* FROM coupons c WHERE c.review_state = 'pending' AND c.status <> 'disabled' ORDER BY c.created_at LIMIT 200`),
setReview: db.prepare("UPDATE coupons SET review_state = 'published', updated_at = ? WHERE id = ? AND review_state = 'pending'"),
++ watchers: db.prepare('SELECT subject FROM coupon_watches WHERE merchant_id = ? ORDER BY created_at LIMIT 1000'),
setExpiry: db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ?, expiry_basis = ?, updated_at = ? WHERE id = ?'),
// Each reporter's latest report counts once (DISTINCT ON keeps the newest row per reporter).
reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (
@@@ -247,6 +248,37 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));
}
++ /**
++ * The people watching the code's merchant are told it is listed (coupons.watch.matched v1, one internal event per
++ * watcher, inside the caller's transaction): only when it is publicly listed now, never the person who submitted
++ * it, and never twice, since every caller runs once per listing (creation, or the one pending → published
++ * approval). OpenVibe.Network turns each into an inbox notification linking the coupon page. No code text leaves.
++ */
++ /** The stated expiry as ISO text (the date alone when it is known only to the day), or null when unknown. */
++ function expiryText(c) {
++ if (c.expires_at == null) return null;
++ const ms = typeof c.expires_at === 'number' || /^\d+$/.test(String(c.expires_at)) ? Number(c.expires_at) : Date.parse(c.expires_at);
++ if (!Number.isFinite(ms)) return null;
++ const iso = new Date(ms).toISOString();
++ return c.expires_precision === 'day' ? iso.slice(0, 10) : iso;
++ }
++
++ async function notifyWatchers(c, { exclude = null, traceparent } = {}) {
++ if (!await publiclyListed(c)) return 0;
++ const m = await merchants.byId(c.merchant_id);
++ let n = 0;
++ for (const { subject } of await q.watchers.all(c.merchant_id)) {
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) || subject === exclude) continue;
++ await publication.emit('coupons.watch.matched', { type: 'coupon', id: c.id }, {
++ recipient: subject, merchant_id: m.id, merchant_name: String(m.name).slice(0, 200), merchant_url: publication.merchantUrl(m),
++ coupon_id: c.id, coupon_url: publication.couponUrl(c), title: String(c.title || 'A new code').slice(0, 500),
++ expires_at: expiryText(c),
++ }, { traceparent });
++ n++;
++ }
++ return n;
++ }
++
function lifecyclePayload(c, extra = {}) {
return { merchant_id: c.merchant_id, status: c.status, confidence: c.confidence, review_state: c.review_state,
expires_at: c.expires_at != null ? new Date(c.expires_at).toISOString() : null, canonical_url: publication.couponUrl(c), ...extra };
@@@ -344,6 +376,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
await q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);
const c = await get(id);
await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: await publiclyListed(c), traceparent });
++ await notifyWatchers(c, { exclude: who.actor, traceparent });
await syncIndex(c, { traceparent });
return { coupon: c, merchant, duplicate: false, created: true };
});
@@@ -380,6 +413,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
await q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);
const c = await get(id);
await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: await publiclyListed(c) });
++ await notifyWatchers(c);
await syncIndex(c);
return { coupon: c, created: true };
}
@@@ -429,6 +463,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
await q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());
if (audit && actor !== c.created_by) await moderated('coupon.approved', c, actor, { traceparent });
await publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: await publiclyListed(after) });
++ await notifyWatchers(after, { exclude: c.created_by, traceparent });
await syncIndex(after);
return after;
});
ddiff --git a/server/domain/watches.js b/server/domain/watches.js
iindex 4056d97..080e1b9 100644
---- a/server/domain/watches.js
++++ b/server/domain/watches.js
@@@ -4,9 +4,9 @@
* Members watching a merchant (coupon_watches). A watch is private to its member: it is never
* shown to anyone else and never leaves Coupons.
*
-- * Delivery is not built yet: Coupons records watches and lists them on /watching, but sends no
-- * notification. It will go through the Network's notification store (roadmap W3) when Coupons is
-- * granted a notification capability; until then the page says so.
++ * Delivery: when a code for a watched merchant is publicly listed, ./coupons.js notifyWatchers emits one
++ * coupons.watch.matched event per watcher (never the submitter), and OpenVibe.Network turns it into an
++ * inbox notification. The watch list itself never leaves Coupons.
*/
const { ids } = require('openvibe-contracts');
const { ApiError } = require('../http/errors');
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex f2d93d1..3c580c2 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -154,7 +154,7 @@ ${hints.length ? h`<ul class="hints">${hints.map((x) => h`<li>How to apply codes
<h2>Active codes (${active.length})</h2>
${active.length ? active.map((v) => couponCard(v, { viewer, csrf, back, link: true })) : h`<p class="empty">No active codes for ${m.name} right now.</p>`}
<p><a href="/submit?merchant=${m.slug}">Submit a code for ${m.name}</a></p>
--${viewer && viewer.kind === 'user' ? h`<form method="post" action="/m/${m.slug}/watch">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}"><button type="submit">${watching ? 'Stop watching' : 'Watch this shop'}</button> <span class="meta">(your watch list is private; notifications are not sent yet)</span></form>` : ''}
++${viewer && viewer.kind === 'user' ? h`<form method="post" action="/m/${m.slug}/watch">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}"><button type="submit">${watching ? 'Stop watching' : 'Watch this shop'}</button> <span class="meta">(private to you; new codes reach your OpenVibe notifications)</span></form>` : ''}
${ended.length ? h`<h2>Recently ended (not in active results)</h2><ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>` : ''}
<p class="meta">Follow: <a href="/m/${m.slug}/feed.xml">RSS</a> · <a href="/m/${m.slug}.json">JSON</a></p>`;
}
@@@ -260,7 +260,7 @@ ${installs.length ? h`<table class="installs"><thead><tr><th>Name</th><th>Token<
function watching({ merchants }) {
return h`<h1>Shops you watch</h1>
--<p class="meta">Private to you. Notifications are not sent yet; this list is where watched shops are collected until they are.</p>
++<p class="meta">Private to you. When a new code for one of these shops is listed, you get a notification in your OpenVibe inbox (unless you muted service notifications there).</p>
${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">You are not watching any shop. Use “Watch this shop” on a shop's page.</p>`}`;
}
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex f77e1fd..d22bc14 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -238,6 +238,13 @@ const DAY = 24 * HOUR;
for (const e of all) {
const v = contracts.validate('events.event-envelope@1', e);
assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
++ if (e.event_type === 'coupons.watch.matched') {
++ // Internal, and names exactly its recipient: the watcher Network notifies (test/watch-delivery.test.js).
++ assert.strictEqual(e.visibility, 'internal');
++ assert.ok(contracts.validate('coupons.watch.matched@1', e.payload).valid);
++ assert.doesNotMatch(JSON.stringify(e).split(e.payload.recipient).join(''), /usr_[0-9A-Z]{26}/, `${e.event_type} names someone other than its recipient`);
++ continue;
++ }
if (e.event_type === 'coupons.moderation.action') {
// The audit log names the staff member who acted, and nobody else (never a submitter).
assert.ok(contracts.validate('coupons.moderation.action@1', e.payload).valid);
ddiff --git a/test/watch-delivery.test.js b/test/watch-delivery.test.js
nnew file mode 100644
iindex 0000000..aeb14c9
---- /dev/null
++++ b/test/watch-delivery.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * Watching a shop is delivered (coupons.watch.matched v1, server/domain/coupons.js notifyWatchers): when a code for a
++ * watched merchant is publicly listed, every watcher but the submitter gets one internal event naming them as the
++ * recipient, which OpenVibe.Network turns into an inbox notification. Created published → the watchers but the
++ * submitter; an AI draft → nobody until staff approve it, then everyone watching; a watcher who stopped watching →
++ * nothing; the payload is valid, internal, and carries no code text. The /watching page says what happens.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const shop = await t.merchant({ name: 'Trail Supply', host: 'trail-supply.com' });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const carol = t.network.addUser('carol');
++ const subjectOf = (u) => t.network.subjectOf ? t.network.subjectOf(u) : u.subject;
++ const watch = async (u, action = 'watch') => assert.strictEqual((await t.get(`/m/${shop.slug}/watch`, { as: u, form: { csrf: t.csrf(u), action } })).status, 303);
++ const matched = async () => await t.events('coupons.watch.matched');
++
++ await check('a member\'s code reaches every watcher but its submitter, once, as a valid internal event without the code', async () => {
++ await watch(alice); await watch(bob);
++ const r = await t.submit(bob, { host: 'trail-supply.com', code: 'TRAIL20', title: '20% off tents' });
++ assert.ok([200, 201].includes(r.status), r.text);
++ const evs = await matched();
++ assert.strictEqual(evs.length, 1, JSON.stringify(evs.map((e) => e.payload.recipient)));
++ const e = evs[0];
++ assert.strictEqual(e.payload.recipient, subjectOf(alice));
++ assert.strictEqual(e.visibility, 'internal');
++ assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
++ assert.deepStrictEqual(e.subject, { type: 'coupon', id: r.json().coupon.id });
++ assert.ok(contracts.validate('events.event-envelope@1', e).valid);
++ const v = contracts.validate('coupons.watch.matched@1', e.payload);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.strictEqual(e.payload.merchant_name, 'Trail Supply');
++ assert.match(e.payload.coupon_url, /^https:\/\/openvibe\.coupons\/c\//);
++ assert.ok(!JSON.stringify(e.payload).includes('TRAIL20'), 'no code text');
++ });
++
++ await check('an AI draft tells nobody until staff approve it; then everyone watching, and nobody who stopped', async () => {
++ await watch(carol); await watch(bob, 'unwatch');
++ const before = (await matched()).length;
++ const ai = t.network.serviceToken('ai', ['coupons.coupon.submit']);
++ const r = await t.get('/api/v1/coupons/submit', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { host: 'trail-supply.com', code: 'AIDRAFT', title: 'Free shipping', evidence_url: 'https://trail-supply.com/promo' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual((await matched()).length, before, 'a pending draft is not listed');
++ const id = r.json().coupon.id;
++ const ok = await t.get(`/staff/coupons/${id}/approve`, { as: t.staff, form: { csrf: t.csrf(t.staff) } });
++ assert.ok([200, 303].includes(ok.status), ok.text);
++ const fresh = (await matched()).slice(before);
++ assert.deepStrictEqual(fresh.map((e) => e.payload.recipient).sort(), [subjectOf(alice), subjectOf(carol)].sort(), 'alice and carol; bob stopped watching');
++ assert.ok(fresh.every((e) => e.subject.id === id));
++ const again = await t.get(`/staff/coupons/${id}/approve`, { as: t.staff, form: { csrf: t.csrf(t.staff) } });
++ assert.ok(again.status < 500);
++ assert.strictEqual((await matched()).length, before + 2, 'approving twice tells nobody twice');
++ });
++
++ await check('the watch pages say new codes reach your notifications', async () => {
++ assert.match((await t.get('/watching', { as: alice })).text, /you get a notification in your OpenVibe inbox/);
++ assert.match((await t.get(`/m/${shop.slug}`, { as: alice })).text, /new codes reach your OpenVibe notifications/);
++ });
++
++ await t.close();
++ done();
++})();