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)

OP OpenVibers committed 16 days ago · 8668827963 · 17 files, +792 −34

Changes

ddiff --git a/README.md b/README.md
iindex 0a4f0c4d..f1bbe542 100644
---- a/README.md
++++ b/README.md
@@@ -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
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).
++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-cloudflare
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
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
- **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).
---
ddiff --git a/docs/notification-digest.md b/docs/notification-digest.md
iindex df9fafaf..3bdbc3ea 100644
---- a/docs/notification-digest.md
++++ b/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).
## Problem
ddiff --git a/migrations/0027_notification_quiet_digest.sql b/migrations/0027_notification_quiet_digest.sql
nnew file mode 100644
iindex 00000000..1788728c
---- /dev/null
++++ b/migrations/0027_notification_quiet_digest.sql
@@@ -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)
++);
ddiff --git a/public/css/my.css b/public/css/my.css
iindex dd45d00b..508666b9 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -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 */
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 7ad3843e..bc320bab 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -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();
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
iindex 06cbd8ce..16271359 100644
---- a/public/js/my/security.js
++++ b/public/js/my/security.js
@@@ -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;
ddiff --git a/public/my.html b/public/my.html
iindex ed1f7d4e..94a2171b 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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>
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 638fb225..c453ccce 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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.
ddiff --git a/server/index.js b/server/index.js
iindex 809b1025..23208430 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 minutes
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
// than 30 days go, in bounded batches; hourly/daily rollups stay.
ddiff --git a/server/notifications/digest.js b/server/notifications/digest.js
nnew file mode 100644
iindex 00000000..167ab46e
---- /dev/null
++++ b/server/notifications/digest.js
@@@ -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 };
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
iindex eec5ce0d..d3e48e79 100644
---- a/server/notifications/email-service.js
++++ b/server/notifications/email-service.js
@@@ -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">
ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
iindex 5e6a6e43..5c9e8971 100644
---- a/server/notifications/notification-service.js
++++ b/server/notifications/notification-service.js
@@@ -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 n
JOIN users u ON u.id = n.user_id
WHERE n.is_emailed = 0 AND n.is_dismissed = 0
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
LIMIT 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 email
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
if (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;
}
ddiff --git a/server/notifications/quiet-hours.js b/server/notifications/quiet-hours.js
nnew file mode 100644
iindex 00000000..880f9b68
---- /dev/null
++++ b/server/notifications/quiet-hours.js
@@@ -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,
++};
ddiff --git a/server/notifications/routes.js b/server/notifications/routes.js
iindex 1d6d83f3..43e73519 100644
---- a/server/notifications/routes.js
++++ b/server/notifications/routes.js
@@@ -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' });
}
});
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex f0b4cfdd..4825aeb0 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/notification-quiet-digest.test.js b/test/notification-quiet-digest.test.js
nnew file mode 100644
iindex 00000000..9244c68a
---- /dev/null
++++ b/test/notification-quiet-digest.test.js
@@@ -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); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex ede56cdd..9078a775 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');