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)

OP OpenVibers committed 16 days ago · 971ce9f0dd · 5 files, +167 −3

Changes

ddiff --git a/README.md b/README.md
iindex f1bbe542..d9ab89b0 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
iindex 5c9e8971..3bc8b819 100644
---- a/server/notifications/notification-service.js
++++ b/server/notifications/notification-service.js
@@@ -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 notifications
WHERE 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);
}
ddiff --git a/server/notifications/routes.js b/server/notifications/routes.js
iindex 43e73519..60e7d010 100644
---- a/server/notifications/routes.js
++++ b/server/notifications/routes.js
@@@ -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, since
router.get('/', requireAuth, async (req, res) => {
try {
const userId = req.user.id;
@@@ -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 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 {
ddiff --git a/server/notifications/tabs.js b/server/notifications/tabs.js
nnew file mode 100644
iindex 00000000..e3e97d55
---- /dev/null
++++ b/server/notifications/tabs.js
@@@ -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 };
ddiff --git a/test/notification-tabs.test.js b/test/notification-tabs.test.js
nnew file mode 100644
iindex 00000000..36664190
---- /dev/null
++++ b/test/notification-tabs.test.js
@@@ -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); });