openvibers/openvibe.network Commits
The bell's tabs: GET /api/notifications?tab= (Mentions, Live, Deals & alerts, System, Agents; one definition in tabs.js) and GET /api/notifications/unread-by-tab for the counts on them (#392)
Changes
@@@ -263,6 +263,8 @@ Users can toggle each category's enabled/sound/toast/email preferences at `openv### Quiet hours and digests++The bell's tabs (All · Mentions · Live · Deals & alerts · System · Agents) are defined once in `server/notifications/tabs.js`: `GET /api/notifications?tab=mentions|live|alerts|system|agents` lists a tab (it combines with `unread_only`, `q` and paging; an unknown tab is All), and `GET /api/notifications/unread-by-tab` answers `{ tabs: { all, mentions, live, alerts, system, agents } }` for the counts on the tabs.++A person can set a do-not-disturb window with `GET`/`PUT /api/notifications/quiet-hours` — `{ enabled, start: 'HH:MM', end: 'HH:MM', tz }`, default off at 22:00–07:00 UTC; the GET also reports `in_quiet`. Normal notification email and browser push wait until the window ends; `critical` notifications still go at once. The notification itself is always stored and appears in the bell.Each category can choose `digest`: `off` (the default, NULL), `hourly`, `daily` or `weekly`, through `PUT /api/notifications/preferences`. A digested category is not emailed one by one: `runDigests()` sends one summary email after the period is due (daily and weekly at 08:00 or later in the person's quiet-hours zone, never during quiet hours), lists up to 20 items and says "and N more", then marks them emailed. It runs every 15 minutes in `server/index.js` and uses the same daily caps and verified/bounced-address checks as the email queue.@@@ -8,6 +8,7 @@const { v4: uuidv4 } = require('uuid');const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');++const tabs = require('./tabs');const contracts = require('openvibe-contracts');const eventRelay = require('../developer/event-relay');const { inQuiet, parseTime, validTimeZone, rowToConfig, configToView } = require('./quiet-hours');@@@ -82,6 +83,12 @@ class NotificationService {WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0`);++ this._unreadCountByKind = db.prepare(`++ SELECT category, type, COUNT(*) as count FROM notifications++ WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0++ GROUP BY category, type++ `);++this._unreadCountByCategory = db.prepare(`SELECT category, COUNT(*) as count FROM notificationsWHERE user_id = ? AND is_read = 0 AND is_dismissed = 0@@@ -318,9 +325,11 @@ class NotificationService {* - since: ISO timestamp, only rows created after it (toasts)* Fetches limit+1 so the client knows whether another page exists.*/-- async getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {++ async getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null, tab = null } = {}) {const where = ['user_id = ?', 'is_dismissed = 0'];const params = [userId];++ const inTab = tabs.tabWhere(tab);++ if (inTab) { where.push(inTab.sql); params.push(...inTab.params); }if (category) { where.push('category = ?'); params.push(category); }if (type) { where.push('type = ?'); params.push(type); }if (unreadOnly) where.push('is_read = 0');@@@ -360,6 +369,11 @@ class NotificationService {return (await this._unreadCount.get(userId))?.count || 0;}++ /** Unread counts for the bell's tabs: { all, mentions, live, alerts, system, agents }. */++ async getUnreadByTab(userId) {++ return tabs.countsByTab(await this._unreadCountByKind.all(userId));++ }++async getUnreadByCategory(userId) {return await this._unreadCountByCategory.all(userId);}@@@ -12,7 +12,7 @@ const router = express.Router();module.exports = function createNotificationRoutes(db, notificationService, requireAuth) {// ─── GET /api/notifications ────────────────────────────-- // Query params: limit, offset, category, unread_only++ // Query params: limit, offset, category, type, tab (the bell's tabs, ./tabs.js), unread_only, q, sincerouter.get('/', requireAuth, async (req, res) => {try {const userId = req.user.id;@@@ -24,8 +24,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requconst unreadOnly = ['1', 'true'].includes(String(req.query.unread_only ?? req.query.unread ?? ''));const q = req.query.q ? String(req.query.q).trim() : null;const since = req.query.since ? String(req.query.since) : null;++ const tab = typeof req.query.tab === 'string' ? req.query.tab : null;-- const out = await notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });++ const out = await notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type, tab });res.json({ ok: true, ...out, limit, offset });} catch (err) {console.error('[Notifications] GET / error:', err);@@@ -43,6 +44,16 @@ module.exports = function createNotificationRoutes(db, notificationService, requ}});++ // ─── GET /api/notifications/unread-by-tab ──────────────++ // The counts on the bell's tabs: { tabs: { all, mentions, live, alerts, system, agents } }.++ router.get('/unread-by-tab', requireAuth, async (req, res) => {++ try {++ res.json({ ok: true, tabs: await notificationService.getUnreadByTab(req.user.id) });++ } catch (err) {++ res.status(500).json({ ok: false, error: 'Failed to get counts' });++ }++ });++// ─── GET /api/notifications/unread-by-category ─────────router.get('/unread-by-category', requireAuth, async (req, res) => {try {nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The bell's tabs (Notifications 2.0, plan 2026-10-28 §3.10): All · Mentions · Live · Deals & alerts · System · Agents.++// One definition serves the list's filter (GET /api/notifications?tab=) and the counts on the tabs++// (GET /api/notifications/unread-by-tab), so the Shared bell knows only the ids. A tab is a set of categories, a set of++// types, and type prefixes for kinds that land later (agent work from Actor and Run); All is no filter, and an unknown++// tab is All.++// ═══════════════════════════════════════════════════════════════++++const TABS = {++ // What people said to you or about you.++ mentions: { types: ['MENTION', 'CONTENT_REPLY', 'CONTENT_COMMENT', 'DIRECT_MESSAGE', 'WHISPER'] },++ live: { categories: ['stream'] },++ // Deal, coupon, trade and watch alerts: the service category.++ alerts: { categories: ['service'] },++ // Your account's security notices, announcements, moderation, and (for staff) operator alerts.++ system: { categories: ['system', 'admin', 'moderation'] },++ // Approvals an agent waits on, and agent tasks finishing or failing.++ agents: { types: ['CONFIRMATION_REQUESTED'], prefixes: ['AGENT_', 'RUN_'] },++};++const IDS = ['all', ...Object.keys(TABS)];++++/** The SQL condition for a tab ({ sql, params }), or null for All and unknown tabs. */++function tabWhere(id) {++ const t = TABS[id];++ if (!t) return null;++ const parts = [];++ const params = [];++ if (t.categories) { parts.push(`category IN (${t.categories.map(() => '?').join(', ')})`); params.push(...t.categories); }++ if (t.types) { parts.push(`type IN (${t.types.map(() => '?').join(', ')})`); params.push(...t.types); }++ for (const p of t.prefixes || []) { parts.push("type LIKE ? ESCAPE '\\'"); params.push(`${p.replace(/[%_\\]/g, (c) => '\\' + c)}%`); }++ return { sql: `(${parts.join(' OR ')})`, params };++}++++/** Whether a notification of this category and type is under the tab. */++function inTab(id, category, type) {++ if (id === 'all') return true;++ const t = TABS[id];++ if (!t) return false;++ type = String(type || '');++ return (t.categories || []).includes(category) || (t.types || []).includes(type) || (t.prefixes || []).some((p) => type.startsWith(p));++}++++/** Unread counts per tab from rows of { category, type, count }. */++function countsByTab(rows) {++ const out = Object.fromEntries(IDS.map((id) => [id, 0]));++ for (const r of rows || []) {++ const n = Number(r.count) || 0;++ for (const id of IDS) if (inTab(id, r.category, r.type)) out[id] += n;++ }++ return out;++}++++module.exports = { TABS, IDS, tabWhere, inTab, countsByTab };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++// The bell's tabs (server/notifications/tabs.js): GET /api/notifications?tab= lists what each tab holds (Mentions by++// type, Live and Deals & alerts and System by category, Agents by type and prefix), an unknown tab is All, tab and++// unread_only combine, and GET /api/notifications/unread-by-tab counts each tab's unread notices.++// node --import ./test/helpers/pg-preload.mjs test/notification-tabs.test.js++const assert = require('assert');++const http = require('http');++const crypto = require('crypto');++const express = require('express');++const { getDb } = require('../server/db/database');++const { NotificationService } = require('../server/notifications/notification-service');++const createNotificationRoutes = require('../server/notifications/routes');++const tabs = require('../server/notifications/tabs');++const { signToken } = require('../server/auth/routes');++++(async () => {++const db = getDb();++const log = console.log; console.log = () => {};++await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (41, 'tabby', '[email protected]', 'x', 1)").run();++const notifications = new NotificationService(db);++++// The pure parts.++assert.strictEqual(tabs.tabWhere('all'), null, 'All is no filter');++assert.strictEqual(tabs.tabWhere('nope'), null, 'an unknown tab is All');++assert.deepStrictEqual(tabs.tabWhere('agents').params, ['CONFIRMATION_REQUESTED', 'AGENT\\_%', 'RUN\\_%'], 'prefixes escape LIKE wildcards');++assert.strictEqual(tabs.inTab('agents', 'system', 'AGENT_TASK_DONE'), true);++assert.strictEqual(tabs.inTab('mentions', 'social', 'FOLLOW'), false, 'a follow is not a mention');++assert.deepStrictEqual(tabs.countsByTab([{ category: 'social', type: 'MENTION', count: '2' }, { category: 'stream', type: 'STREAM_LIVE', count: 3 }]),++ { all: 5, mentions: 2, live: 3, alerts: 0, system: 0, agents: 0 }, 'counts add up per tab, string counts included');++++const made = {};++for (const [key, type, category] of [++ ['mention', 'MENTION', 'social'], ['reply', 'CONTENT_REPLY', 'social'], ['dm', 'DIRECT_MESSAGE', 'chat'], ['follow', 'FOLLOW', 'social'],++ ['live', 'STREAM_LIVE', 'stream'], ['deal', 'DEAL_WATCH_MATCH', 'service'], ['login', 'NEW_LOGIN', 'system'],++ ['ops', 'OPERATOR_ALERT', 'admin'], ['confirm', 'CONFIRMATION_REQUESTED', 'system'], ['agent', 'AGENT_TASK_DONE', 'system'], ['level', 'LEVEL_UP', 'game'],++]) {++ made[key] = await notifications.create({ user_id: 41, type, category, title: `${key} notice`, service: 'network' });++ assert.ok(made[key], `${key} is created`);++}++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++const user = await db.prepare('SELECT * FROM users WHERE id = 41').get();++const token = signToken(user, keys.privateKey, config);++const app = express();++app.use(express.json());++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use('/api/notifications', createNotificationRoutes(db, notifications, requireAuth));++const server = http.createServer(app);++await new Promise((r) => server.listen(0, '127.0.0.1', r));++const base = `http://127.0.0.1:${server.address().port}`;++const get = (path) => fetch(base + path, { headers: { authorization: `Bearer ${token}` } }).then(async (r) => ({ status: r.status, body: await r.json() }));++const titles = (r) => r.body.notifications.map((n) => n.title.replace(' notice', '')).sort();++++try {++ assert.deepStrictEqual(titles(await get('/api/notifications?tab=mentions')), ['dm', 'mention', 'reply'], 'Mentions: what people said to you');++ assert.deepStrictEqual(titles(await get('/api/notifications?tab=live')), ['live']);++ assert.deepStrictEqual(titles(await get('/api/notifications?tab=alerts')), ['deal'], 'Deals & alerts: the service category');++ assert.deepStrictEqual(titles(await get('/api/notifications?tab=system')), ['agent', 'confirm', 'login', 'ops'], 'System: system, admin and moderation');++ assert.deepStrictEqual(titles(await get('/api/notifications?tab=agents')), ['agent', 'confirm'], 'Agents: approvals and AGENT_ work');++ assert.strictEqual((await get('/api/notifications?tab=bogus')).body.notifications.length, 11, 'an unknown tab lists everything');++ const tabTotal = await get('/api/notifications?tab=mentions&limit=2');++ assert.deepStrictEqual([tabTotal.body.notifications.length, tabTotal.body.has_more, Number(tabTotal.body.total)], [2, true, 3], 'paging and the total follow the tab');++++ let counts = await get('/api/notifications/unread-by-tab');++ assert.strictEqual(counts.status, 200);++ assert.deepStrictEqual(counts.body.tabs, { all: 11, mentions: 3, live: 1, alerts: 1, system: 4, agents: 2 });++++ await notifications.markReadMany([made.mention.id, made.agent.id], 41);++ assert.deepStrictEqual(titles(await get('/api/notifications?tab=mentions&unread_only=1')), ['dm', 'reply'], 'tab and unread_only combine');++ counts = await get('/api/notifications/unread-by-tab');++ assert.deepStrictEqual(counts.body.tabs, { all: 9, mentions: 2, live: 1, alerts: 1, system: 3, agents: 1 }, 'read notices leave the counts');++++ assert.strictEqual((await fetch(base + '/api/notifications/unread-by-tab')).status, 401, 'counts need a session');++} finally {++ server.close();++ console.log = log;++}++console.log('notification-tabs: ok');++process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -263,6 +263,8 @@ Users can toggle each category's enabled/sound/toast/email preferences at `openv
@@ -263,6 +263,8 @@ Users can toggle each category's enabled/sound/toast/email preferences at `openv
### Quiet hours and digests
### Quiet hours and digests
+The bell's tabs (All · Mentions · Live · Deals & alerts · System · Agents) are defined once in `server/notifications/tabs.js`: `GET /api/notifications?tab=mentions|live|alerts|system|agents` lists a tab (it combines with `unread_only`, `q` and paging; an unknown tab is All), and `GET /api/notifications/unread-by-tab` answers `{ tabs: { all, mentions, live, alerts, system, agents } }` for the counts on the tabs.
+
A person can set a do-not-disturb window with `GET`/`PUT /api/notifications/quiet-hours` — `{ enabled, start: 'HH:MM', end: 'HH:MM', tz }`, default off at 22:00–07:00 UTC; the GET also reports `in_quiet`. Normal notification email and browser push wait until the window ends; `critical` notifications still go at once. The notification itself is always stored and appears in the bell.
A person can set a do-not-disturb window with `GET`/`PUT /api/notifications/quiet-hours` — `{ enabled, start: 'HH:MM', end: 'HH:MM', tz }`, default off at 22:00–07:00 UTC; the GET also reports `in_quiet`. Normal notification email and browser push wait until the window ends; `critical` notifications still go at once. The notification itself is always stored and appears in the bell.
Each category can choose `digest`: `off` (the default, NULL), `hourly`, `daily` or `weekly`, through `PUT /api/notifications/preferences`. A digested category is not emailed one by one: `runDigests()` sends one summary email after the period is due (daily and weekly at 08:00 or later in the person's quiet-hours zone, never during quiet hours), lists up to 20 items and says "and N more", then marks them emailed. It runs every 15 minutes in `server/index.js` and uses the same daily caps and verified/bounced-address checks as the email queue.
Each category can choose `digest`: `off` (the default, NULL), `hourly`, `daily` or `weekly`, through `PUT /api/notifications/preferences`. A digested category is not emailed one by one: `runDigests()` sends one summary email after the period is due (daily and weekly at 08:00 or later in the person's quiet-hours zone, never during quiet hours), lists up to 20 items and says "and N more", then marks them emailed. It runs every 15 minutes in `server/index.js` and uses the same daily caps and verified/bounced-address checks as the email queue.
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');
const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');
+const tabs = require('./tabs');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
const { inQuiet, parseTime, validTimeZone, rowToConfig, configToView } = require('./quiet-hours');
const { inQuiet, parseTime, validTimeZone, rowToConfig, configToView } = require('./quiet-hours');
@@ -82,6 +83,12 @@ class NotificationService {
@@ -82,6 +83,12 @@ class NotificationService {
WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
`);
`);
+ this._unreadCountByKind = db.prepare(`
+ SELECT category, type, COUNT(*) as count FROM notifications
+ WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
+ GROUP BY category, type
+ `);
+
this._unreadCountByCategory = db.prepare(`
this._unreadCountByCategory = db.prepare(`
SELECT category, COUNT(*) as count FROM notifications
SELECT category, COUNT(*) as count FROM notifications
WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
@@ -318,9 +325,11 @@ class NotificationService {
@@ -318,9 +325,11 @@ class NotificationService {
* - since: ISO timestamp, only rows created after it (toasts)
* - since: ISO timestamp, only rows created after it (toasts)
* Fetches limit+1 so the client knows whether another page exists.
* Fetches limit+1 so the client knows whether another page exists.
*/
*/
- async getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {
+ async getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null, tab = null } = {}) {
const where = ['user_id = ?', 'is_dismissed = 0'];
const where = ['user_id = ?', 'is_dismissed = 0'];
const params = [userId];
const params = [userId];
+ const inTab = tabs.tabWhere(tab);
+ if (inTab) { where.push(inTab.sql); params.push(...inTab.params); }
if (category) { where.push('category = ?'); params.push(category); }
if (category) { where.push('category = ?'); params.push(category); }
if (type) { where.push('type = ?'); params.push(type); }
if (type) { where.push('type = ?'); params.push(type); }
if (unreadOnly) where.push('is_read = 0');
if (unreadOnly) where.push('is_read = 0');
@@ -360,6 +369,11 @@ class NotificationService {
@@ -360,6 +369,11 @@ class NotificationService {
return (await this._unreadCount.get(userId))?.count || 0;
return (await this._unreadCount.get(userId))?.count || 0;
}
}
+ /** Unread counts for the bell's tabs: { all, mentions, live, alerts, system, agents }. */
+ async getUnreadByTab(userId) {
+ return tabs.countsByTab(await this._unreadCountByKind.all(userId));
+ }
+
async getUnreadByCategory(userId) {
async getUnreadByCategory(userId) {
return await this._unreadCountByCategory.all(userId);
return await this._unreadCountByCategory.all(userId);
}
}
@@ -12,7 +12,7 @@ const router = express.Router();
@@ -12,7 +12,7 @@ const router = express.Router();
module.exports = function createNotificationRoutes(db, notificationService, requireAuth) {
module.exports = function createNotificationRoutes(db, notificationService, requireAuth) {
// ─── GET /api/notifications ────────────────────────────
// ─── GET /api/notifications ────────────────────────────
- // Query params: limit, offset, category, unread_only
+ // Query params: limit, offset, category, type, tab (the bell's tabs, ./tabs.js), unread_only, q, since
router.get('/', requireAuth, async (req, res) => {
router.get('/', requireAuth, async (req, res) => {
try {
try {
const userId = req.user.id;
const userId = req.user.id;
@@ -24,8 +24,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -24,8 +24,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
const unreadOnly = ['1', 'true'].includes(String(req.query.unread_only ?? req.query.unread ?? ''));
const unreadOnly = ['1', 'true'].includes(String(req.query.unread_only ?? req.query.unread ?? ''));
const q = req.query.q ? String(req.query.q).trim() : null;
const q = req.query.q ? String(req.query.q).trim() : null;
const since = req.query.since ? String(req.query.since) : null;
const since = req.query.since ? String(req.query.since) : null;
+ const tab = typeof req.query.tab === 'string' ? req.query.tab : null;
- const out = await notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });
+ const out = await notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type, tab });
res.json({ ok: true, ...out, limit, offset });
res.json({ ok: true, ...out, limit, offset });
} catch (err) {
} catch (err) {
console.error('[Notifications] GET / error:', err);
console.error('[Notifications] GET / error:', err);
@@ -43,6 +44,16 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -43,6 +44,16 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
}
}
});
});
+ // ─── GET /api/notifications/unread-by-tab ──────────────
+ // The counts on the bell's tabs: { tabs: { all, mentions, live, alerts, system, agents } }.
+ router.get('/unread-by-tab', requireAuth, async (req, res) => {
+ try {
+ res.json({ ok: true, tabs: await notificationService.getUnreadByTab(req.user.id) });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: 'Failed to get counts' });
+ }
+ });
+
// ─── GET /api/notifications/unread-by-category ─────────
// ─── GET /api/notifications/unread-by-category ─────────
router.get('/unread-by-category', requireAuth, async (req, res) => {
router.get('/unread-by-category', requireAuth, async (req, res) => {
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The bell's tabs (Notifications 2.0, plan 2026-10-28 §3.10): All · Mentions · Live · Deals & alerts · System · Agents.
+// One definition serves the list's filter (GET /api/notifications?tab=) and the counts on the tabs
+// (GET /api/notifications/unread-by-tab), so the Shared bell knows only the ids. A tab is a set of categories, a set of
+// types, and type prefixes for kinds that land later (agent work from Actor and Run); All is no filter, and an unknown
+// tab is All.
+// ═══════════════════════════════════════════════════════════════
+
+const TABS = {
+ // What people said to you or about you.
+ mentions: { types: ['MENTION', 'CONTENT_REPLY', 'CONTENT_COMMENT', 'DIRECT_MESSAGE', 'WHISPER'] },
+ live: { categories: ['stream'] },
+ // Deal, coupon, trade and watch alerts: the service category.
+ alerts: { categories: ['service'] },
+ // Your account's security notices, announcements, moderation, and (for staff) operator alerts.
+ system: { categories: ['system', 'admin', 'moderation'] },
+ // Approvals an agent waits on, and agent tasks finishing or failing.
+ agents: { types: ['CONFIRMATION_REQUESTED'], prefixes: ['AGENT_', 'RUN_'] },
+};
+const IDS = ['all', ...Object.keys(TABS)];
+
+/** The SQL condition for a tab ({ sql, params }), or null for All and unknown tabs. */
+function tabWhere(id) {
+ const t = TABS[id];
+ if (!t) return null;
+ const parts = [];
+ const params = [];
+ if (t.categories) { parts.push(`category IN (${t.categories.map(() => '?').join(', ')})`); params.push(...t.categories); }
+ if (t.types) { parts.push(`type IN (${t.types.map(() => '?').join(', ')})`); params.push(...t.types); }
+ for (const p of t.prefixes || []) { parts.push("type LIKE ? ESCAPE '\\'"); params.push(`${p.replace(/[%_\\]/g, (c) => '\\' + c)}%`); }
+ return { sql: `(${parts.join(' OR ')})`, params };
+}
+
+/** Whether a notification of this category and type is under the tab. */
+function inTab(id, category, type) {
+ if (id === 'all') return true;
+ const t = TABS[id];
+ if (!t) return false;
+ type = String(type || '');
+ return (t.categories || []).includes(category) || (t.types || []).includes(type) || (t.prefixes || []).some((p) => type.startsWith(p));
+}
+
+/** Unread counts per tab from rows of { category, type, count }. */
+function countsByTab(rows) {
+ const out = Object.fromEntries(IDS.map((id) => [id, 0]));
+ for (const r of rows || []) {
+ const n = Number(r.count) || 0;
+ for (const id of IDS) if (inTab(id, r.category, r.type)) out[id] += n;
+ }
+ return out;
+}
+
+module.exports = { TABS, IDS, tabWhere, inTab, countsByTab };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+// The bell's tabs (server/notifications/tabs.js): GET /api/notifications?tab= lists what each tab holds (Mentions by
+// type, Live and Deals & alerts and System by category, Agents by type and prefix), an unknown tab is All, tab and
+// unread_only combine, and GET /api/notifications/unread-by-tab counts each tab's unread notices.
+// node --import ./test/helpers/pg-preload.mjs test/notification-tabs.test.js
+const assert = require('assert');
+const http = require('http');
+const crypto = require('crypto');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { NotificationService } = require('../server/notifications/notification-service');
+const createNotificationRoutes = require('../server/notifications/routes');
+const tabs = require('../server/notifications/tabs');
+const { signToken } = require('../server/auth/routes');
+
+(async () => {
+const db = getDb();
+const log = console.log; console.log = () => {};
+await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (41, 'tabby', '[email protected]', 'x', 1)").run();
+const notifications = new NotificationService(db);
+
+// The pure parts.
+assert.strictEqual(tabs.tabWhere('all'), null, 'All is no filter');
+assert.strictEqual(tabs.tabWhere('nope'), null, 'an unknown tab is All');
+assert.deepStrictEqual(tabs.tabWhere('agents').params, ['CONFIRMATION_REQUESTED', 'AGENT\\_%', 'RUN\\_%'], 'prefixes escape LIKE wildcards');
+assert.strictEqual(tabs.inTab('agents', 'system', 'AGENT_TASK_DONE'), true);
+assert.strictEqual(tabs.inTab('mentions', 'social', 'FOLLOW'), false, 'a follow is not a mention');
+assert.deepStrictEqual(tabs.countsByTab([{ category: 'social', type: 'MENTION', count: '2' }, { category: 'stream', type: 'STREAM_LIVE', count: 3 }]),
+ { all: 5, mentions: 2, live: 3, alerts: 0, system: 0, agents: 0 }, 'counts add up per tab, string counts included');
+
+const made = {};
+for (const [key, type, category] of [
+ ['mention', 'MENTION', 'social'], ['reply', 'CONTENT_REPLY', 'social'], ['dm', 'DIRECT_MESSAGE', 'chat'], ['follow', 'FOLLOW', 'social'],
+ ['live', 'STREAM_LIVE', 'stream'], ['deal', 'DEAL_WATCH_MATCH', 'service'], ['login', 'NEW_LOGIN', 'system'],
+ ['ops', 'OPERATOR_ALERT', 'admin'], ['confirm', 'CONFIRMATION_REQUESTED', 'system'], ['agent', 'AGENT_TASK_DONE', 'system'], ['level', 'LEVEL_UP', 'game'],
+]) {
+ made[key] = await notifications.create({ user_id: 41, type, category, title: `${key} notice`, service: 'network' });
+ assert.ok(made[key], `${key} is created`);
+}
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+const user = await db.prepare('SELECT * FROM users WHERE id = 41').get();
+const token = signToken(user, keys.privateKey, config);
+const app = express();
+app.use(express.json());
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use('/api/notifications', createNotificationRoutes(db, notifications, requireAuth));
+const server = http.createServer(app);
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+const get = (path) => fetch(base + path, { headers: { authorization: `Bearer ${token}` } }).then(async (r) => ({ status: r.status, body: await r.json() }));
+const titles = (r) => r.body.notifications.map((n) => n.title.replace(' notice', '')).sort();
+
+try {
+ assert.deepStrictEqual(titles(await get('/api/notifications?tab=mentions')), ['dm', 'mention', 'reply'], 'Mentions: what people said to you');
+ assert.deepStrictEqual(titles(await get('/api/notifications?tab=live')), ['live']);
+ assert.deepStrictEqual(titles(await get('/api/notifications?tab=alerts')), ['deal'], 'Deals & alerts: the service category');
+ assert.deepStrictEqual(titles(await get('/api/notifications?tab=system')), ['agent', 'confirm', 'login', 'ops'], 'System: system, admin and moderation');
+ assert.deepStrictEqual(titles(await get('/api/notifications?tab=agents')), ['agent', 'confirm'], 'Agents: approvals and AGENT_ work');
+ assert.strictEqual((await get('/api/notifications?tab=bogus')).body.notifications.length, 11, 'an unknown tab lists everything');
+ const tabTotal = await get('/api/notifications?tab=mentions&limit=2');
+ assert.deepStrictEqual([tabTotal.body.notifications.length, tabTotal.body.has_more, Number(tabTotal.body.total)], [2, true, 3], 'paging and the total follow the tab');
+
+ let counts = await get('/api/notifications/unread-by-tab');
+ assert.strictEqual(counts.status, 200);
+ assert.deepStrictEqual(counts.body.tabs, { all: 11, mentions: 3, live: 1, alerts: 1, system: 4, agents: 2 });
+
+ await notifications.markReadMany([made.mention.id, made.agent.id], 41);
+ assert.deepStrictEqual(titles(await get('/api/notifications?tab=mentions&unread_only=1')), ['dm', 'reply'], 'tab and unread_only combine');
+ counts = await get('/api/notifications/unread-by-tab');
+ assert.deepStrictEqual(counts.body.tabs, { all: 9, mentions: 2, live: 1, alerts: 1, system: 3, agents: 1 }, 'read notices leave the counts');
+
+ assert.strictEqual((await fetch(base + '/api/notifications/unread-by-tab')).status, 401, 'counts need a session');
+} finally {
+ server.close();
+ console.log = log;
+}
+console.log('notification-tabs: ok');
+process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });