openvibers/openvibe.network Commits
Network: Coupons watch matches become notifications (coupons.watch.matched) (#209)
Changes
@@@ -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",@@@ -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",@@@ -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 : {};@@@ -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 WHEassert.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); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1320,9 +1320,9 @@
@@ -1320,9 +1320,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",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
*
*
* deals.watch.matched a Deals watch (keyword, product, price below) matched a fresh observation;
* deals.watch.matched a Deals watch (keyword, product, price below) matched a fresh observation;
* payload.recipient is the watcher's usr_ subject
* 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);
* trade.alert.triggered a Trade alert rule fired (threshold crossed, filing/document seen);
* event.subject { type: 'user', id } is the rule's owner
* 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);
* live.stream.started a Live channel went live (OpenVibe.Live server/events/stream-events.js);
@@ -24,9 +26,8 @@
@@ -24,9 +26,8 @@
* (common.usage-recorded@1): kept per project and day for its dashboard
* (common.usage-recorded@1): kept per project and day for its dashboard
* (server/developer/usage.js), never anyone's notification.
* (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(),
* 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
* which applies their preferences: a muted category ('service') creates nothing, and email follows
@@ -54,7 +55,7 @@ const CONSUMER = 'network-notifications';
@@ -54,7 +55,7 @@ const CONSUMER = 'network-notifications';
const INBOX_TABLE = 'network_event_inbox';
const INBOX_TABLE = 'network_event_inbox';
const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;
const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;
const USAGE_TOPICS = require('../developer/usage').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 LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -100,6 +101,23 @@ const HANDLERS = {
@@ -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) {
'trade.alert.triggered'(event) {
if (event.source !== 'trade') return 'ignored:source';
if (event.source !== 'trade') return 'ignored:source';
const s = event.subject && typeof event.subject === 'object' ? event.subject : {};
const s = event.subject && typeof event.subject === 'object' ? event.subject : {};
@@ -40,6 +40,17 @@ const dealsEvent = (over = {}) => ({
@@ -40,6 +40,17 @@ const dealsEvent = (over = {}) => ({
},
},
...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 = {}) => ({
const tradeEvent = (over = {}) => ({
event_id: ids.newId('event'), event_type: 'trade.alert.triggered', version: 1, source: 'trade',
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',
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
@@ -57,7 +68,7 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
(async () => {
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
// 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`),
...['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']);
'tools.usage.recorded', 'events.usage.recorded', 'media.usage.recorded']);
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
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
@@ -294,6 +305,25 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
assert.strictEqual(await count(), total);
assert.strictEqual(await count(), total);
assert.strictEqual(discordCalls.length, 2);
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();
srv.close();
console.log('events consumer: all checks passed');
console.log('events consumer: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });