openvibers/openvibe.coupons Commits
Coupons: watching a shop is delivered (coupons.watch.matched to each watcher when a code is listed) (#59)
Changes
@@@ -23,6 +23,7 @@],"eventsProduced": ["coupons.coupon.created",++ "coupons.watch.matched","coupons.coupon.updated","coupons.coupon.expired","coupons.coupon.disabled",@@@ -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",@@@ -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",@@@ -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;});@@@ -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');@@@ -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>`}`;}@@@ -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);nnew file mode 100644@@@ -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();++})();
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
],
],
"eventsProduced": [
"eventsProduced": [
"coupons.coupon.created",
"coupons.coupon.created",
+ "coupons.watch.matched",
"coupons.coupon.updated",
"coupons.coupon.updated",
"coupons.coupon.expired",
"coupons.coupon.expired",
"coupons.coupon.disabled",
"coupons.coupon.disabled",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
@@ -873,9 +873,9 @@
@@ -873,9 +873,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
@@ -191,6 +191,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -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`),
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`),
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'"),
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 = ?'),
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).
// Each reporter's latest report counts once (DISTINCT ON keeps the newest row per reporter).
reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (
reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (
@@ -247,6 +248,37 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -247,6 +248,37 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));
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 = {}) {
function lifecyclePayload(c, extra = {}) {
return { merchant_id: c.merchant_id, status: c.status, confidence: c.confidence, review_state: c.review_state,
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 };
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 }) {
@@ -344,6 +376,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
await q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);
await q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);
const c = await get(id);
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 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 });
await syncIndex(c, { traceparent });
return { coupon: c, merchant, duplicate: false, created: true };
return { coupon: c, merchant, duplicate: false, created: true };
});
});
@@ -380,6 +413,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -380,6 +413,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
await q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);
await q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);
const c = await get(id);
const c = await get(id);
await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: await publiclyListed(c) });
await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: await publiclyListed(c) });
+ await notifyWatchers(c);
await syncIndex(c);
await syncIndex(c);
return { coupon: c, created: true };
return { coupon: c, created: true };
}
}
@@ -429,6 +463,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -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());
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 });
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 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);
await syncIndex(after);
return after;
return after;
});
});
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
* Members watching a merchant (coupon_watches). A watch is private to its member: it is never
* Members watching a merchant (coupon_watches). A watch is private to its member: it is never
* shown to anyone else and never leaves Coupons.
* 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 { ids } = require('openvibe-contracts');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
@@ -154,7 +154,7 @@ ${hints.length ? h`<ul class="hints">${hints.map((x) => h`<li>How to apply codes
@@ -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>
<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>`}
${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>
<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>` : ''}
${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>`;
<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<
@@ -260,7 +260,7 @@ ${installs.length ? h`<table class="installs"><thead><tr><th>Name</th><th>Token<
function watching({ merchants }) {
function watching({ merchants }) {
return h`<h1>Shops you watch</h1>
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>`}`;
${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>`}`;
}
}
@@ -238,6 +238,13 @@ const DAY = 24 * HOUR;
@@ -238,6 +238,13 @@ const DAY = 24 * HOUR;
for (const e of all) {
for (const e of all) {
const v = contracts.validate('events.event-envelope@1', e);
const v = contracts.validate('events.event-envelope@1', e);
assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
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') {
if (e.event_type === 'coupons.moderation.action') {
// The audit log names the staff member who acted, and nobody else (never a submitter).
// 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);
assert.ok(contracts.validate('coupons.moderation.action@1', e.payload).valid);
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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();
+})();