openvibers/openvibe.network Commits

Network: Coupons watch matches become notifications (coupons.watch.matched) (#209)

OP OpenVibers committed 29 days ago · c130d36ded · 4 files, +58 −10

Network delivers Coupons' merchant watches: `coupons.watch.matched` (Contracts 0.188.0) becomes an inbox notification for the watcher, as `deals.watch.matched` does. This is the Network half of Coupons#59.

- **Handler** (`server/notifications/events-consumer.js`):
  - only from source `coupons`;
  - the notification is titled "New code for <shop>"; its message is the code's title plus "· until <date>" when the expiry is known;
  - it is type `COUPON_WATCH_MATCH`, category `service` (so a person who muted service notifications gets nothing), and links the coupon page while openvibe.coupons is open;
  - markup is stripped.
- **Exactly once:** the same inbox claim as every other topic.
- **Topics:** `TOPICS` (and `scripts/subscribe-events.js`, which subscribes every topic) gain `coupons.watch.matched`. The header comment no longer says Coupons has nothing to consume.
- **Contracts pin:** v0.188.0, where Network's manifest consumes the event.

**Tests:** `test/events-consumer.test.js` covers the topic list, one notification with the rendered title and message, a redelivery being a duplicate, and a spoofed source ignored. The registry-topics and notification-events tests pass, as do the 8 files `ov test` maps.

After deploy: `node scripts/subscribe-events.js --topic coupons.watch.matched` on the host creates the subscription.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 7dad243f..ef2362dd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "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-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.2.1",
"pg": "^8.23.0",
@@@ -1320,9 +1320,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 ead9732d..1e48c715 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "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-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.2.1",
"pg": "^8.23.0",
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex 9ceae2c1..46584626 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -5,6 +5,8 @@
*
* deals.watch.matched a Deals watch (keyword, product, price below) matched a fresh observation;
* payload.recipient is the watcher's usr_ subject
++ * coupons.watch.matched a code for a shop someone watches on Coupons was publicly listed (created
++ * published, or approved); payload.recipient is the watcher, never the submitter
* trade.alert.triggered a Trade alert rule fired (threshold crossed, filing/document seen);
* event.subject { type: 'user', id } is the rule's owner
* live.stream.started a Live channel went live (OpenVibe.Live server/events/stream-events.js);
@@@ -24,9 +26,8 @@
* (common.usage-recorded@1): kept per project and day for its dashboard
* (server/developer/usage.js), never anyone's notification.
*
-- * Coupons publishes no watch event: its merchant watches never leave Coupons (server/domain/watches.js
-- * there: "Delivery is not built yet"), and its coupons.* events name no person, so there is nothing
-- * of Coupons' to consume yet. README "Notifications from Events" has the operator steps.
++ * Coupons' merchant watches arrive as coupons.watch.matched (Coupons server/domain/coupons.js
++ * notifyWatchers, Contracts 0.188.0). README "Notifications from Events" has the operator steps.
*
* Each event becomes one inbox notification for its person through NotificationService.create(),
* which applies their preferences: a muted category ('service') creates nothing, and email follows
@@@ -54,7 +55,7 @@ const CONSUMER = 'network-notifications';
const INBOX_TABLE = 'network_event_inbox';
const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;
const USAGE_TOPICS = require('../developer/usage').TOPICS;
--const TOPICS = Object.freeze(['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', ...AUDIT_TOPICS, ...USAGE_TOPICS]);
++const TOPICS = Object.freeze(['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', ...AUDIT_TOPICS, ...USAGE_TOPICS]);
const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -100,6 +101,23 @@ const HANDLERS = {
},
};
},
++ 'coupons.watch.matched'(event) {
++ if (event.source !== 'coupons') return 'ignored:source';
++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
++ const merchant = clean(p.merchant_name, 120);
++ const title = clean(p.title, 200);
++ if (!merchant || !title || !p.coupon_id) return 'ignored:payload';
++ const until = p.expires_at ? ` · until ${clean(p.expires_at, 10)}` : '';
++ return {
++ subject: p.recipient,
++ notification: {
++ type: 'COUPON_WATCH_MATCH', category: 'service', priority: 'normal', icon: '🎟️', title: clean(`New code for ${merchant}`, 60),
++ message: clean(`${title}${until}`, 300),
++ service: 'coupons', url: publicLink(p.coupon_url),
++ rich_content: { service: 'coupons', context: { event_id: event.event_id, merchant_id: clean(p.merchant_id, 64), coupon_id: clean(p.coupon_id, 64), planned_url: ownLink(p.coupon_url) } },
++ },
++ };
++ },
'trade.alert.triggered'(event) {
if (event.source !== 'trade') return 'ignored:source';
const s = event.subject && typeof event.subject === 'object' ? event.subject : {};
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex dd74b029..00407a9e 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -40,6 +40,17 @@ const dealsEvent = (over = {}) => ({
},
...over,
});
++const couponsEvent = (over = {}) => ({
++ event_id: ids.newId('event'), event_type: 'coupons.watch.matched', version: 1, source: 'coupons',
++ actor: { type: 'service', id: 'coupons' }, subject: { type: 'coupon', id: 'cpn_01JAB2C3D4E5F6G7H8J9K0MNPT' }, visibility: 'internal',
++ occurred_at: new Date().toISOString(),
++ payload: {
++ recipient: ALICE, merchant_id: 'mer_01JAB2C3D4E5F6G7H8J9K0MNPS', merchant_name: 'Trail <i>Supply</i>',
++ merchant_url: 'https://openvibe.coupons/m/trail-supply', coupon_id: 'cpn_01JAB2C3D4E5F6G7H8J9K0MNPT',
++ coupon_url: 'https://openvibe.coupons/c/cpn_01JAB2C3D4E5F6G7H8J9K0MNPT', title: '20% off tents', expires_at: '2026-11-30',
++ },
++ ...over,
++});
const tradeEvent = (over = {}) => ({
event_id: ids.newId('event'), event_type: 'trade.alert.triggered', version: 1, source: 'trade',
actor: { type: 'service', id: 'trade' }, subject: { type: 'user', id: BOB }, visibility: 'subject', priority: 'important',
@@@ -57,7 +68,7 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
-- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
++ assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'services'].map((svc) => `${svc}.moderation.action`),
'tools.usage.recorded', 'events.usage.recorded', 'media.usage.recorded']);
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
@@@ -294,6 +305,25 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
assert.strictEqual(await count(), total);
assert.strictEqual(discordCalls.length, 2);
++ // A Coupons watch match becomes one notification for the watcher, linking the coupon page; a wrong source is ignored.
++ {
++ const before = (await rows(7)).length;
++ const c1 = couponsEvent();
++ const cr = await post(c1, { now: clock });
++ assert.deepStrictEqual(cr.body, { event_id: c1.event_id, duplicate: false, outcome: 'notified' });
++ const theirs = (await rows(7)).slice(before);
++ assert.strictEqual(theirs.length, 1);
++ assert.strictEqual(theirs[0].type, 'COUPON_WATCH_MATCH');
++ assert.strictEqual(theirs[0].service, 'coupons');
++ assert.match(theirs[0].title, /^New code for Trail/);
++ assert.ok(!/[<>]/.test(theirs[0].title), 'markup is stripped');
++ assert.strictEqual(theirs[0].message, '20% off tents · until 2026-11-30');
++ assert.strictEqual((await post(c1, { now: clock })).body.duplicate, true, 'a redelivery notifies nobody again');
++ const spoof = await post(couponsEvent({ source: 'deals' }), { now: clock });
++ assert.strictEqual(spoof.body.outcome, 'ignored:source');
++ assert.strictEqual((await rows(7)).length, before + 1);
++ }
++
srv.close();
console.log('events consumer: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });