openvibers/openvibe.network Commits
Quiet hours and the notification digest: a person's window in their time zone holds email and push (critical breaks through), categories can email hourly, daily or weekly digests (a 15-minute job), the account hub's settings for both (#390)
Changes
@@@ -244,6 +244,7 @@ sudo apt install certbot python3-certbot-dns-cloudflare2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below)4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).++5. **Quiet hours and digests** can hold normal email and browser push until a window ends, or replace per-alert email with an hourly/daily/weekly summary; critical notifications always break through.### Priorities@@@ -260,6 +261,12 @@ sudo apt install certbot python3-certbot-dns-cloudflareUsers can toggle each category's enabled/sound/toast/email preferences at `openvibe.network/my` → Notifications tab.++### Quiet hours and digests++++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.++### Cross-Service PushOther services push notifications to openvibe.network via the internal API:@@@ -361,7 +368,7 @@ It then opens `${stream_url}?topics=network.notification.*&ticket=…[&last_even- **Refusals:** a guest gets 403 `realtime.guest`. `REALTIME_TICKETS=off` in `/etc/openvibe/network.env` answers 503 `realtime.disabled`, and every badge stays on polling. `OV_EVENTS_PUBLIC_URL` overrides the stream origin (default `https://openvibe.events`).- **Client:** openvibe-shared `notification-live.js`, on where a site sets `notificationsRealtime: true`.--A digest (a daily or weekly summary instead of one alert per notification) is designed in [docs/notification-digest.md](docs/notification-digest.md) for a later release.++Quiet hours and per-category digests are built (migration `0027_notification_quiet_digest.sql`, `server/notifications/quiet-hours.js` and `digest.js`); the design note is [docs/notification-digest.md](docs/notification-digest.md).---@@@ -1,6 +1,8 @@--# Notification digests: design (later release)++# Notification digests: design--Roadmap WS-E task 3 asks for a digest design now and the digest itself in a later release. Network owns digests (ADR-020: Network keeps addressing, preferences, digests, read state and the store). Nothing here is built yet.++> Built: migration `0027_notification_quiet_digest.sql`, `server/notifications/quiet-hours.js` and `server/notifications/digest.js` implement the per-category `digest` column, quiet hours and the 15-minute runner. This note records the original design; the `delivery` column, `digest_pending` and the `DIGEST` inbox notification below were not part of this slice.++++Roadmap WS-E task 3 asks for a digest design now and the digest itself in a later release. Network owns digests (ADR-020: Network keeps addressing, preferences, digests, read state and the store).## Problemnnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- Quiet hours and email digests: a do-not-disturb window in each person's own zone, plus a per-category hourly/daily/weekly summary; additive, an older release never reads these.++CREATE TABLE IF NOT EXISTS notification_quiet_hours (++ user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,++ enabled bigint NOT NULL DEFAULT 1,++ start_min integer NOT NULL CHECK (start_min BETWEEN 0 AND 1439),++ end_min integer NOT NULL CHECK (end_min BETWEEN 0 AND 1439),++ tz text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" DEFAULT ov_now()++);++++ALTER TABLE notification_preferences ADD COLUMN IF NOT EXISTS digest text COLLATE "C" CHECK (digest IN ('off', 'hourly', 'daily', 'weekly'));++++CREATE TABLE IF NOT EXISTS notification_digest_runs (++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,++ period text COLLATE "C" NOT NULL,++ last_sent_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (user_id, period)++);@@@ -121,7 +121,15 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa.account-item .actions{margin-left:auto;display:flex;gap:6px}/* Notification pref grid */--.notif-grid{display:grid;grid-template-columns:1fr auto auto auto auto;gap:8px;align-items:center;font-size:12px}++.notif-grid{display:grid;grid-template-columns:1fr auto auto auto auto auto;gap:8px;align-items:center;font-size:12px}++.notif-grid select.digest{min-height:30px;padding:2px 6px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit;font-size:12px}++.quiet-form{display:grid;gap:14px}++.quiet-times{display:flex;flex-wrap:wrap;gap:10px 16px}++.quiet-times label{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:600;color:var(--text-muted)}++.quiet-times input,.quiet-times select{min-height:40px;padding:6px 10px;border-radius:10px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit;font-size:14px}++.quiet-times .quiet-tz{flex:1 1 220px}++.quiet-times .quiet-tz select{width:100%}++.quiet-form .btn-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px}.notif-grid .cat{font-weight:600;text-transform:capitalize}.notif-grid .toggle{width:36px;height:20px;border-radius:10px;border:none;cursor:pointer;position:relative;transition:background .2s}.notif-grid .toggle::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s}@@@ -215,7 +223,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat.page-header{flex-direction:column;text-align:center}.page-header .info .badges{justify-content:center}.notif-grid{grid-template-columns:1fr auto auto}-- .notif-grid .col-email,.notif-grid .col-toast{display:none}++ .notif-grid .col-email,.notif-grid .col-toast,.notif-grid .col-digest{display:none}}/* Dashboard Showcase */@@@ -40,7 +40,7 @@ async function loadUser() {renderMerge();renderExport(); renderDeletion();if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');-- loadNotifPrefs();++ loadNotifPrefs(); loadQuietHours();loadLinked();loadSessions();loadBlocks();@@@ -69,18 +69,69 @@ async function loadNotifPrefs() {const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;p.email = emailOn ? 1 : 0;const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;++ const digest = ['hourly', 'daily', 'weekly'].includes(p.digest) ? p.digest : 'off';++ const opt = (v, l) => `<option value="${v}"${digest === v ? ' selected' : ''}>${l}</option>`;const html = `<div class="cat pref-row" title="${cat}">${label}</div><button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button><button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button><button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button><button class="toggle pref-row col-email ${p.email ? 'on' : 'off'}" data-cat="${cat}" data-field="email" onclick="togglePref(this)"></button>++ <select class="digest pref-row col-digest" data-cat="${cat}" data-was="${digest}" aria-label="${label}: how emails come" onchange="setDigest(this)">${opt('off', 'Each one')}${opt('hourly', 'Hourly')}${opt('daily', 'Daily')}${opt('weekly', 'Weekly')}</select>`;grid.insertAdjacentHTML('beforeend', html);}} catch (err) { console.error('Failed to load notif prefs:', err); }}++async function setDigest(sel) {++ const before = sel.dataset.was || 'off';++ try {++ await apiFetch('/api/notifications/preferences', { method: 'PUT', body: JSON.stringify({ category: sel.dataset.cat, digest: sel.value }) });++ sel.dataset.was = sel.value;++ } catch (err) { sel.value = before; }++}++++// Quiet hours: the person's window in their own time zone (Network keeps it; the bell and email follow it).++async function loadQuietHours() {++ if (isAnonSession()) return;++ const form = document.getElementById('quiet-form');++ if (!form || form.dataset.ready) return;++ const tzSel = document.getElementById('quiet-tz');++ const device = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();++ let zones = [];++ try { zones = Intl.supportedValuesOf('timeZone'); } catch (e) { zones = [device, 'UTC']; }++ if (!zones.includes('UTC')) zones.unshift('UTC');++ let q = null;++ try { q = await apiFetch('/api/notifications/quiet-hours'); } catch (e) { q = null; }++ const tz = q && q.enabled ? q.tz : device;++ if (!zones.includes(tz)) zones.unshift(tz);++ tzSel.textContent = '';++ for (const z of zones) { const o = document.createElement('option'); o.value = z; o.textContent = z.replace(/_/g, ' '); if (z === tz) o.selected = true; tzSel.appendChild(o); }++ document.getElementById('quiet-on').checked = !!(q && q.enabled);++ if (q && q.start) document.getElementById('quiet-start').value = q.start;++ if (q && q.end) document.getElementById('quiet-end').value = q.end;++ showQuietStatus(q);++ form.dataset.ready = '1';++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ const status = document.getElementById('quiet-status');++ status.textContent = 'Saving…';++ try {++ const saved = await apiFetch('/api/notifications/quiet-hours', { method: 'PUT', body: JSON.stringify({++ enabled: document.getElementById('quiet-on').checked, start: document.getElementById('quiet-start').value,++ end: document.getElementById('quiet-end').value, tz: tzSel.value }) });++ showQuietStatus(saved, 'Saved. ');++ } catch (err) { status.textContent = 'Not saved: ' + (err && err.message ? err.message : 'try again'); }++ });++}++function showQuietStatus(q, prefix = '') {++ const status = document.getElementById('quiet-status');++ if (!status) return;++ if (!q || !q.enabled) { status.textContent = prefix + 'Off: notices pop up whenever they arrive.'; return; }++ status.textContent = prefix + (q.in_quiet ? `Quiet now, until ${q.end} (${q.tz.replace(/_/g, ' ')}).` : `Quiet from ${q.start} to ${q.end} (${q.tz.replace(/_/g, ' ')}).`);++}++async function togglePref(btn) {const cat = btn.dataset.cat;const field = btn.dataset.field;@@@ -228,8 +228,23 @@<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Sound</div><div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-toast">Toast</div><div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-email">Email</div>++ <div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-digest">Emails</div><!-- Rows populated by JS --></div>++ <p class="muted" style="margin-top:12px;font-size:12px">Emails: one per notice, or gathered into an hourly, daily (around 8 in the morning) or weekly digest. Urgent notices about your account always come at once.</p>++ </div>++ <div class="card" id="quiet-card">++ <h3><span class="icon"><i class="fa-solid fa-moon"></i></span> Quiet hours</h3>++ <p class="muted" style="margin:0 0 14px;font-size:13px">During these hours no notice pops up, plays a sound or sends an email: they wait in the bell, and emails go out when the hours end. Urgent notices about your account still come through.</p>++ <form id="quiet-form" class="quiet-form">++ <label class="data-check"><input type="checkbox" id="quiet-on"> <span>Quiet hours on</span></label>++ <div class="quiet-times">++ <label>From <input type="time" id="quiet-start" value="22:00" step="900" required></label>++ <label>Until <input type="time" id="quiet-end" value="07:00" step="900" required></label>++ <label class="quiet-tz">Time zone <select id="quiet-tz"></select></label>++ </div>++ <div class="btn-row"><button type="submit" class="btn btn-primary">Save quiet hours</button><span id="quiet-status" class="muted" role="status"></span></div>++ </form></div></div>@@@ -180,6 +180,7 @@ async function networkPart(db, userId) {add('preferences.json', {display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,++ quiet_hours: (await rowsOf(db, 'notification_quiet_hours', 'user_id = ?', [u.id])).rows[0] || null,effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,@@@ -335,7 +336,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',-- 'notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',++ 'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes','ip_log', 'email_delivery_log', 'user_theme_history']) await del(t, `user_id IN ${inU}`, userIds);if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);// Notifications this person sent others stay, without their name or picture.@@@ -25,6 +25,7 @@ const urlRegistry = require('./url-registry');const { BRAND } = require('openvibe-shared/brand');const { NotificationService } = require('./notifications/notification-service');const { EmailService } = require('./notifications/email-service');++const { runDigests } = require('./notifications/digest');const createNotificationRoutes = require('./notifications/routes');const createAdminRoutes = require('./admin/routes');const createSetupRoutes = require('./setup/routes');@@@ -918,6 +919,9 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf// Process email queue every 2 minutestimers.push(setInterval(() => emailService.processQueue(notificationService).catch((e) => console.warn('[Email] queue failed:', e.message)), 2 * 60 * 1000));++ // Send due notification digests every 15 minutes (hourly/daily/weekly, never during quiet hours)++ timers.push(setInterval(() => runDigests(Date.now(), { db, notificationService, emailService }).catch((e) => console.warn('[Digest] run failed:', e.message)), 15 * 60 * 1000));++// Raw analytics retention (ADR-021) is scheduled by the PostgreSQL tracker itself: events older// than 30 days go, in bounded batches; hourly/daily rollups stay.nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// Notification digests — hourly/daily/weekly summaries.++// A category with a digest preference is never emailed one by one:++// this job groups its unread, undismissed notifications and sends one++// email when the period is due, then marks them emailed. Critical++// notifications never wait here (the queue emails them at once).++// Quiet hours always win: no digest is sent inside them.++// ═══════════════════════════════════════════════════════════════++++const { inQuiet, zoned, rowToConfig } = require('./quiet-hours');++++const DIGEST_PERIODS = new Set(['hourly', 'daily', 'weekly']);++const HOUR_MS = 60 * 60 * 1000;++const MORNING_MIN = 8 * 60; // 08:00 local; daily and weekly wait for it++const DAILY_GAP_MS = 23 * HOUR_MS;++const WEEKLY_GAP_MS = 6.5 * 24 * HOUR_MS;++const LIST_LIMIT = 20;++++/** A stored text timestamp (ov_now() or ISO) as milliseconds, or null. */++function parseStamp(value) {++ if (!value) return null;++ const text = String(value);++ const ms = Date.parse(text.includes('T') ? text : `${text.replace(' ', 'T')}Z`);++ return Number.isFinite(ms) ? ms : null;++}++++/** ov_now()'s shape: UTC 'YYYY-MM-DD HH:MM:SS'. */++function stamp(ms) {++ return new Date(ms).toISOString().slice(0, 19).replace('T', ' ');++}++++/** Whether `period` is due at `nowMs`, reading the last run in the person's own zone. */++function digestDue(period, lastSentAt, nowMs, tz) {++ const last = parseStamp(lastSentAt);++ if (period === 'hourly') return last == null || nowMs - last >= HOUR_MS;++ const local = zoned(nowMs, tz);++ if (local.minute < MORNING_MIN) return false; // not before 08:00 local++ if (period === 'daily') {++ if (last != null && nowMs - last < DAILY_GAP_MS) return false;++ // One a day: a run earlier the same local day is not repeated.++ return last == null || zoned(last, tz).day !== local.day;++ }++ if (period === 'weekly') return last == null || nowMs - last >= WEEKLY_GAP_MS;++ return false;++}++++/**++ * Run every due digest. `now` may be a Date, an ISO string or milliseconds (tests move the clock).++ * Dependencies are explicit so tests and the server share the same code:++ * runDigests(Date.now(), { db, notificationService, emailService })++ * Returns { sent, skipped }; one person's failure never stops the run.++ */++async function runDigests(now = Date.now(), { db, notificationService, emailService } = {}) {++ if (!db || !notificationService || !emailService) throw new TypeError('runDigests: db, notificationService and emailService are required');++ const nowMs = now instanceof Date ? now.getTime() : typeof now === 'string' ? Date.parse(now) : Number(now);++ if (!Number.isFinite(nowMs)) throw new TypeError('runDigests: invalid now');++++ await emailService.ready;++ if (emailService.isEnabled === false) return { sent: 0, skipped: 0 };++++ const rows = await db.prepare(`++ SELECT n.id, n.user_id, n.type, n.category, n.priority, n.title, n.message, n.service, n.url, n.created_at,++ u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at,++ p.digest AS digest_period,++ q.enabled AS quiet_enabled, q.start_min AS quiet_start, q.end_min AS quiet_end, q.tz AS quiet_tz,++ r.last_sent_at++ FROM notifications n++ JOIN notification_preferences p ON p.user_id = n.user_id AND p.category = n.category++ JOIN users u ON u.id = n.user_id++ LEFT JOIN notification_quiet_hours q ON q.user_id = n.user_id++ LEFT JOIN notification_digest_runs r ON r.user_id = n.user_id AND r.period = p.digest++ WHERE n.is_emailed = 0 AND n.is_dismissed = 0 AND n.is_read = 0++ AND p.enabled = 1 AND p.digest IN ('hourly', 'daily', 'weekly')++ AND n.priority != 'critical'++ ORDER BY n.user_id, n.created_at ASC, n.seq ASC++ `).all();++ if (!rows.length) return { sent: 0, skipped: 0 };++++ const users = new Map();++ for (const row of rows) {++ const key = String(row.user_id);++ let entry = users.get(key);++ if (!entry) {++ entry = {++ user: row,++ quiet: row.quiet_tz == null++ ? rowToConfig(null)++ : rowToConfig({ enabled: row.quiet_enabled, start_min: row.quiet_start, end_min: row.quiet_end, tz: row.quiet_tz }),++ periods: new Map(),++ lastSent: new Map(),++ };++ users.set(key, entry);++ }++ if (!entry.periods.has(row.digest_period)) entry.periods.set(row.digest_period, []);++ entry.periods.get(row.digest_period).push(row);++ entry.lastSent.set(row.digest_period, row.last_sent_at);++ }++++ const markEmailed = db.prepare('UPDATE notifications SET is_emailed = 1 WHERE id = ? AND is_emailed = 0');++ const upsertRun = db.prepare(`++ INSERT INTO notification_digest_runs (user_id, period, last_sent_at)++ VALUES (?, ?, ?)++ ON CONFLICT (user_id, period) DO UPDATE SET last_sent_at = excluded.last_sent_at++ `);++++ let sent = 0;++ let skipped = 0;++ for (const entry of users.values()) {++ const user = entry.user;++ try {++ // Quiet hours always win; critical items never reach this query.++ if (inQuiet(entry.quiet, nowMs)) { skipped++; continue; }++ // The same deliverability rules as the queue: a real address, not bouncing, verified.++ if (!user.email || user.email_bounced_at || !Number(user.email_verified)) { skipped++; continue; }++++ for (const [period, items] of entry.periods) {++ try {++ if (!digestDue(period, entry.lastSent.get(period), nowMs, entry.quiet.tz)) continue;++ const cap = notificationService.emailCaps ? await notificationService.emailCaps(user.user_id) : null;++ if (cap === 'user-cap' || cap === 'global-cap') { skipped++; continue; }++ const ok = await emailService.sendDigestEmail({++ to: user.email,++ username: user.display_name || user.username,++ userId: user.user_id,++ period,++ items: items.slice(0, LIST_LIMIT),++ total: items.length,++ });++ if (!ok) { skipped++; continue; }++ await db.tx(async () => {++ for (const item of items) await markEmailed.run(item.id);++ await upsertRun.run(user.user_id, period, stamp(nowMs));++ });++ sent++;++ } catch (err) {++ skipped++;++ console.warn(`[Digest] ${period} for user ${user.user_id} failed:`, err.message);++ }++ }++ } catch (err) {++ skipped++;++ console.warn(`[Digest] user ${user.user_id} failed:`, err.message);++ }++ }++ if (sent) console.log(`[Digest] sent ${sent} digest(s), skipped ${skipped}`);++ return { sent, skipped };++}++++module.exports = { runDigests, digestDue, DIGEST_PERIODS, parseStamp, stamp, LIST_LIMIT };@@@ -225,6 +225,28 @@ class EmailService {return sent;}++ /**++ * Send one digest email (hourly/daily/weekly summary).++ * @param {Object} opts - { to, username, userId, period, items, total }++ * @returns {Promise<boolean>} true on success++ */++ async sendDigestEmail({ to, username, userId = null, period = 'daily', items = [], total = items.length }) {++ const subject = `Your OpenVibe digest: ${total} new`;++ const htmlBody = this._buildDigestHtml({ username, items, total });++ const textBody = this._buildDigestText({ username, items, total });++ const sent = await this._sendEmail({++ to,++ subject,++ htmlBody,++ textBody,++ emailType: `notification_digest:${period}`,++ userId,++ metadata: { period, count: total },++ });++ if (sent) console.log(`[Email] Sent ${period} digest to ${to}: ${total} new`);++ return sent;++ }++/** Email-address verification link. */async sendVerificationEmail({ to, username, verifyUrl, expiresMinutes = 60 }) {const u = this._escapeHtml(username || 'there');@@@ -277,8 +299,15 @@ class EmailService {let skipped = 0;for (const notif of pending) {++ const digest = notifService.isDigest ? await notifService.isDigest(notif) : false;if (!(await notifService.shouldEmail(notif))) {-- await notifService.markEmailed(notif.id);++ // A digested notification stays pending for runDigests(); anything else will never email.++ if (!digest) await notifService.markEmailed(notif.id);++ continue;++ }++ // Quiet hours hold a normal email until the window ends; a critical one breaks through.++ if (notif.priority !== 'critical' && notifService.inQuietHours && await notifService.inQuietHours(notif.user_id)) {++ skipped++;continue;}const why = notifService.emailGuard ? await notifService.emailGuard(notif) : null;@@@ -299,7 +328,7 @@ class EmailService {});if (sent) await notifService.markEmailed(notif.id);}-- if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);++ if (skipped) console.log(`[Email] ${skipped} notification email(s) held back (quiet hours/stale/dup/caps)`);}// ─── Email Templates ───────────────────────────────────────@@@ -365,6 +394,29 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}].join('\n');}++ _buildDigestHtml({ username, items, total }) {++ const more = Math.max(0, Number(total) - items.length);++ const list = items.map((n) => `<li><strong>${this._escapeHtml(n.title)}</strong>`++ + `${n.service ? ` · ${this._escapeHtml(n.service)}` : ''}`++ + `${n.url ? ` · <a href="${this._escapeHtml(n.url)}">Open</a>` : ''}</li>`).join('');++ return `<p>Hey ${this._escapeHtml(username || 'there')},</p>++<p>Your OpenVibe digest: ${Number(total)} new</p>++<ul>${list}</ul>++${more ? `<p>and ${more} more</p>` : ''}++<p><a href="https://openvibe.network/notifications">Manage notification preferences</a></p>`;++ }++++ _buildDigestText({ username, items, total }) {++ const more = Math.max(0, Number(total) - items.length);++ const lines = [`Hey ${username || 'there'},`, '', `Your OpenVibe digest: ${Number(total)} new`, ''];++ for (const n of items) {++ lines.push(`${n.title}${n.service ? ` · ${n.service}` : ''}${n.url ? ` · ${n.url}` : ''}`);++ }++ if (more) lines.push('', `and ${more} more`);++ lines.push('', '---', 'Manage preferences: https://openvibe.network/notifications', 'OpenVibe · https://openvibe.network');++ return lines.join('\n');++ }++_buildPasswordResetHtml({ username, resetUrl, expiresMinutes }) {return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">@@@ -10,10 +10,20 @@ const { v4: uuidv4 } = require('uuid');const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');const contracts = require('openvibe-contracts');const eventRelay = require('../developer/event-relay');++const { inQuiet, parseTime, validTimeZone, rowToConfig, configToView } = require('./quiet-hours');++const { DIGEST_PERIODS } = require('./digest');// Staff and platform notices: never hidden by a block.const BLOCK_EXEMPT_CATEGORIES = new Set(['moderation', 'system', 'admin']);++/** A validation failure the routes answer 400 to. */++function badRequest(message) {++ const err = new Error(message);++ err.status = 400;++ err.code = 'notification.invalid';++ return err;++}++/*** network.notification.created (Contracts 0.61.0; ADR-005 amendment 2, roadmap WS-E task 3): every stored* notification is announced to its person over OpenVibe.Events, so the notification badge on any site@@@ -93,17 +103,30 @@ class NotificationService {this._getPrefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?');this._getPrefByCategory = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ? AND category = ?');this._upsertPref = db.prepare(`-- INSERT INTO notification_preferences (user_id, category, enabled, sound, toasts, email)-- VALUES (?, ?, ?, ?, ?, ?)-- ON CONFLICT(user_id, category) DO UPDATE SET enabled = ?, sound = ?, toasts = ?, email = ?++ INSERT INTO notification_preferences (user_id, category, enabled, sound, toasts, email, digest)++ VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT(user_id, category) DO UPDATE SET enabled = ?, sound = ?, toasts = ?, email = ?, digest = ?`);this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');++ this._getQuietHours = db.prepare('SELECT * FROM notification_quiet_hours WHERE user_id = ?');++ this._upsertQuietHours = db.prepare(`++ INSERT INTO notification_quiet_hours (user_id, enabled, start_min, end_min, tz, updated_at)++ VALUES (?, ?, ?, ?, ?, ov_now())++ ON CONFLICT(user_id) DO UPDATE SET enabled = excluded.enabled, start_min = excluded.start_min,++ end_min = excluded.end_min, tz = excluded.tz, updated_at = ov_now()++ `);++this._getPendingEmails = db.prepare(`SELECT n.*, u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at FROM notifications nJOIN users u ON u.id = n.user_idWHERE n.is_emailed = 0 AND n.is_dismissed = 0AND u.email IS NOT NULL AND u.email != ''++ -- Notifications waiting for a digest are the digest job's (runDigests): left in this queue they would fill++ -- its 200 slots and starve everyone else's emails.++ AND (n.priority = 'critical' OR NOT EXISTS (++ SELECT 1 FROM notification_preferences p++ WHERE p.user_id = n.user_id AND p.category = n.category AND p.digest IN ('hourly', 'daily', 'weekly')))ORDER BY n.created_at ASCLIMIT 200`);@@@ -195,17 +218,24 @@ class NotificationService {// Wake the relay after the commit (the outbox row committed with the notification).if (announced) { const live = eventRelay.outboxFor(this.db); if (live) live.kick(); }-- // Fire browser push notification (async, non-blocking); `silent` keeps it to the bell.-- if (!data.silent) try {-- const pushService = require('../push/push-service');-- pushService.sendPush(data.user_id, {-- title,-- message: data.message,-- icon,-- url: data.url,-- type: data.type,-- }).catch(() => {});-- } catch (_) { /* push module not available */ }++ // Fire browser push notification (async, non-blocking); `silent` keeps it to the bell. Quiet hours++ // hold it back like email, except for critical priority, which always breaks through.++ if (!data.silent) {++ let held = false;++ if (priority !== PRIORITY.CRITICAL) {++ try { held = await this.inQuietHours(data.user_id); } catch { /* no quiet window */ }++ }++ if (!held) try {++ const pushService = require('../push/push-service');++ pushService.sendPush(data.user_id, {++ title,++ message: data.message,++ icon,++ url: data.url,++ type: data.type,++ }).catch(() => {});++ } catch (_) { /* push module not available */ }++ }return { id, user_id: data.user_id, type: data.type, category, priority, title, message: data.message, icon, service: data.service, url: data.url, rich_content: data.rich_content, is_read: 0, created_at: createdAt };}@@@ -384,7 +414,7 @@ class NotificationService {// ─── Preferences ──────────────────────────────────────────async getPreferences(userId) {-- return await this._getPrefs.all(userId);++ return (await this._getPrefs.all(userId)).map((pref) => ({ ...pref, digest: pref.digest || 'off' }));}async setPreference(userId, category, updates = {}) {@@@ -393,12 +423,20 @@ class NotificationService {sound: 1,toasts: 1,email: null, // NULL = no explicit choice → shouldEmail() applies the defaults++ digest: null, // NULL = off};++ let digest = current.digest || null;++ if (updates.digest !== undefined) {++ if (updates.digest === null || updates.digest === 'off') digest = null;++ else if (DIGEST_PERIODS.has(updates.digest)) digest = updates.digest;++ else throw badRequest("digest must be 'off', 'hourly', 'daily' or 'weekly'");++ }const next = {enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,++ digest,};await this._upsertPref.run(userId,@@@ -407,10 +445,12 @@ class NotificationService {next.sound,next.toasts,next.email,++ next.digest,next.enabled,next.sound,next.toasts,next.email,++ next.digest,);}@@@ -418,6 +458,36 @@ class NotificationService {await this._deletePrefs.run(userId, category);}++ // ─── Quiet Hours ─────────────────────────────────────────++++ /** The API shape { enabled, start: 'HH:MM', end: 'HH:MM', tz }, with the 22:00–07:00 UTC default. */++ async getQuietHours(userId) {++ return configToView(rowToConfig(await this._getQuietHours.get(userId)));++ }++++ /** The numeric config inQuiet() reads (also gives the digest its zone when quiet hours are off). */++ async getQuietHoursConfig(userId) {++ return rowToConfig(await this._getQuietHours.get(userId));++ }++++ async inQuietHours(userId, at = Date.now()) {++ return inQuiet(await this.getQuietHoursConfig(userId), at);++ }++++ async setQuietHours(userId, { enabled, start, end, tz } = {}) {++ const row = await this._getQuietHours.get(userId);++ const current = rowToConfig(row);++ const startMin = start === undefined ? current.start_min : parseTime(start);++ const endMin = end === undefined ? current.end_min : parseTime(end);++ if (startMin === null) throw badRequest("start must be a time 'HH:MM'");++ if (endMin === null) throw badRequest("end must be a time 'HH:MM'");++ const zone = tz === undefined ? current.tz : tz;++ if (!validTimeZone(zone)) throw badRequest("tz must be an IANA time zone");++ const on = enabled === undefined ? (row ? current.enabled : true) : !(enabled === false || Number(enabled) === 0);++ await this._upsertQuietHours.run(userId, on ? 1 : 0, startMin, endMin, zone);++ return await this.getQuietHours(userId);++ }++// ─── Email Queue ──────────────────────────────────────────/**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */@@@ -435,6 +505,8 @@ class NotificationService {const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);// If user disabled the whole category, no emailif (pref && !pref.enabled) return false;++ // A digested category waits for its summary; critical still emails at once.++ if (pref && DIGEST_PERIODS.has(pref.digest) && notification.priority !== PRIORITY.CRITICAL) return false;// LOW priority never emailsif (notification.priority === PRIORITY.LOW) return false;const critical = notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);@@@ -450,12 +522,33 @@ class NotificationService {return critical;}++ /** Whether this notification belongs to a digest category (critical never waits for the digest). */++ async isDigest(notification) {++ if (notification.priority === PRIORITY.CRITICAL) return false;++ const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);++ return !!(pref && Number(pref.enabled) !== 0 && DIGEST_PERIODS.has(pref.digest));++ }++++ /**++ * The daily caps, shared by the queue and the digest: 'user-cap', 'global-cap' or null.++ * - user cap: site_settings email_user_daily_cap (default 30)++ * - global cap: site_settings email_daily_cap (default 2000)++ */++ async emailCaps(userId) {++ try {++ const userCap = parseInt(await this.db.getSetting?.('email_user_daily_cap'), 10) || 30;++ if (((await this._emailsSentToUserToday.get(userId))?.c || 0) >= userCap) return 'user-cap';++ const globalCap = parseInt(await this.db.getSetting?.('email_daily_cap'), 10) || 2000;++ if (((await this._emailsSentToday.get())?.c || 0) >= globalCap) return 'global-cap';++ } catch { /* guards are best-effort */ }++ return null;++ }++/*** Abuse/cost guards evaluated right before a send. Returns a reason string to skip, or null.* - stale: a go-live alert older than 2h is useless in an inbox* - dup: same streamer already emailed this user within the hour-- * - user cap / global cap: site_settings email_user_daily_cap (default 30),-- * email_daily_cap (default 2000)++ * - user cap / global cap (see emailCaps)*/async emailGuard(notification) {try {@@@ -464,10 +557,7 @@ class NotificationService {if (ageMs > 2 * 3600 * 1000) return 'stale';if (notification.sender_id != null && await this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';}-- const userCap = parseInt(await this.db.getSetting?.('email_user_daily_cap'), 10) || 30;-- if (((await this._emailsSentToUserToday.get(notification.user_id))?.c || 0) >= userCap) return 'user-cap';-- const globalCap = parseInt(await this.db.getSetting?.('email_daily_cap'), 10) || 2000;-- if (((await this._emailsSentToday.get())?.c || 0) >= globalCap) return 'global-cap';++ return await this.emailCaps(notification.user_id);} catch { /* guards are best-effort */ }return null;}nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// Quiet hours — a person's do-not-disturb window in their own zone.++// The window may cross midnight (22:00–07:00); start === end means none.++// Intl.DateTimeFormat does the zone arithmetic, so DST changes need no++// special case. An unknown or missing zone is read as UTC.++// ═══════════════════════════════════════════════════════════════++++const DEFAULT_QUIET = Object.freeze({ enabled: false, start_min: 22 * 60, end_min: 7 * 60, tz: 'UTC' });++++/** 'HH:MM' → minutes since midnight, or null when it is not a valid wall time. */++function parseTime(value) {++ const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value ?? ''));++ return m ? Number(m[1]) * 60 + Number(m[2]) : null;++}++++/** Minutes since midnight → 'HH:MM'. */++function formatTime(minutes) {++ const n = Number(minutes);++ if (!Number.isInteger(n) || n < 0 || n > 1439) return '00:00';++ return `${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;++}++++/** An IANA zone Intl accepts (UTC included); aliases such as US/Pacific are fine. */++function validTimeZone(tz) {++ if (typeof tz !== 'string' || !tz || tz.length > 100) return false;++ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }++}++++/** The person's wall clock at `at`: { minute: 0–1439, hour: 0–23, day: 'YYYY-MM-DD' }. */++function zoned(at, tz) {++ const zone = validTimeZone(tz) ? tz : 'UTC';++ const fmt = new Intl.DateTimeFormat('en-CA', {++ timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',++ hour: '2-digit', minute: '2-digit', hourCycle: 'h23',++ });++ const parts = {};++ for (const p of fmt.formatToParts(at instanceof Date ? at : new Date(at))) parts[p.type] = p.value;++ let hour = Number(parts.hour);++ if (hour === 24) hour = 0; // some ICU versions render midnight as 24++ return { minute: hour * 60 + Number(parts.minute), hour, day: `${parts.year}-${parts.month}-${parts.day}` };++}++++/** A stored row (or nothing) as the numeric config inQuiet() reads. */++function rowToConfig(row) {++ if (!row) return { ...DEFAULT_QUIET };++ return {++ enabled: !!Number(row.enabled),++ start_min: Number(row.start_min),++ end_min: Number(row.end_min),++ tz: row.tz || 'UTC',++ };++}++++/** The API shape: { enabled, start: 'HH:MM', end: 'HH:MM', tz }. */++function configToView(config) {++ const c = config || DEFAULT_QUIET;++ return {++ enabled: !!c.enabled,++ start: formatTime(c.start_min),++ end: formatTime(c.end_min),++ tz: c.tz || 'UTC',++ };++}++++/**++ * Whether `at` falls in this quiet window. Cross-midnight windows wrap; start === end is no window.++ * @param {{ start_min: number, end_min: number, tz: string, enabled?: boolean|number }} config++ * @param {number|Date} [at]++ */++function inQuiet(config = {}, at = Date.now()) {++ const off = config.enabled === false || Number(config.enabled) === 0;++ if (off) return false;++ const start = Number(config.start_min);++ const end = Number(config.end_min);++ if (!Number.isInteger(start) || !Number.isInteger(end)) return false;++ if (start < 0 || start > 1439 || end < 0 || end > 1439) return false;++ if (start === end) return false;++ const now = zoned(at, config.tz).minute;++ return start < end ? (now >= start && now < end) : (now >= start || now < end);++}++++module.exports = {++ DEFAULT_QUIET, parseTime, formatTime, validTimeZone, zoned, rowToConfig, configToView, inQuiet,++};@@@ -117,6 +117,34 @@ module.exports = function createNotificationRoutes(db, notificationService, requ}});++ // ─── GET /api/notifications/quiet-hours ────────────────++ // The person's do-not-disturb window (default 22:00–07:00 UTC, off) and whether it is now.++ router.get('/quiet-hours', requireAuth, async (req, res) => {++ try {++ const quiet = await notificationService.getQuietHours(req.user.id);++ const in_quiet = await notificationService.inQuietHours(req.user.id);++ res.json({ ok: true, ...quiet, in_quiet });++ } catch (err) {++ console.error('[Notifications] GET /quiet-hours error:', err);++ res.status(500).json({ ok: false, error: 'Failed to get quiet hours' });++ }++ });++++ // ─── PUT /api/notifications/quiet-hours ────────────────++ // Body: { enabled, start: 'HH:MM', end: 'HH:MM', tz }++ router.put('/quiet-hours', requireAuth, async (req, res) => {++ try {++ const { enabled, start, end, tz } = req.body || {};++ const quiet = await notificationService.setQuietHours(req.user.id, { enabled, start, end, tz });++ const in_quiet = await notificationService.inQuietHours(req.user.id);++ res.json({ ok: true, ...quiet, in_quiet });++ } catch (err) {++ if (err.status === 400) return res.status(400).json({ ok: false, error: err.message });++ console.error('[Notifications] PUT /quiet-hours error:', err);++ res.status(500).json({ ok: false, error: 'Failed to save quiet hours' });++ }++ });++// ─── GET /api/notifications/preferences ────────────────router.get('/preferences', requireAuth, async (req, res) => {try {@@@ -128,19 +156,21 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── PUT /api/notifications/preferences ────────────────-- // Body: { category, enabled, sound, toasts, email }++ // Body: { category, enabled, sound, toasts, email, digest }router.put('/preferences', requireAuth, async (req, res) => {try {-- const { category, enabled, sound, toasts, email } = req.body;++ const { category, enabled, sound, toasts, email, digest } = req.body;if (!category) return res.status(400).json({ ok: false, error: 'Category required' });await notificationService.setPreference(req.user.id, category, {enabled,sound,toasts,email,++ digest,});res.json({ ok: true });} catch (err) {++ if (err.status === 400) return res.status(400).json({ ok: false, error: err.message });res.status(500).json({ ok: false, error: 'Failed to update preferences' });}});@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,242 @@++'use strict';++// Quiet hours and notification digests (migration 0027):++// - inQuiet: cross-midnight and same-day windows, start === end, unknown zones, and a DST-change date++// in America/Los_Angeles (the wall clock, via Intl, not a fixed UTC offset);++// - the API: the default window, a saved window, bad times/zones as 400, and the digest preference;++// - email: a normal notification waits out quiet hours, a critical one breaks through, and the held++// notification goes out on the first pass after the window;++// - digests: a digested category is not emailed one by one, one hourly email lists and marks the items,++// a second run inside the hour sends nothing, and a daily digest waits for 08:00 local.++// node --import ./test/helpers/pg-preload.mjs test/notification-quiet-digest.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 { EmailService } = require('../server/notifications/email-service');++const createNotificationRoutes = require('../server/notifications/routes');++const quiet = require('../server/notifications/quiet-hours');++const { runDigests, digestDue } = require('../server/notifications/digest');++const { signToken } = require('../server/auth/routes');++++const DAY = 24 * 60 * 60 * 1000;++++(async () => {++const db = getDb();++const log = console.log; console.log = () => {};++process.env.RESEND_API_KEY = process.env.RESEND_API_KEY || 're_test_quiet_digest';++await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('email_enabled', 'true', 'boolean') ON CONFLICT (key) DO UPDATE SET value = 'true', type = 'boolean'").run();++await db.prepare(`INSERT INTO users (id, username, email, password_hash, email_verified) VALUES++ (1, 'qa', '[email protected]', 'x', 1),++ (2, 'rita', '[email protected]', 'x', 1),++ (3, 'dora', '[email protected]', 'x', 1),++ (4, 'wanda', '[email protected]', 'x', 1)`).run();++++const notifications = new NotificationService(db);++const emailService = new EmailService(db);++await emailService.ready;++assert.strictEqual(emailService.isEnabled, true, 'the email service is enabled for the test');++const sent = [];++emailService._sendEmail = async (opts) => { sent.push(opts); return true; };++++// ── 1. inQuiet ────────────────────────────────────────────────────────────────────────────────++const cross = { enabled: true, start_min: 22 * 60, end_min: 7 * 60, tz: 'UTC' };++assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-15T23:30:00Z')), true, '23:30 is inside 22:00–07:00');++assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-16T06:59:00Z')), true, '06:59 is still inside the window');++assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-16T07:00:00Z')), false, '07:00 ends it');++assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-15T12:00:00Z')), false, 'midday is outside it');++++const sameDay = { enabled: true, start_min: 9 * 60, end_min: 17 * 60, tz: 'UTC' };++assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T09:00:00Z')), true, '09:00 starts a same-day window');++assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T16:59:00Z')), true, '16:59 is inside it');++assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T17:00:00Z')), false, '17:00 ends it');++assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T08:59:00Z')), false, '08:59 is before it');++assert.strictEqual(quiet.inQuiet({ ...sameDay, enabled: false }, Date.parse('2026-01-15T12:00:00Z')), false, 'disabled means no window');++assert.strictEqual(quiet.inQuiet({ ...sameDay, start_min: 600, end_min: 600 }, Date.parse('2026-01-15T10:00:00Z')), false, 'start === end is no window');++assert.strictEqual(quiet.inQuiet({ ...cross, tz: 'Not/AZone' }, Date.parse('2026-01-15T23:00:00Z')), true, 'an unknown zone is read as UTC');++++// America/Los_Angeles, 2026-03-08: 02:00 PST jumps to 03:00 PDT. A 01:00–04:00 wall-clock window has++// to follow the clock, not a fixed -8 offset.++const LA = 'America/Los_Angeles';++const laWindow = { enabled: true, start_min: 60, end_min: 4 * 60, tz: LA };++assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T08:30:00Z')), false, '00:30 PST, before the window');++assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T09:30:00Z')), true, '01:30 PST, inside it');++assert.strictEqual(quiet.zoned(Date.parse('2026-03-08T10:00:00Z'), LA).hour, 3, '10:00Z is 03:00 PDT after the jump');++assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T10:30:00Z')), true, '03:30 PDT, still inside it');++assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T11:30:00Z')), false, '04:30 PDT, after it');++++// Weekly morning rule (the daily and weekly share it).++assert.strictEqual(digestDue('weekly', null, Date.parse('2026-01-15T07:59:00Z'), 'UTC'), false, 'weekly waits for 08:00');++assert.strictEqual(digestDue('weekly', null, Date.parse('2026-01-15T08:00:00Z'), 'UTC'), true, 'weekly may start at 08:00');++assert.strictEqual(digestDue('weekly', '2026-01-08 20:00:00', Date.parse('2026-01-15T09:00:00Z'), 'UTC'), true, '6.5 days later');++assert.strictEqual(digestDue('weekly', '2026-01-09 08:00:00', Date.parse('2026-01-15T09:00:00Z'), 'UTC'), false, 'six days is too soon');++++// ── 2. The API ────────────────────────────────────────────────────────────────────────────────++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++const qa = await db.prepare('SELECT * FROM users WHERE id = 1').get();++const token = signToken(qa, 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 call = (method, path, body) => fetch(base + path, {++ method,++ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },++ body: body === undefined ? undefined : JSON.stringify(body),++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));++++try {++ let r = await call('GET', '/api/notifications/quiet-hours');++ assert.deepStrictEqual([r.status, r.body.enabled, r.body.start, r.body.end, r.body.tz, typeof r.body.in_quiet],++ [200, false, '22:00', '07:00', 'UTC', 'boolean'], 'the unset window is 22:00–07:00 UTC, off');++++ r = await call('PUT', '/api/notifications/quiet-hours', { enabled: true, start: '22:00', end: '07:00', tz: 'America/Los_Angeles' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual([r.body.enabled, r.body.start, r.body.end, r.body.tz], [true, '22:00', '07:00', 'America/Los_Angeles']);++ r = await call('GET', '/api/notifications/quiet-hours');++ assert.deepStrictEqual([r.body.enabled, r.body.start, r.body.end, r.body.tz], [true, '22:00', '07:00', 'America/Los_Angeles'], 'the saved window reads back');++ assert.strictEqual(typeof r.body.in_quiet, 'boolean');++++ r = await call('PUT', '/api/notifications/quiet-hours', { enabled: true, start: '25:00', end: '07:00', tz: 'UTC' });++ assert.strictEqual(r.status, 400, 'a bad time is refused');++ r = await call('PUT', '/api/notifications/quiet-hours', { enabled: true, start: '22:00', end: '07:00', tz: 'Mars/Olympus' });++ assert.strictEqual(r.status, 400, 'an unknown zone is refused');++ r = await call('GET', '/api/notifications/quiet-hours');++ assert.strictEqual(r.body.tz, 'America/Los_Angeles', 'a refused update changes nothing');++++ r = await call('PUT', '/api/notifications/preferences', { category: 'social', digest: 'daily' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ r = await call('GET', '/api/notifications/preferences');++ let social = r.body.preferences.find((p) => p.category === 'social');++ assert.strictEqual(social.digest, 'daily', 'the digest preference round-trips');++++ r = await call('PUT', '/api/notifications/preferences', { category: 'social', digest: 'off' });++ assert.strictEqual(r.status, 200);++ r = await call('GET', '/api/notifications/preferences');++ social = r.body.preferences.find((p) => p.category === 'social');++ assert.strictEqual(social.digest, 'off', 'NULL reads back as off');++ const raw = await db.prepare("SELECT digest FROM notification_preferences WHERE user_id = 1 AND category = 'social'").get();++ assert.strictEqual(raw.digest, null, "'off' is stored as NULL");++++ r = await call('PUT', '/api/notifications/preferences', { category: 'social', digest: 'sometimes' });++ assert.strictEqual(r.status, 400, 'an unknown digest is refused');++} finally {++ await new Promise((r) => server.close(r));++}++++// ── 3. Email quiet hours ──────────────────────────────────────────────────────────────────────++const rita = await db.prepare('SELECT * FROM users WHERE id = 2').get();++await notifications.setQuietHours(rita.id, { enabled: true, start: '22:00', end: '07:00', tz: 'UTC' });++// A normal go-live alert (normally emailed because STREAM_LIVE is a default email type) and a critical++// security notice, both created during the window.++const live = await notifications.create({ user_id: rita.id, type: 'STREAM_LIVE', category: 'stream', priority: 'high', title: 'Rita is live', sender_id: 99, sender_name: 'Rita', service: 'live', url: 'https://openvibe.live/rita' });++const critical = await notifications.create({ user_id: rita.id, type: 'GENERIC', category: 'system', priority: 'critical', title: 'Security alert', message: 'A new sign-in', service: 'network' });++assert.ok(live && critical);++++const realNow = Date.now;++let ritaQuiet = Date.parse('2026-01-15T23:00:00Z');++Date.now = () => ritaQuiet;++try {++ sent.length = 0;++ await emailService.processQueue(notifications);++ assert.strictEqual(sent.length, 1, 'only the critical notification is emailed during quiet hours');++ assert.match(sent[0].subject, /Security alert/);++ assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(live.id)).is_emailed, 0, 'the normal one stays pending');++ assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(critical.id)).is_emailed, 1, 'critical breaks through');++++ // The window ends: the queue picks the held notification up on its next pass.++ ritaQuiet = Date.parse('2026-01-16T07:00:00Z');++ sent.length = 0;++ await emailService.processQueue(notifications);++ assert.strictEqual(sent.length, 1, 'the held notification is emailed after the window');++ assert.match(sent[0].subject, /Rita is live/);++ assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(live.id)).is_emailed, 1, 'and is marked emailed');++} finally {++ Date.now = realNow;++}++++// ── 4. Digests ────────────────────────────────────────────────────────────────────────────────++// Hourly: the category is never emailed one by one; runDigests sends one summary and marks the item.++const dora = await db.prepare('SELECT * FROM users WHERE id = 3').get();++await notifications.setPreference(dora.id, 'social', { digest: 'hourly' });++const d1 = await notifications.create({ user_id: dora.id, type: 'FOLLOW', category: 'social', priority: 'normal', title: 'New follower', message: 'someone followed you', service: 'live', url: 'https://openvibe.live/@someone' });++assert.ok(d1);++sent.length = 0;++await emailService.processQueue(notifications);++assert.strictEqual(sent.length, 0, 'an hourly-digest category is not emailed one by one');++assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d1.id)).is_emailed, 0, 'it is left for the digest');++++let now = Date.parse('2026-01-15T12:00:00Z');++let result = await runDigests(now, { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 1, 'the first hourly digest is due');++assert.strictEqual(sent.length, 1);++assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');++assert.match(sent[0].textBody, /New follower/);++assert.match(sent[0].textBody, /live/);++assert.match(sent[0].textBody, /https:\/\/openvibe\.live\/@someone/);++assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d1.id)).is_emailed, 1, 'the item is marked emailed');++let run = await db.prepare("SELECT * FROM notification_digest_runs WHERE user_id = ? AND period = 'hourly'").get(dora.id);++assert.strictEqual(run.last_sent_at, '2026-01-15 12:00:00', 'the run is recorded');++++const d2 = await notifications.create({ user_id: dora.id, type: 'FOLLOW', category: 'social', priority: 'normal', title: 'Another follower', service: 'live', url: 'https://openvibe.live/@another' });++sent.length = 0;++result = await runDigests(now + 30 * 60 * 1000, { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 0, 'a second run inside the hour sends nothing');++assert.strictEqual(sent.length, 0);++assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d2.id)).is_emailed, 0, 'the new item waits for the next hour');++++sent.length = 0;++result = await runDigests(now + 61 * 60 * 1000, { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 1, 'an hour after the last run it is due again');++assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');++assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d2.id)).is_emailed, 1);++++// Daily: 08:00 in the person's quiet-hours zone (08:00 PST is 16:00Z), never before it.++const wanda = await db.prepare('SELECT * FROM users WHERE id = 4').get();++await notifications.setQuietHours(wanda.id, { enabled: false, start: '22:00', end: '07:00', tz: LA });++await notifications.setPreference(wanda.id, 'economy', { digest: 'daily' });++const daily = await notifications.create({ user_id: wanda.id, type: 'COINS_RECEIVED', category: 'economy', priority: 'normal', title: 'Coins received', service: 'network' });++assert.ok(daily);++++sent.length = 0;++result = await runDigests(Date.parse('2026-01-15T15:59:00Z'), { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 0, 'daily waits for 08:00 local');++assert.strictEqual(sent.length, 0);++assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(daily.id)).is_emailed, 0);++++result = await runDigests(Date.parse('2026-01-15T16:00:00Z'), { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 1, '08:00 local makes the daily digest due');++assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');++assert.strictEqual(sent[0].emailType, 'notification_digest:daily');++assert.match(sent[0].textBody, /Coins received/);++assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(daily.id)).is_emailed, 1);++run = await db.prepare("SELECT * FROM notification_digest_runs WHERE user_id = ? AND period = 'daily'").get(wanda.id);++assert.strictEqual(run.last_sent_at, '2026-01-15 16:00:00');++++// The next day before 08:00 is still not due; at 08:00 it is.++await notifications.create({ user_id: wanda.id, type: 'COINS_RECEIVED', category: 'economy', priority: 'normal', title: 'More coins', service: 'network' });++sent.length = 0;++result = await runDigests(Date.parse('2026-01-16T15:59:00Z'), { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 0, 'the next day before 08:00 local is not due');++result = await runDigests(Date.parse('2026-01-16T16:00:00Z'), { db, notificationService: notifications, emailService });++assert.strictEqual(result.sent, 1, 'the next morning at 08:00 local is due');++assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');++++// A digest is never sent inside quiet hours.++await notifications.setQuietHours(wanda.id, { enabled: true, start: '07:00', end: '10:00', tz: LA });++await notifications.create({ user_id: wanda.id, type: 'COINS_RECEIVED', category: 'economy', priority: 'normal', title: 'Held coin', service: 'network' });++sent.length = 0;++result = await runDigests(Date.parse('2026-01-17T16:30:00Z'), { db, notificationService: notifications, emailService }); // 08:30 PST++assert.strictEqual(result.sent, 0, 'quiet hours hold the digest');++assert.strictEqual(sent.length, 0);++++console.log = log;++console.log('quiet hours and notification digests: ok');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -244,6 +244,7 @@ sudo apt install certbot python3-certbot-dns-cloudflare
@@ -244,6 +244,7 @@ sudo apt install certbot python3-certbot-dns-cloudflare
2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content
2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content
3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below)
3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below)
4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).
4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).
+5. **Quiet hours and digests** can hold normal email and browser push until a window ends, or replace per-alert email with an hourly/daily/weekly summary; critical notifications always break through.
### Priorities
### Priorities
@@ -260,6 +261,12 @@ sudo apt install certbot python3-certbot-dns-cloudflare
@@ -260,6 +261,12 @@ sudo apt install certbot python3-certbot-dns-cloudflare
Users can toggle each category's enabled/sound/toast/email preferences at `openvibe.network/my` → Notifications tab.
Users can toggle each category's enabled/sound/toast/email preferences at `openvibe.network/my` → Notifications tab.
+### Quiet hours and digests
+
+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.
+
### Cross-Service Push
### Cross-Service Push
Other services push notifications to openvibe.network via the internal API:
Other services push notifications to openvibe.network via the internal API:
@@ -361,7 +368,7 @@ It then opens `${stream_url}?topics=network.notification.*&ticket=…[&last_even
@@ -361,7 +368,7 @@ It then opens `${stream_url}?topics=network.notification.*&ticket=…[&last_even
- **Refusals:** a guest gets 403 `realtime.guest`. `REALTIME_TICKETS=off` in `/etc/openvibe/network.env` answers 503 `realtime.disabled`, and every badge stays on polling. `OV_EVENTS_PUBLIC_URL` overrides the stream origin (default `https://openvibe.events`).
- **Refusals:** a guest gets 403 `realtime.guest`. `REALTIME_TICKETS=off` in `/etc/openvibe/network.env` answers 503 `realtime.disabled`, and every badge stays on polling. `OV_EVENTS_PUBLIC_URL` overrides the stream origin (default `https://openvibe.events`).
- **Client:** openvibe-shared `notification-live.js`, on where a site sets `notificationsRealtime: true`.
- **Client:** openvibe-shared `notification-live.js`, on where a site sets `notificationsRealtime: true`.
-A digest (a daily or weekly summary instead of one alert per notification) is designed in [docs/notification-digest.md](docs/notification-digest.md) for a later release.
+Quiet hours and per-category digests are built (migration `0027_notification_quiet_digest.sql`, `server/notifications/quiet-hours.js` and `digest.js`); the design note is [docs/notification-digest.md](docs/notification-digest.md).
---
---
@@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
-# Notification digests: design (later release)
+# Notification digests: design
-Roadmap WS-E task 3 asks for a digest design now and the digest itself in a later release. Network owns digests (ADR-020: Network keeps addressing, preferences, digests, read state and the store). Nothing here is built yet.
+> Built: migration `0027_notification_quiet_digest.sql`, `server/notifications/quiet-hours.js` and `server/notifications/digest.js` implement the per-category `digest` column, quiet hours and the 15-minute runner. This note records the original design; the `delivery` column, `digest_pending` and the `DIGEST` inbox notification below were not part of this slice.
+
+Roadmap WS-E task 3 asks for a digest design now and the digest itself in a later release. Network owns digests (ADR-020: Network keeps addressing, preferences, digests, read state and the store).
## Problem
## Problem
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- Quiet hours and email digests: a do-not-disturb window in each person's own zone, plus a per-category hourly/daily/weekly summary; additive, an older release never reads these.
+CREATE TABLE IF NOT EXISTS notification_quiet_hours (
+ user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ enabled bigint NOT NULL DEFAULT 1,
+ start_min integer NOT NULL CHECK (start_min BETWEEN 0 AND 1439),
+ end_min integer NOT NULL CHECK (end_min BETWEEN 0 AND 1439),
+ tz text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" DEFAULT ov_now()
+);
+
+ALTER TABLE notification_preferences ADD COLUMN IF NOT EXISTS digest text COLLATE "C" CHECK (digest IN ('off', 'hourly', 'daily', 'weekly'));
+
+CREATE TABLE IF NOT EXISTS notification_digest_runs (
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ period text COLLATE "C" NOT NULL,
+ last_sent_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (user_id, period)
+);
@@ -121,7 +121,15 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -121,7 +121,15 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.account-item .actions{margin-left:auto;display:flex;gap:6px}
.account-item .actions{margin-left:auto;display:flex;gap:6px}
/* Notification pref grid */
/* Notification pref grid */
-.notif-grid{display:grid;grid-template-columns:1fr auto auto auto auto;gap:8px;align-items:center;font-size:12px}
+.notif-grid{display:grid;grid-template-columns:1fr auto auto auto auto auto;gap:8px;align-items:center;font-size:12px}
+.notif-grid select.digest{min-height:30px;padding:2px 6px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit;font-size:12px}
+.quiet-form{display:grid;gap:14px}
+.quiet-times{display:flex;flex-wrap:wrap;gap:10px 16px}
+.quiet-times label{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:600;color:var(--text-muted)}
+.quiet-times input,.quiet-times select{min-height:40px;padding:6px 10px;border-radius:10px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit;font-size:14px}
+.quiet-times .quiet-tz{flex:1 1 220px}
+.quiet-times .quiet-tz select{width:100%}
+.quiet-form .btn-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
.notif-grid .cat{font-weight:600;text-transform:capitalize}
.notif-grid .cat{font-weight:600;text-transform:capitalize}
.notif-grid .toggle{width:36px;height:20px;border-radius:10px;border:none;cursor:pointer;position:relative;transition:background .2s}
.notif-grid .toggle{width:36px;height:20px;border-radius:10px;border:none;cursor:pointer;position:relative;transition:background .2s}
.notif-grid .toggle::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s}
.notif-grid .toggle::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s}
@@ -215,7 +223,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -215,7 +223,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
.page-header{flex-direction:column;text-align:center}
.page-header{flex-direction:column;text-align:center}
.page-header .info .badges{justify-content:center}
.page-header .info .badges{justify-content:center}
.notif-grid{grid-template-columns:1fr auto auto}
.notif-grid{grid-template-columns:1fr auto auto}
- .notif-grid .col-email,.notif-grid .col-toast{display:none}
+ .notif-grid .col-email,.notif-grid .col-toast,.notif-grid .col-digest{display:none}
}
}
/* Dashboard Showcase */
/* Dashboard Showcase */
@@ -40,7 +40,7 @@ async function loadUser() {
@@ -40,7 +40,7 @@ async function loadUser() {
renderMerge();
renderMerge();
renderExport(); renderDeletion();
renderExport(); renderDeletion();
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
- loadNotifPrefs();
+ loadNotifPrefs(); loadQuietHours();
loadLinked();
loadLinked();
loadSessions();
loadSessions();
loadBlocks();
loadBlocks();
@@ -69,18 +69,69 @@ async function loadNotifPrefs() {
@@ -69,18 +69,69 @@ async function loadNotifPrefs() {
const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;
const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;
p.email = emailOn ? 1 : 0;
p.email = emailOn ? 1 : 0;
const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;
const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;
+ const digest = ['hourly', 'daily', 'weekly'].includes(p.digest) ? p.digest : 'off';
+ const opt = (v, l) => `<option value="${v}"${digest === v ? ' selected' : ''}>${l}</option>`;
const html = `
const html = `
<div class="cat pref-row" title="${cat}">${label}</div>
<div class="cat pref-row" title="${cat}">${label}</div>
<button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
<button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
<button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
<button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
<button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
<button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
<button class="toggle pref-row col-email ${p.email ? 'on' : 'off'}" data-cat="${cat}" data-field="email" onclick="togglePref(this)"></button>
<button class="toggle pref-row col-email ${p.email ? 'on' : 'off'}" data-cat="${cat}" data-field="email" onclick="togglePref(this)"></button>
+ <select class="digest pref-row col-digest" data-cat="${cat}" data-was="${digest}" aria-label="${label}: how emails come" onchange="setDigest(this)">${opt('off', 'Each one')}${opt('hourly', 'Hourly')}${opt('daily', 'Daily')}${opt('weekly', 'Weekly')}</select>
`;
`;
grid.insertAdjacentHTML('beforeend', html);
grid.insertAdjacentHTML('beforeend', html);
}
}
} catch (err) { console.error('Failed to load notif prefs:', err); }
} catch (err) { console.error('Failed to load notif prefs:', err); }
}
}
+async function setDigest(sel) {
+ const before = sel.dataset.was || 'off';
+ try {
+ await apiFetch('/api/notifications/preferences', { method: 'PUT', body: JSON.stringify({ category: sel.dataset.cat, digest: sel.value }) });
+ sel.dataset.was = sel.value;
+ } catch (err) { sel.value = before; }
+}
+
+// Quiet hours: the person's window in their own time zone (Network keeps it; the bell and email follow it).
+async function loadQuietHours() {
+ if (isAnonSession()) return;
+ const form = document.getElementById('quiet-form');
+ if (!form || form.dataset.ready) return;
+ const tzSel = document.getElementById('quiet-tz');
+ const device = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();
+ let zones = [];
+ try { zones = Intl.supportedValuesOf('timeZone'); } catch (e) { zones = [device, 'UTC']; }
+ if (!zones.includes('UTC')) zones.unshift('UTC');
+ let q = null;
+ try { q = await apiFetch('/api/notifications/quiet-hours'); } catch (e) { q = null; }
+ const tz = q && q.enabled ? q.tz : device;
+ if (!zones.includes(tz)) zones.unshift(tz);
+ tzSel.textContent = '';
+ for (const z of zones) { const o = document.createElement('option'); o.value = z; o.textContent = z.replace(/_/g, ' '); if (z === tz) o.selected = true; tzSel.appendChild(o); }
+ document.getElementById('quiet-on').checked = !!(q && q.enabled);
+ if (q && q.start) document.getElementById('quiet-start').value = q.start;
+ if (q && q.end) document.getElementById('quiet-end').value = q.end;
+ showQuietStatus(q);
+ form.dataset.ready = '1';
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const status = document.getElementById('quiet-status');
+ status.textContent = 'Saving…';
+ try {
+ const saved = await apiFetch('/api/notifications/quiet-hours', { method: 'PUT', body: JSON.stringify({
+ enabled: document.getElementById('quiet-on').checked, start: document.getElementById('quiet-start').value,
+ end: document.getElementById('quiet-end').value, tz: tzSel.value }) });
+ showQuietStatus(saved, 'Saved. ');
+ } catch (err) { status.textContent = 'Not saved: ' + (err && err.message ? err.message : 'try again'); }
+ });
+}
+function showQuietStatus(q, prefix = '') {
+ const status = document.getElementById('quiet-status');
+ if (!status) return;
+ if (!q || !q.enabled) { status.textContent = prefix + 'Off: notices pop up whenever they arrive.'; return; }
+ status.textContent = prefix + (q.in_quiet ? `Quiet now, until ${q.end} (${q.tz.replace(/_/g, ' ')}).` : `Quiet from ${q.start} to ${q.end} (${q.tz.replace(/_/g, ' ')}).`);
+}
+
async function togglePref(btn) {
async function togglePref(btn) {
const cat = btn.dataset.cat;
const cat = btn.dataset.cat;
const field = btn.dataset.field;
const field = btn.dataset.field;
@@ -228,8 +228,23 @@
@@ -228,8 +228,23 @@
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Sound</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Sound</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-toast">Toast</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-toast">Toast</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-email">Email</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-email">Email</div>
+ <div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-digest">Emails</div>
<!-- Rows populated by JS -->
<!-- Rows populated by JS -->
</div>
</div>
+ <p class="muted" style="margin-top:12px;font-size:12px">Emails: one per notice, or gathered into an hourly, daily (around 8 in the morning) or weekly digest. Urgent notices about your account always come at once.</p>
+ </div>
+ <div class="card" id="quiet-card">
+ <h3><span class="icon"><i class="fa-solid fa-moon"></i></span> Quiet hours</h3>
+ <p class="muted" style="margin:0 0 14px;font-size:13px">During these hours no notice pops up, plays a sound or sends an email: they wait in the bell, and emails go out when the hours end. Urgent notices about your account still come through.</p>
+ <form id="quiet-form" class="quiet-form">
+ <label class="data-check"><input type="checkbox" id="quiet-on"> <span>Quiet hours on</span></label>
+ <div class="quiet-times">
+ <label>From <input type="time" id="quiet-start" value="22:00" step="900" required></label>
+ <label>Until <input type="time" id="quiet-end" value="07:00" step="900" required></label>
+ <label class="quiet-tz">Time zone <select id="quiet-tz"></select></label>
+ </div>
+ <div class="btn-row"><button type="submit" class="btn btn-primary">Save quiet hours</button><span id="quiet-status" class="muted" role="status"></span></div>
+ </form>
</div>
</div>
</div>
</div>
@@ -180,6 +180,7 @@ async function networkPart(db, userId) {
@@ -180,6 +180,7 @@ async function networkPart(db, userId) {
add('preferences.json', {
add('preferences.json', {
display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,
display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,
notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
+ quiet_hours: (await rowsOf(db, 'notification_quiet_hours', 'user_id = ?', [u.id])).rows[0] || null,
effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,
push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,
theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,
theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,
@@ -335,7 +336,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -335,7 +336,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };
const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
- 'notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
+ 'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'ip_log', 'email_delivery_log', 'user_theme_history']) await del(t, `user_id IN ${inU}`, userIds);
'ip_log', 'email_delivery_log', 'user_theme_history']) await del(t, `user_id IN ${inU}`, userIds);
if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
// Notifications this person sent others stay, without their name or picture.
// Notifications this person sent others stay, without their name or picture.
@@ -25,6 +25,7 @@ const urlRegistry = require('./url-registry');
@@ -25,6 +25,7 @@ const urlRegistry = require('./url-registry');
const { BRAND } = require('openvibe-shared/brand');
const { BRAND } = require('openvibe-shared/brand');
const { NotificationService } = require('./notifications/notification-service');
const { NotificationService } = require('./notifications/notification-service');
const { EmailService } = require('./notifications/email-service');
const { EmailService } = require('./notifications/email-service');
+const { runDigests } = require('./notifications/digest');
const createNotificationRoutes = require('./notifications/routes');
const createNotificationRoutes = require('./notifications/routes');
const createAdminRoutes = require('./admin/routes');
const createAdminRoutes = require('./admin/routes');
const createSetupRoutes = require('./setup/routes');
const createSetupRoutes = require('./setup/routes');
@@ -918,6 +919,9 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
@@ -918,6 +919,9 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
// Process email queue every 2 minutes
// Process email queue every 2 minutes
timers.push(setInterval(() => emailService.processQueue(notificationService).catch((e) => console.warn('[Email] queue failed:', e.message)), 2 * 60 * 1000));
timers.push(setInterval(() => emailService.processQueue(notificationService).catch((e) => console.warn('[Email] queue failed:', e.message)), 2 * 60 * 1000));
+ // Send due notification digests every 15 minutes (hourly/daily/weekly, never during quiet hours)
+ timers.push(setInterval(() => runDigests(Date.now(), { db, notificationService, emailService }).catch((e) => console.warn('[Digest] run failed:', e.message)), 15 * 60 * 1000));
+
// Raw analytics retention (ADR-021) is scheduled by the PostgreSQL tracker itself: events older
// Raw analytics retention (ADR-021) is scheduled by the PostgreSQL tracker itself: events older
// than 30 days go, in bounded batches; hourly/daily rollups stay.
// than 30 days go, in bounded batches; hourly/daily rollups stay.
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// Notification digests — hourly/daily/weekly summaries.
+// A category with a digest preference is never emailed one by one:
+// this job groups its unread, undismissed notifications and sends one
+// email when the period is due, then marks them emailed. Critical
+// notifications never wait here (the queue emails them at once).
+// Quiet hours always win: no digest is sent inside them.
+// ═══════════════════════════════════════════════════════════════
+
+const { inQuiet, zoned, rowToConfig } = require('./quiet-hours');
+
+const DIGEST_PERIODS = new Set(['hourly', 'daily', 'weekly']);
+const HOUR_MS = 60 * 60 * 1000;
+const MORNING_MIN = 8 * 60; // 08:00 local; daily and weekly wait for it
+const DAILY_GAP_MS = 23 * HOUR_MS;
+const WEEKLY_GAP_MS = 6.5 * 24 * HOUR_MS;
+const LIST_LIMIT = 20;
+
+/** A stored text timestamp (ov_now() or ISO) as milliseconds, or null. */
+function parseStamp(value) {
+ if (!value) return null;
+ const text = String(value);
+ const ms = Date.parse(text.includes('T') ? text : `${text.replace(' ', 'T')}Z`);
+ return Number.isFinite(ms) ? ms : null;
+}
+
+/** ov_now()'s shape: UTC 'YYYY-MM-DD HH:MM:SS'. */
+function stamp(ms) {
+ return new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
+}
+
+/** Whether `period` is due at `nowMs`, reading the last run in the person's own zone. */
+function digestDue(period, lastSentAt, nowMs, tz) {
+ const last = parseStamp(lastSentAt);
+ if (period === 'hourly') return last == null || nowMs - last >= HOUR_MS;
+ const local = zoned(nowMs, tz);
+ if (local.minute < MORNING_MIN) return false; // not before 08:00 local
+ if (period === 'daily') {
+ if (last != null && nowMs - last < DAILY_GAP_MS) return false;
+ // One a day: a run earlier the same local day is not repeated.
+ return last == null || zoned(last, tz).day !== local.day;
+ }
+ if (period === 'weekly') return last == null || nowMs - last >= WEEKLY_GAP_MS;
+ return false;
+}
+
+/**
+ * Run every due digest. `now` may be a Date, an ISO string or milliseconds (tests move the clock).
+ * Dependencies are explicit so tests and the server share the same code:
+ * runDigests(Date.now(), { db, notificationService, emailService })
+ * Returns { sent, skipped }; one person's failure never stops the run.
+ */
+async function runDigests(now = Date.now(), { db, notificationService, emailService } = {}) {
+ if (!db || !notificationService || !emailService) throw new TypeError('runDigests: db, notificationService and emailService are required');
+ const nowMs = now instanceof Date ? now.getTime() : typeof now === 'string' ? Date.parse(now) : Number(now);
+ if (!Number.isFinite(nowMs)) throw new TypeError('runDigests: invalid now');
+
+ await emailService.ready;
+ if (emailService.isEnabled === false) return { sent: 0, skipped: 0 };
+
+ const rows = await db.prepare(`
+ SELECT n.id, n.user_id, n.type, n.category, n.priority, n.title, n.message, n.service, n.url, n.created_at,
+ u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at,
+ p.digest AS digest_period,
+ q.enabled AS quiet_enabled, q.start_min AS quiet_start, q.end_min AS quiet_end, q.tz AS quiet_tz,
+ r.last_sent_at
+ FROM notifications n
+ JOIN notification_preferences p ON p.user_id = n.user_id AND p.category = n.category
+ JOIN users u ON u.id = n.user_id
+ LEFT JOIN notification_quiet_hours q ON q.user_id = n.user_id
+ LEFT JOIN notification_digest_runs r ON r.user_id = n.user_id AND r.period = p.digest
+ WHERE n.is_emailed = 0 AND n.is_dismissed = 0 AND n.is_read = 0
+ AND p.enabled = 1 AND p.digest IN ('hourly', 'daily', 'weekly')
+ AND n.priority != 'critical'
+ ORDER BY n.user_id, n.created_at ASC, n.seq ASC
+ `).all();
+ if (!rows.length) return { sent: 0, skipped: 0 };
+
+ const users = new Map();
+ for (const row of rows) {
+ const key = String(row.user_id);
+ let entry = users.get(key);
+ if (!entry) {
+ entry = {
+ user: row,
+ quiet: row.quiet_tz == null
+ ? rowToConfig(null)
+ : rowToConfig({ enabled: row.quiet_enabled, start_min: row.quiet_start, end_min: row.quiet_end, tz: row.quiet_tz }),
+ periods: new Map(),
+ lastSent: new Map(),
+ };
+ users.set(key, entry);
+ }
+ if (!entry.periods.has(row.digest_period)) entry.periods.set(row.digest_period, []);
+ entry.periods.get(row.digest_period).push(row);
+ entry.lastSent.set(row.digest_period, row.last_sent_at);
+ }
+
+ const markEmailed = db.prepare('UPDATE notifications SET is_emailed = 1 WHERE id = ? AND is_emailed = 0');
+ const upsertRun = db.prepare(`
+ INSERT INTO notification_digest_runs (user_id, period, last_sent_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT (user_id, period) DO UPDATE SET last_sent_at = excluded.last_sent_at
+ `);
+
+ let sent = 0;
+ let skipped = 0;
+ for (const entry of users.values()) {
+ const user = entry.user;
+ try {
+ // Quiet hours always win; critical items never reach this query.
+ if (inQuiet(entry.quiet, nowMs)) { skipped++; continue; }
+ // The same deliverability rules as the queue: a real address, not bouncing, verified.
+ if (!user.email || user.email_bounced_at || !Number(user.email_verified)) { skipped++; continue; }
+
+ for (const [period, items] of entry.periods) {
+ try {
+ if (!digestDue(period, entry.lastSent.get(period), nowMs, entry.quiet.tz)) continue;
+ const cap = notificationService.emailCaps ? await notificationService.emailCaps(user.user_id) : null;
+ if (cap === 'user-cap' || cap === 'global-cap') { skipped++; continue; }
+ const ok = await emailService.sendDigestEmail({
+ to: user.email,
+ username: user.display_name || user.username,
+ userId: user.user_id,
+ period,
+ items: items.slice(0, LIST_LIMIT),
+ total: items.length,
+ });
+ if (!ok) { skipped++; continue; }
+ await db.tx(async () => {
+ for (const item of items) await markEmailed.run(item.id);
+ await upsertRun.run(user.user_id, period, stamp(nowMs));
+ });
+ sent++;
+ } catch (err) {
+ skipped++;
+ console.warn(`[Digest] ${period} for user ${user.user_id} failed:`, err.message);
+ }
+ }
+ } catch (err) {
+ skipped++;
+ console.warn(`[Digest] user ${user.user_id} failed:`, err.message);
+ }
+ }
+ if (sent) console.log(`[Digest] sent ${sent} digest(s), skipped ${skipped}`);
+ return { sent, skipped };
+}
+
+module.exports = { runDigests, digestDue, DIGEST_PERIODS, parseStamp, stamp, LIST_LIMIT };
@@ -225,6 +225,28 @@ class EmailService {
@@ -225,6 +225,28 @@ class EmailService {
return sent;
return sent;
}
}
+ /**
+ * Send one digest email (hourly/daily/weekly summary).
+ * @param {Object} opts - { to, username, userId, period, items, total }
+ * @returns {Promise<boolean>} true on success
+ */
+ async sendDigestEmail({ to, username, userId = null, period = 'daily', items = [], total = items.length }) {
+ const subject = `Your OpenVibe digest: ${total} new`;
+ const htmlBody = this._buildDigestHtml({ username, items, total });
+ const textBody = this._buildDigestText({ username, items, total });
+ const sent = await this._sendEmail({
+ to,
+ subject,
+ htmlBody,
+ textBody,
+ emailType: `notification_digest:${period}`,
+ userId,
+ metadata: { period, count: total },
+ });
+ if (sent) console.log(`[Email] Sent ${period} digest to ${to}: ${total} new`);
+ return sent;
+ }
+
/** Email-address verification link. */
/** Email-address verification link. */
async sendVerificationEmail({ to, username, verifyUrl, expiresMinutes = 60 }) {
async sendVerificationEmail({ to, username, verifyUrl, expiresMinutes = 60 }) {
const u = this._escapeHtml(username || 'there');
const u = this._escapeHtml(username || 'there');
@@ -277,8 +299,15 @@ class EmailService {
@@ -277,8 +299,15 @@ class EmailService {
let skipped = 0;
let skipped = 0;
for (const notif of pending) {
for (const notif of pending) {
+ const digest = notifService.isDigest ? await notifService.isDigest(notif) : false;
if (!(await notifService.shouldEmail(notif))) {
if (!(await notifService.shouldEmail(notif))) {
- await notifService.markEmailed(notif.id);
+ // A digested notification stays pending for runDigests(); anything else will never email.
+ if (!digest) await notifService.markEmailed(notif.id);
+ continue;
+ }
+ // Quiet hours hold a normal email until the window ends; a critical one breaks through.
+ if (notif.priority !== 'critical' && notifService.inQuietHours && await notifService.inQuietHours(notif.user_id)) {
+ skipped++;
continue;
continue;
}
}
const why = notifService.emailGuard ? await notifService.emailGuard(notif) : null;
const why = notifService.emailGuard ? await notifService.emailGuard(notif) : null;
@@ -299,7 +328,7 @@ class EmailService {
@@ -299,7 +328,7 @@ class EmailService {
});
});
if (sent) await notifService.markEmailed(notif.id);
if (sent) await notifService.markEmailed(notif.id);
}
}
- if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);
+ if (skipped) console.log(`[Email] ${skipped} notification email(s) held back (quiet hours/stale/dup/caps)`);
}
}
// ─── Email Templates ───────────────────────────────────────
// ─── Email Templates ───────────────────────────────────────
@@ -365,6 +394,29 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
@@ -365,6 +394,29 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
].join('\n');
].join('\n');
}
}
+ _buildDigestHtml({ username, items, total }) {
+ const more = Math.max(0, Number(total) - items.length);
+ const list = items.map((n) => `<li><strong>${this._escapeHtml(n.title)}</strong>`
+ + `${n.service ? ` · ${this._escapeHtml(n.service)}` : ''}`
+ + `${n.url ? ` · <a href="${this._escapeHtml(n.url)}">Open</a>` : ''}</li>`).join('');
+ return `<p>Hey ${this._escapeHtml(username || 'there')},</p>
+<p>Your OpenVibe digest: ${Number(total)} new</p>
+<ul>${list}</ul>
+${more ? `<p>and ${more} more</p>` : ''}
+<p><a href="https://openvibe.network/notifications">Manage notification preferences</a></p>`;
+ }
+
+ _buildDigestText({ username, items, total }) {
+ const more = Math.max(0, Number(total) - items.length);
+ const lines = [`Hey ${username || 'there'},`, '', `Your OpenVibe digest: ${Number(total)} new`, ''];
+ for (const n of items) {
+ lines.push(`${n.title}${n.service ? ` · ${n.service}` : ''}${n.url ? ` · ${n.url}` : ''}`);
+ }
+ if (more) lines.push('', `and ${more} more`);
+ lines.push('', '---', 'Manage preferences: https://openvibe.network/notifications', 'OpenVibe · https://openvibe.network');
+ return lines.join('\n');
+ }
+
_buildPasswordResetHtml({ username, resetUrl, expiresMinutes }) {
_buildPasswordResetHtml({ username, resetUrl, expiresMinutes }) {
return `<!DOCTYPE html>
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -10,10 +10,20 @@ const { v4: uuidv4 } = require('uuid');
@@ -10,10 +10,20 @@ 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 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 { DIGEST_PERIODS } = require('./digest');
// Staff and platform notices: never hidden by a block.
// Staff and platform notices: never hidden by a block.
const BLOCK_EXEMPT_CATEGORIES = new Set(['moderation', 'system', 'admin']);
const BLOCK_EXEMPT_CATEGORIES = new Set(['moderation', 'system', 'admin']);
+/** A validation failure the routes answer 400 to. */
+function badRequest(message) {
+ const err = new Error(message);
+ err.status = 400;
+ err.code = 'notification.invalid';
+ return err;
+}
+
/**
/**
* network.notification.created (Contracts 0.61.0; ADR-005 amendment 2, roadmap WS-E task 3): every stored
* network.notification.created (Contracts 0.61.0; ADR-005 amendment 2, roadmap WS-E task 3): every stored
* notification is announced to its person over OpenVibe.Events, so the notification badge on any site
* notification is announced to its person over OpenVibe.Events, so the notification badge on any site
@@ -93,17 +103,30 @@ class NotificationService {
@@ -93,17 +103,30 @@ class NotificationService {
this._getPrefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?');
this._getPrefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?');
this._getPrefByCategory = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ? AND category = ?');
this._getPrefByCategory = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ? AND category = ?');
this._upsertPref = db.prepare(`
this._upsertPref = db.prepare(`
- INSERT INTO notification_preferences (user_id, category, enabled, sound, toasts, email)
- VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT(user_id, category) DO UPDATE SET enabled = ?, sound = ?, toasts = ?, email = ?
+ INSERT INTO notification_preferences (user_id, category, enabled, sound, toasts, email, digest)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(user_id, category) DO UPDATE SET enabled = ?, sound = ?, toasts = ?, email = ?, digest = ?
`);
`);
this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');
this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');
+ this._getQuietHours = db.prepare('SELECT * FROM notification_quiet_hours WHERE user_id = ?');
+ this._upsertQuietHours = db.prepare(`
+ INSERT INTO notification_quiet_hours (user_id, enabled, start_min, end_min, tz, updated_at)
+ VALUES (?, ?, ?, ?, ?, ov_now())
+ ON CONFLICT(user_id) DO UPDATE SET enabled = excluded.enabled, start_min = excluded.start_min,
+ end_min = excluded.end_min, tz = excluded.tz, updated_at = ov_now()
+ `);
+
this._getPendingEmails = db.prepare(`
this._getPendingEmails = db.prepare(`
SELECT n.*, u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at FROM notifications n
SELECT n.*, u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at FROM notifications n
JOIN users u ON u.id = n.user_id
JOIN users u ON u.id = n.user_id
WHERE n.is_emailed = 0 AND n.is_dismissed = 0
WHERE n.is_emailed = 0 AND n.is_dismissed = 0
AND u.email IS NOT NULL AND u.email != ''
AND u.email IS NOT NULL AND u.email != ''
+ -- Notifications waiting for a digest are the digest job's (runDigests): left in this queue they would fill
+ -- its 200 slots and starve everyone else's emails.
+ AND (n.priority = 'critical' OR NOT EXISTS (
+ SELECT 1 FROM notification_preferences p
+ WHERE p.user_id = n.user_id AND p.category = n.category AND p.digest IN ('hourly', 'daily', 'weekly')))
ORDER BY n.created_at ASC
ORDER BY n.created_at ASC
LIMIT 200
LIMIT 200
`);
`);
@@ -195,17 +218,24 @@ class NotificationService {
@@ -195,17 +218,24 @@ class NotificationService {
// Wake the relay after the commit (the outbox row committed with the notification).
// Wake the relay after the commit (the outbox row committed with the notification).
if (announced) { const live = eventRelay.outboxFor(this.db); if (live) live.kick(); }
if (announced) { const live = eventRelay.outboxFor(this.db); if (live) live.kick(); }
- // Fire browser push notification (async, non-blocking); `silent` keeps it to the bell.
- if (!data.silent) try {
- const pushService = require('../push/push-service');
- pushService.sendPush(data.user_id, {
- title,
- message: data.message,
- icon,
- url: data.url,
- type: data.type,
- }).catch(() => {});
- } catch (_) { /* push module not available */ }
+ // Fire browser push notification (async, non-blocking); `silent` keeps it to the bell. Quiet hours
+ // hold it back like email, except for critical priority, which always breaks through.
+ if (!data.silent) {
+ let held = false;
+ if (priority !== PRIORITY.CRITICAL) {
+ try { held = await this.inQuietHours(data.user_id); } catch { /* no quiet window */ }
+ }
+ if (!held) try {
+ const pushService = require('../push/push-service');
+ pushService.sendPush(data.user_id, {
+ title,
+ message: data.message,
+ icon,
+ url: data.url,
+ type: data.type,
+ }).catch(() => {});
+ } catch (_) { /* push module not available */ }
+ }
return { id, user_id: data.user_id, type: data.type, category, priority, title, message: data.message, icon, service: data.service, url: data.url, rich_content: data.rich_content, is_read: 0, created_at: createdAt };
return { id, user_id: data.user_id, type: data.type, category, priority, title, message: data.message, icon, service: data.service, url: data.url, rich_content: data.rich_content, is_read: 0, created_at: createdAt };
}
}
@@ -384,7 +414,7 @@ class NotificationService {
@@ -384,7 +414,7 @@ class NotificationService {
// ─── Preferences ──────────────────────────────────────────
// ─── Preferences ──────────────────────────────────────────
async getPreferences(userId) {
async getPreferences(userId) {
- return await this._getPrefs.all(userId);
+ return (await this._getPrefs.all(userId)).map((pref) => ({ ...pref, digest: pref.digest || 'off' }));
}
}
async setPreference(userId, category, updates = {}) {
async setPreference(userId, category, updates = {}) {
@@ -393,12 +423,20 @@ class NotificationService {
@@ -393,12 +423,20 @@ class NotificationService {
sound: 1,
sound: 1,
toasts: 1,
toasts: 1,
email: null, // NULL = no explicit choice → shouldEmail() applies the defaults
email: null, // NULL = no explicit choice → shouldEmail() applies the defaults
+ digest: null, // NULL = off
};
};
+ let digest = current.digest || null;
+ if (updates.digest !== undefined) {
+ if (updates.digest === null || updates.digest === 'off') digest = null;
+ else if (DIGEST_PERIODS.has(updates.digest)) digest = updates.digest;
+ else throw badRequest("digest must be 'off', 'hourly', 'daily' or 'weekly'");
+ }
const next = {
const next = {
enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,
enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,
sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,
sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,
toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,
email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,
+ digest,
};
};
await this._upsertPref.run(
await this._upsertPref.run(
userId,
userId,
@@ -407,10 +445,12 @@ class NotificationService {
@@ -407,10 +445,12 @@ class NotificationService {
next.sound,
next.sound,
next.toasts,
next.toasts,
next.email,
next.email,
+ next.digest,
next.enabled,
next.enabled,
next.sound,
next.sound,
next.toasts,
next.toasts,
next.email,
next.email,
+ next.digest,
);
);
}
}
@@ -418,6 +458,36 @@ class NotificationService {
@@ -418,6 +458,36 @@ class NotificationService {
await this._deletePrefs.run(userId, category);
await this._deletePrefs.run(userId, category);
}
}
+ // ─── Quiet Hours ─────────────────────────────────────────
+
+ /** The API shape { enabled, start: 'HH:MM', end: 'HH:MM', tz }, with the 22:00–07:00 UTC default. */
+ async getQuietHours(userId) {
+ return configToView(rowToConfig(await this._getQuietHours.get(userId)));
+ }
+
+ /** The numeric config inQuiet() reads (also gives the digest its zone when quiet hours are off). */
+ async getQuietHoursConfig(userId) {
+ return rowToConfig(await this._getQuietHours.get(userId));
+ }
+
+ async inQuietHours(userId, at = Date.now()) {
+ return inQuiet(await this.getQuietHoursConfig(userId), at);
+ }
+
+ async setQuietHours(userId, { enabled, start, end, tz } = {}) {
+ const row = await this._getQuietHours.get(userId);
+ const current = rowToConfig(row);
+ const startMin = start === undefined ? current.start_min : parseTime(start);
+ const endMin = end === undefined ? current.end_min : parseTime(end);
+ if (startMin === null) throw badRequest("start must be a time 'HH:MM'");
+ if (endMin === null) throw badRequest("end must be a time 'HH:MM'");
+ const zone = tz === undefined ? current.tz : tz;
+ if (!validTimeZone(zone)) throw badRequest("tz must be an IANA time zone");
+ const on = enabled === undefined ? (row ? current.enabled : true) : !(enabled === false || Number(enabled) === 0);
+ await this._upsertQuietHours.run(userId, on ? 1 : 0, startMin, endMin, zone);
+ return await this.getQuietHours(userId);
+ }
+
// ─── Email Queue ──────────────────────────────────────────
// ─── Email Queue ──────────────────────────────────────────
/**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */
/**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */
@@ -435,6 +505,8 @@ class NotificationService {
@@ -435,6 +505,8 @@ class NotificationService {
const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);
const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);
// If user disabled the whole category, no email
// If user disabled the whole category, no email
if (pref && !pref.enabled) return false;
if (pref && !pref.enabled) return false;
+ // A digested category waits for its summary; critical still emails at once.
+ if (pref && DIGEST_PERIODS.has(pref.digest) && notification.priority !== PRIORITY.CRITICAL) return false;
// LOW priority never emails
// LOW priority never emails
if (notification.priority === PRIORITY.LOW) return false;
if (notification.priority === PRIORITY.LOW) return false;
const critical = notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
const critical = notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
@@ -450,12 +522,33 @@ class NotificationService {
@@ -450,12 +522,33 @@ class NotificationService {
return critical;
return critical;
}
}
+ /** Whether this notification belongs to a digest category (critical never waits for the digest). */
+ async isDigest(notification) {
+ if (notification.priority === PRIORITY.CRITICAL) return false;
+ const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);
+ return !!(pref && Number(pref.enabled) !== 0 && DIGEST_PERIODS.has(pref.digest));
+ }
+
+ /**
+ * The daily caps, shared by the queue and the digest: 'user-cap', 'global-cap' or null.
+ * - user cap: site_settings email_user_daily_cap (default 30)
+ * - global cap: site_settings email_daily_cap (default 2000)
+ */
+ async emailCaps(userId) {
+ try {
+ const userCap = parseInt(await this.db.getSetting?.('email_user_daily_cap'), 10) || 30;
+ if (((await this._emailsSentToUserToday.get(userId))?.c || 0) >= userCap) return 'user-cap';
+ const globalCap = parseInt(await this.db.getSetting?.('email_daily_cap'), 10) || 2000;
+ if (((await this._emailsSentToday.get())?.c || 0) >= globalCap) return 'global-cap';
+ } catch { /* guards are best-effort */ }
+ return null;
+ }
+
/**
/**
* Abuse/cost guards evaluated right before a send. Returns a reason string to skip, or null.
* Abuse/cost guards evaluated right before a send. Returns a reason string to skip, or null.
* - stale: a go-live alert older than 2h is useless in an inbox
* - stale: a go-live alert older than 2h is useless in an inbox
* - dup: same streamer already emailed this user within the hour
* - dup: same streamer already emailed this user within the hour
- * - user cap / global cap: site_settings email_user_daily_cap (default 30),
- * email_daily_cap (default 2000)
+ * - user cap / global cap (see emailCaps)
*/
*/
async emailGuard(notification) {
async emailGuard(notification) {
try {
try {
@@ -464,10 +557,7 @@ class NotificationService {
@@ -464,10 +557,7 @@ class NotificationService {
if (ageMs > 2 * 3600 * 1000) return 'stale';
if (ageMs > 2 * 3600 * 1000) return 'stale';
if (notification.sender_id != null && await this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';
if (notification.sender_id != null && await this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';
}
}
- const userCap = parseInt(await this.db.getSetting?.('email_user_daily_cap'), 10) || 30;
- if (((await this._emailsSentToUserToday.get(notification.user_id))?.c || 0) >= userCap) return 'user-cap';
- const globalCap = parseInt(await this.db.getSetting?.('email_daily_cap'), 10) || 2000;
- if (((await this._emailsSentToday.get())?.c || 0) >= globalCap) return 'global-cap';
+ return await this.emailCaps(notification.user_id);
} catch { /* guards are best-effort */ }
} catch { /* guards are best-effort */ }
return null;
return null;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// Quiet hours — a person's do-not-disturb window in their own zone.
+// The window may cross midnight (22:00–07:00); start === end means none.
+// Intl.DateTimeFormat does the zone arithmetic, so DST changes need no
+// special case. An unknown or missing zone is read as UTC.
+// ═══════════════════════════════════════════════════════════════
+
+const DEFAULT_QUIET = Object.freeze({ enabled: false, start_min: 22 * 60, end_min: 7 * 60, tz: 'UTC' });
+
+/** 'HH:MM' → minutes since midnight, or null when it is not a valid wall time. */
+function parseTime(value) {
+ const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value ?? ''));
+ return m ? Number(m[1]) * 60 + Number(m[2]) : null;
+}
+
+/** Minutes since midnight → 'HH:MM'. */
+function formatTime(minutes) {
+ const n = Number(minutes);
+ if (!Number.isInteger(n) || n < 0 || n > 1439) return '00:00';
+ return `${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
+}
+
+/** An IANA zone Intl accepts (UTC included); aliases such as US/Pacific are fine. */
+function validTimeZone(tz) {
+ if (typeof tz !== 'string' || !tz || tz.length > 100) return false;
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
+}
+
+/** The person's wall clock at `at`: { minute: 0–1439, hour: 0–23, day: 'YYYY-MM-DD' }. */
+function zoned(at, tz) {
+ const zone = validTimeZone(tz) ? tz : 'UTC';
+ const fmt = new Intl.DateTimeFormat('en-CA', {
+ timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
+ });
+ const parts = {};
+ for (const p of fmt.formatToParts(at instanceof Date ? at : new Date(at))) parts[p.type] = p.value;
+ let hour = Number(parts.hour);
+ if (hour === 24) hour = 0; // some ICU versions render midnight as 24
+ return { minute: hour * 60 + Number(parts.minute), hour, day: `${parts.year}-${parts.month}-${parts.day}` };
+}
+
+/** A stored row (or nothing) as the numeric config inQuiet() reads. */
+function rowToConfig(row) {
+ if (!row) return { ...DEFAULT_QUIET };
+ return {
+ enabled: !!Number(row.enabled),
+ start_min: Number(row.start_min),
+ end_min: Number(row.end_min),
+ tz: row.tz || 'UTC',
+ };
+}
+
+/** The API shape: { enabled, start: 'HH:MM', end: 'HH:MM', tz }. */
+function configToView(config) {
+ const c = config || DEFAULT_QUIET;
+ return {
+ enabled: !!c.enabled,
+ start: formatTime(c.start_min),
+ end: formatTime(c.end_min),
+ tz: c.tz || 'UTC',
+ };
+}
+
+/**
+ * Whether `at` falls in this quiet window. Cross-midnight windows wrap; start === end is no window.
+ * @param {{ start_min: number, end_min: number, tz: string, enabled?: boolean|number }} config
+ * @param {number|Date} [at]
+ */
+function inQuiet(config = {}, at = Date.now()) {
+ const off = config.enabled === false || Number(config.enabled) === 0;
+ if (off) return false;
+ const start = Number(config.start_min);
+ const end = Number(config.end_min);
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return false;
+ if (start < 0 || start > 1439 || end < 0 || end > 1439) return false;
+ if (start === end) return false;
+ const now = zoned(at, config.tz).minute;
+ return start < end ? (now >= start && now < end) : (now >= start || now < end);
+}
+
+module.exports = {
+ DEFAULT_QUIET, parseTime, formatTime, validTimeZone, zoned, rowToConfig, configToView, inQuiet,
+};
@@ -117,6 +117,34 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -117,6 +117,34 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
}
}
});
});
+ // ─── GET /api/notifications/quiet-hours ────────────────
+ // The person's do-not-disturb window (default 22:00–07:00 UTC, off) and whether it is now.
+ router.get('/quiet-hours', requireAuth, async (req, res) => {
+ try {
+ const quiet = await notificationService.getQuietHours(req.user.id);
+ const in_quiet = await notificationService.inQuietHours(req.user.id);
+ res.json({ ok: true, ...quiet, in_quiet });
+ } catch (err) {
+ console.error('[Notifications] GET /quiet-hours error:', err);
+ res.status(500).json({ ok: false, error: 'Failed to get quiet hours' });
+ }
+ });
+
+ // ─── PUT /api/notifications/quiet-hours ────────────────
+ // Body: { enabled, start: 'HH:MM', end: 'HH:MM', tz }
+ router.put('/quiet-hours', requireAuth, async (req, res) => {
+ try {
+ const { enabled, start, end, tz } = req.body || {};
+ const quiet = await notificationService.setQuietHours(req.user.id, { enabled, start, end, tz });
+ const in_quiet = await notificationService.inQuietHours(req.user.id);
+ res.json({ ok: true, ...quiet, in_quiet });
+ } catch (err) {
+ if (err.status === 400) return res.status(400).json({ ok: false, error: err.message });
+ console.error('[Notifications] PUT /quiet-hours error:', err);
+ res.status(500).json({ ok: false, error: 'Failed to save quiet hours' });
+ }
+ });
+
// ─── GET /api/notifications/preferences ────────────────
// ─── GET /api/notifications/preferences ────────────────
router.get('/preferences', requireAuth, async (req, res) => {
router.get('/preferences', requireAuth, async (req, res) => {
try {
try {
@@ -128,19 +156,21 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -128,19 +156,21 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── PUT /api/notifications/preferences ────────────────
// ─── PUT /api/notifications/preferences ────────────────
- // Body: { category, enabled, sound, toasts, email }
+ // Body: { category, enabled, sound, toasts, email, digest }
router.put('/preferences', requireAuth, async (req, res) => {
router.put('/preferences', requireAuth, async (req, res) => {
try {
try {
- const { category, enabled, sound, toasts, email } = req.body;
+ const { category, enabled, sound, toasts, email, digest } = req.body;
if (!category) return res.status(400).json({ ok: false, error: 'Category required' });
if (!category) return res.status(400).json({ ok: false, error: 'Category required' });
await notificationService.setPreference(req.user.id, category, {
await notificationService.setPreference(req.user.id, category, {
enabled,
enabled,
sound,
sound,
toasts,
toasts,
email,
email,
+ digest,
});
});
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
+ if (err.status === 400) return res.status(400).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: 'Failed to update preferences' });
res.status(500).json({ ok: false, error: 'Failed to update preferences' });
}
}
});
});
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+'use strict';
+// Quiet hours and notification digests (migration 0027):
+// - inQuiet: cross-midnight and same-day windows, start === end, unknown zones, and a DST-change date
+// in America/Los_Angeles (the wall clock, via Intl, not a fixed UTC offset);
+// - the API: the default window, a saved window, bad times/zones as 400, and the digest preference;
+// - email: a normal notification waits out quiet hours, a critical one breaks through, and the held
+// notification goes out on the first pass after the window;
+// - digests: a digested category is not emailed one by one, one hourly email lists and marks the items,
+// a second run inside the hour sends nothing, and a daily digest waits for 08:00 local.
+// node --import ./test/helpers/pg-preload.mjs test/notification-quiet-digest.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 { EmailService } = require('../server/notifications/email-service');
+const createNotificationRoutes = require('../server/notifications/routes');
+const quiet = require('../server/notifications/quiet-hours');
+const { runDigests, digestDue } = require('../server/notifications/digest');
+const { signToken } = require('../server/auth/routes');
+
+const DAY = 24 * 60 * 60 * 1000;
+
+(async () => {
+const db = getDb();
+const log = console.log; console.log = () => {};
+process.env.RESEND_API_KEY = process.env.RESEND_API_KEY || 're_test_quiet_digest';
+await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('email_enabled', 'true', 'boolean') ON CONFLICT (key) DO UPDATE SET value = 'true', type = 'boolean'").run();
+await db.prepare(`INSERT INTO users (id, username, email, password_hash, email_verified) VALUES
+ (1, 'qa', '[email protected]', 'x', 1),
+ (2, 'rita', '[email protected]', 'x', 1),
+ (3, 'dora', '[email protected]', 'x', 1),
+ (4, 'wanda', '[email protected]', 'x', 1)`).run();
+
+const notifications = new NotificationService(db);
+const emailService = new EmailService(db);
+await emailService.ready;
+assert.strictEqual(emailService.isEnabled, true, 'the email service is enabled for the test');
+const sent = [];
+emailService._sendEmail = async (opts) => { sent.push(opts); return true; };
+
+// ── 1. inQuiet ────────────────────────────────────────────────────────────────────────────────
+const cross = { enabled: true, start_min: 22 * 60, end_min: 7 * 60, tz: 'UTC' };
+assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-15T23:30:00Z')), true, '23:30 is inside 22:00–07:00');
+assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-16T06:59:00Z')), true, '06:59 is still inside the window');
+assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-16T07:00:00Z')), false, '07:00 ends it');
+assert.strictEqual(quiet.inQuiet(cross, Date.parse('2026-01-15T12:00:00Z')), false, 'midday is outside it');
+
+const sameDay = { enabled: true, start_min: 9 * 60, end_min: 17 * 60, tz: 'UTC' };
+assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T09:00:00Z')), true, '09:00 starts a same-day window');
+assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T16:59:00Z')), true, '16:59 is inside it');
+assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T17:00:00Z')), false, '17:00 ends it');
+assert.strictEqual(quiet.inQuiet(sameDay, Date.parse('2026-01-15T08:59:00Z')), false, '08:59 is before it');
+assert.strictEqual(quiet.inQuiet({ ...sameDay, enabled: false }, Date.parse('2026-01-15T12:00:00Z')), false, 'disabled means no window');
+assert.strictEqual(quiet.inQuiet({ ...sameDay, start_min: 600, end_min: 600 }, Date.parse('2026-01-15T10:00:00Z')), false, 'start === end is no window');
+assert.strictEqual(quiet.inQuiet({ ...cross, tz: 'Not/AZone' }, Date.parse('2026-01-15T23:00:00Z')), true, 'an unknown zone is read as UTC');
+
+// America/Los_Angeles, 2026-03-08: 02:00 PST jumps to 03:00 PDT. A 01:00–04:00 wall-clock window has
+// to follow the clock, not a fixed -8 offset.
+const LA = 'America/Los_Angeles';
+const laWindow = { enabled: true, start_min: 60, end_min: 4 * 60, tz: LA };
+assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T08:30:00Z')), false, '00:30 PST, before the window');
+assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T09:30:00Z')), true, '01:30 PST, inside it');
+assert.strictEqual(quiet.zoned(Date.parse('2026-03-08T10:00:00Z'), LA).hour, 3, '10:00Z is 03:00 PDT after the jump');
+assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T10:30:00Z')), true, '03:30 PDT, still inside it');
+assert.strictEqual(quiet.inQuiet(laWindow, Date.parse('2026-03-08T11:30:00Z')), false, '04:30 PDT, after it');
+
+// Weekly morning rule (the daily and weekly share it).
+assert.strictEqual(digestDue('weekly', null, Date.parse('2026-01-15T07:59:00Z'), 'UTC'), false, 'weekly waits for 08:00');
+assert.strictEqual(digestDue('weekly', null, Date.parse('2026-01-15T08:00:00Z'), 'UTC'), true, 'weekly may start at 08:00');
+assert.strictEqual(digestDue('weekly', '2026-01-08 20:00:00', Date.parse('2026-01-15T09:00:00Z'), 'UTC'), true, '6.5 days later');
+assert.strictEqual(digestDue('weekly', '2026-01-09 08:00:00', Date.parse('2026-01-15T09:00:00Z'), 'UTC'), false, 'six days is too soon');
+
+// ── 2. The API ────────────────────────────────────────────────────────────────────────────────
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+const qa = await db.prepare('SELECT * FROM users WHERE id = 1').get();
+const token = signToken(qa, 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 call = (method, path, body) => fetch(base + path, {
+ method,
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
+ body: body === undefined ? undefined : JSON.stringify(body),
+}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+
+try {
+ let r = await call('GET', '/api/notifications/quiet-hours');
+ assert.deepStrictEqual([r.status, r.body.enabled, r.body.start, r.body.end, r.body.tz, typeof r.body.in_quiet],
+ [200, false, '22:00', '07:00', 'UTC', 'boolean'], 'the unset window is 22:00–07:00 UTC, off');
+
+ r = await call('PUT', '/api/notifications/quiet-hours', { enabled: true, start: '22:00', end: '07:00', tz: 'America/Los_Angeles' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual([r.body.enabled, r.body.start, r.body.end, r.body.tz], [true, '22:00', '07:00', 'America/Los_Angeles']);
+ r = await call('GET', '/api/notifications/quiet-hours');
+ assert.deepStrictEqual([r.body.enabled, r.body.start, r.body.end, r.body.tz], [true, '22:00', '07:00', 'America/Los_Angeles'], 'the saved window reads back');
+ assert.strictEqual(typeof r.body.in_quiet, 'boolean');
+
+ r = await call('PUT', '/api/notifications/quiet-hours', { enabled: true, start: '25:00', end: '07:00', tz: 'UTC' });
+ assert.strictEqual(r.status, 400, 'a bad time is refused');
+ r = await call('PUT', '/api/notifications/quiet-hours', { enabled: true, start: '22:00', end: '07:00', tz: 'Mars/Olympus' });
+ assert.strictEqual(r.status, 400, 'an unknown zone is refused');
+ r = await call('GET', '/api/notifications/quiet-hours');
+ assert.strictEqual(r.body.tz, 'America/Los_Angeles', 'a refused update changes nothing');
+
+ r = await call('PUT', '/api/notifications/preferences', { category: 'social', digest: 'daily' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ r = await call('GET', '/api/notifications/preferences');
+ let social = r.body.preferences.find((p) => p.category === 'social');
+ assert.strictEqual(social.digest, 'daily', 'the digest preference round-trips');
+
+ r = await call('PUT', '/api/notifications/preferences', { category: 'social', digest: 'off' });
+ assert.strictEqual(r.status, 200);
+ r = await call('GET', '/api/notifications/preferences');
+ social = r.body.preferences.find((p) => p.category === 'social');
+ assert.strictEqual(social.digest, 'off', 'NULL reads back as off');
+ const raw = await db.prepare("SELECT digest FROM notification_preferences WHERE user_id = 1 AND category = 'social'").get();
+ assert.strictEqual(raw.digest, null, "'off' is stored as NULL");
+
+ r = await call('PUT', '/api/notifications/preferences', { category: 'social', digest: 'sometimes' });
+ assert.strictEqual(r.status, 400, 'an unknown digest is refused');
+} finally {
+ await new Promise((r) => server.close(r));
+}
+
+// ── 3. Email quiet hours ──────────────────────────────────────────────────────────────────────
+const rita = await db.prepare('SELECT * FROM users WHERE id = 2').get();
+await notifications.setQuietHours(rita.id, { enabled: true, start: '22:00', end: '07:00', tz: 'UTC' });
+// A normal go-live alert (normally emailed because STREAM_LIVE is a default email type) and a critical
+// security notice, both created during the window.
+const live = await notifications.create({ user_id: rita.id, type: 'STREAM_LIVE', category: 'stream', priority: 'high', title: 'Rita is live', sender_id: 99, sender_name: 'Rita', service: 'live', url: 'https://openvibe.live/rita' });
+const critical = await notifications.create({ user_id: rita.id, type: 'GENERIC', category: 'system', priority: 'critical', title: 'Security alert', message: 'A new sign-in', service: 'network' });
+assert.ok(live && critical);
+
+const realNow = Date.now;
+let ritaQuiet = Date.parse('2026-01-15T23:00:00Z');
+Date.now = () => ritaQuiet;
+try {
+ sent.length = 0;
+ await emailService.processQueue(notifications);
+ assert.strictEqual(sent.length, 1, 'only the critical notification is emailed during quiet hours');
+ assert.match(sent[0].subject, /Security alert/);
+ assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(live.id)).is_emailed, 0, 'the normal one stays pending');
+ assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(critical.id)).is_emailed, 1, 'critical breaks through');
+
+ // The window ends: the queue picks the held notification up on its next pass.
+ ritaQuiet = Date.parse('2026-01-16T07:00:00Z');
+ sent.length = 0;
+ await emailService.processQueue(notifications);
+ assert.strictEqual(sent.length, 1, 'the held notification is emailed after the window');
+ assert.match(sent[0].subject, /Rita is live/);
+ assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(live.id)).is_emailed, 1, 'and is marked emailed');
+} finally {
+ Date.now = realNow;
+}
+
+// ── 4. Digests ────────────────────────────────────────────────────────────────────────────────
+// Hourly: the category is never emailed one by one; runDigests sends one summary and marks the item.
+const dora = await db.prepare('SELECT * FROM users WHERE id = 3').get();
+await notifications.setPreference(dora.id, 'social', { digest: 'hourly' });
+const d1 = await notifications.create({ user_id: dora.id, type: 'FOLLOW', category: 'social', priority: 'normal', title: 'New follower', message: 'someone followed you', service: 'live', url: 'https://openvibe.live/@someone' });
+assert.ok(d1);
+sent.length = 0;
+await emailService.processQueue(notifications);
+assert.strictEqual(sent.length, 0, 'an hourly-digest category is not emailed one by one');
+assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d1.id)).is_emailed, 0, 'it is left for the digest');
+
+let now = Date.parse('2026-01-15T12:00:00Z');
+let result = await runDigests(now, { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 1, 'the first hourly digest is due');
+assert.strictEqual(sent.length, 1);
+assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');
+assert.match(sent[0].textBody, /New follower/);
+assert.match(sent[0].textBody, /live/);
+assert.match(sent[0].textBody, /https:\/\/openvibe\.live\/@someone/);
+assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d1.id)).is_emailed, 1, 'the item is marked emailed');
+let run = await db.prepare("SELECT * FROM notification_digest_runs WHERE user_id = ? AND period = 'hourly'").get(dora.id);
+assert.strictEqual(run.last_sent_at, '2026-01-15 12:00:00', 'the run is recorded');
+
+const d2 = await notifications.create({ user_id: dora.id, type: 'FOLLOW', category: 'social', priority: 'normal', title: 'Another follower', service: 'live', url: 'https://openvibe.live/@another' });
+sent.length = 0;
+result = await runDigests(now + 30 * 60 * 1000, { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 0, 'a second run inside the hour sends nothing');
+assert.strictEqual(sent.length, 0);
+assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d2.id)).is_emailed, 0, 'the new item waits for the next hour');
+
+sent.length = 0;
+result = await runDigests(now + 61 * 60 * 1000, { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 1, 'an hour after the last run it is due again');
+assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');
+assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(d2.id)).is_emailed, 1);
+
+// Daily: 08:00 in the person's quiet-hours zone (08:00 PST is 16:00Z), never before it.
+const wanda = await db.prepare('SELECT * FROM users WHERE id = 4').get();
+await notifications.setQuietHours(wanda.id, { enabled: false, start: '22:00', end: '07:00', tz: LA });
+await notifications.setPreference(wanda.id, 'economy', { digest: 'daily' });
+const daily = await notifications.create({ user_id: wanda.id, type: 'COINS_RECEIVED', category: 'economy', priority: 'normal', title: 'Coins received', service: 'network' });
+assert.ok(daily);
+
+sent.length = 0;
+result = await runDigests(Date.parse('2026-01-15T15:59:00Z'), { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 0, 'daily waits for 08:00 local');
+assert.strictEqual(sent.length, 0);
+assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(daily.id)).is_emailed, 0);
+
+result = await runDigests(Date.parse('2026-01-15T16:00:00Z'), { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 1, '08:00 local makes the daily digest due');
+assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');
+assert.strictEqual(sent[0].emailType, 'notification_digest:daily');
+assert.match(sent[0].textBody, /Coins received/);
+assert.strictEqual((await db.prepare('SELECT is_emailed FROM notifications WHERE id = ?').get(daily.id)).is_emailed, 1);
+run = await db.prepare("SELECT * FROM notification_digest_runs WHERE user_id = ? AND period = 'daily'").get(wanda.id);
+assert.strictEqual(run.last_sent_at, '2026-01-15 16:00:00');
+
+// The next day before 08:00 is still not due; at 08:00 it is.
+await notifications.create({ user_id: wanda.id, type: 'COINS_RECEIVED', category: 'economy', priority: 'normal', title: 'More coins', service: 'network' });
+sent.length = 0;
+result = await runDigests(Date.parse('2026-01-16T15:59:00Z'), { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 0, 'the next day before 08:00 local is not due');
+result = await runDigests(Date.parse('2026-01-16T16:00:00Z'), { db, notificationService: notifications, emailService });
+assert.strictEqual(result.sent, 1, 'the next morning at 08:00 local is due');
+assert.strictEqual(sent[0].subject, 'Your OpenVibe digest: 1 new');
+
+// A digest is never sent inside quiet hours.
+await notifications.setQuietHours(wanda.id, { enabled: true, start: '07:00', end: '10:00', tz: LA });
+await notifications.create({ user_id: wanda.id, type: 'COINS_RECEIVED', category: 'economy', priority: 'normal', title: 'Held coin', service: 'network' });
+sent.length = 0;
+result = await runDigests(Date.parse('2026-01-17T16:30:00Z'), { db, notificationService: notifications, emailService }); // 08:30 PST
+assert.strictEqual(result.sent, 0, 'quiet hours hold the digest');
+assert.strictEqual(sent.length, 0);
+
+console.log = log;
+console.log('quiet hours and notification digests: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');