diff --git a/docs/feature-notification-center.md b/docs/feature-notification-center.md index 82dddd6c..01992d7e 100644 --- a/docs/feature-notification-center.md +++ b/docs/feature-notification-center.md @@ -62,8 +62,8 @@ Ereignis (activity.log / gc.notify / Admin) ├─ getrennte Geräte beim nächsten Verbinden (Last-Event-ID) ├─ E-Mail sofort oder als Rückfall, wenn nach N Minuten kein Gerät bestätigt hat └─ Webhooks wie bisher -Gerät ──► POST /api/v1/client/push/ack (zugestellt / gelesen / Aktion) - ◄── Gelesen-Abgleich an die anderen Geräte derselben Person +Gerät ──► POST /api/v1/client/push/ack (zugestellt / gelesen / weggewischt / Aktion) + ◄── Gelesen-Abgleich an die anderen Geräte derselben Person (nur „gelesen“) ``` **Warum SSE und nicht WebSocket:** @@ -164,6 +164,12 @@ CREATE TABLE IF NOT EXISTS notify_device_prefs ( -- vom Gerät gemeldet `email_state`/`email_due_at`/`email_sent_at` (E-Mail-Rückfall), `meta` (nur Server: die zurückgehaltene E-Mail) und `updated_at`. * `notification_deliveries.silent`: in der Ruhezeit zugestellt. +* `notification_deliveries.read_via` (Migration v144 `notify_read_via`): woher + das „gelesen“ einer Zeile kommt — `self` (das Gerät selbst hat `read` + oder `dismissed` bestätigt), `device` (die Person hat es auf einem anderen + ihrer Geräte gelesen), `portal` (im Portal „Gelesen“/„Alle als gelesen“), + `elsewhere` (eins von beiden, Zeile von vor der Migration), `NULL` (nicht + gelesen). Siehe „Gelesen-Abgleich“ unten. * `notify_device_prefs` zusätzlich `client_type`, `via`, `last_seen_at`. Eine Zeile heißt: Auf diesem Token läuft eine App mit Push (Stream einmal geöffnet oder `prefs`/`test` gerufen). Nur solche Geräte bekommen eine @@ -173,6 +179,33 @@ CREATE TABLE IF NOT EXISTS notify_device_prefs ( -- vom Gerät gemeldet * Zeitstempel sind ISO-8601 UTC mit Millisekunden; an die Geräte gehen `created_at`/`expires_at` ohne Millisekunden. +**Gelesen-Abgleich:** + +* Nur ein echtes `read` eines Geräts (geöffnet, angetippt, Knopf, „Gelesen“) + markiert dieselbe Mitteilung auf den anderen Geräten derselben Person als + gelesen (`state='read'`, `read_via='device'`) und schickt den offenen + Streams `event: read`. Das Portal („Gelesen“, „Alle als gelesen“) wirkt + genauso auf alle Geräte (`read_via='portal'`). +* `dismissed` (weggewischt, Benachrichtigung geschlossen) gilt nur für die + Zeile dieses Geräts; die anderen Geräte behalten die Mitteilung als + ungelesen. (Früher ging auch `dismissed` als „gelesen“ an alle Geräte.) +* `delivered_at` setzt nur das Gerät selbst (jede Bestätigung, auch auf + einer schon anderswo gelesenen Zeile). Eine anderswo gelesene Zeile wird + nicht mehr gesendet (`pendingForToken` liefert nur `queued`/`sent`); im + Posteingang des Geräts steht sie als `read`. +* Admin-Zustellprotokoll: eine solche Zeile heißt „Anderswo gelesen“ + (statt „Gelesen“), darunter z. B. „nicht zugestellt · auf anderem Gerät + gelesen …“ bzw. „… · im Portal gelesen …“. „zugestellt“ zählt nur Zeilen + mit `delivered_at`, „gelesen“ nur Lesen auf dem Gerät selbst; eine + anderswo gelesene, nie zugestellte Zeile fehlt nicht (Status „ok“, im + Verlauf „1/2 · 1 anderswo gelesen“). „Bestätigt“ im Reiter Geräte ist die + letzte Bestätigung des Geräts selbst. +* Bestehende Zeilen (Migration v144): Die Herkunft wurde früher nicht + gespeichert. Eine eigene Bestätigung setzte immer `delivered_at`, der + Abgleich nie — gelesene Zeilen ohne `delivered_at` bekommen `elsewhere`, + alle anderen gelesenen `self` (eine Zeile, die das Gerät erhalten hatte und + die dann anderswo gelesen wurde, ist nachträglich nicht mehr erkennbar). + **Aufräumen:** Ein Job läuft stündlich. * Nicht zugestellte Nachrichten verfallen nach 72 h (Standard; je Priorität einstellbar). @@ -242,7 +275,7 @@ Alle Antworten tragen zusätzlich `"ok": true`. * `GET /api/v1/notify/overview` → `{kpis:{devices_connected, devices_total, direct, tunnel, delivered_24h, read_24h, queued, queued_devices, failed_7d, median_latency_ms}, recent:[{id, title, event_id, topic, priority, source, - created_at, recipients_label, delivered, total, read, silent}] (8), + created_at, recipients_label, delivered, total, read, elsewhere, silent}] (8), hub:{enabled, endpoint, keepalive_s, retention_h, max_queue, allow_direct}, sources:[{id: security|devices|services|system|plugins, count}] (7 Tage)}` * `GET /api/v1/notify/rules` → `{rules:[{event_id, group, label, priority, @@ -266,10 +299,12 @@ Alle Antworten tragen zusätzlich `"ok": true`. ohne Lizenz; Limit wie bisher (10 je Minute und Konto). * `GET /api/v1/notify/history?filter=all|important|undelivered|plugins|manual&days=7&before=&limit=50` → `{items:[{id, title, body, event_id, source, priority, created_at, - delivered, total, read, recipients_label, silent, status: ok|partial|waiting}], next_before}` + delivered, total, read, elsewhere, recipients_label, silent, status: ok|partial|waiting}], next_before}` + (`delivered`: vom Gerät bestätigt; `read`: auf dem Gerät selbst gelesen; + `elsewhere`: nie zugestellt, weil anderswo gelesen — siehe „Gelesen-Abgleich“) * `GET /api/v1/notify/history/:id` → `{notification, timeline:[{at, kind, text}], deliveries:[{token_id, device_name, user_name, state, via, queued_at, sent_at, - delivered_at, read_at, latency_ms, action}], email:{sent, at}}`; + delivered_at, read_at, read_via, latency_ms, action}], email:{sent, at}}`; `POST …/history/:id/resend` → `{ok}` * `GET/PUT /api/v1/notify/settings` → `{enabled, retention_h, history_days, max_queue, keepalive_s, allow_direct, email_fallback_s, max_streams}` @@ -676,7 +711,7 @@ data: {"seq":123,"id":45,"event_id":"gateway_state","topic":"devices","priority" "facts":[{"label":"Zuletzt gesehen","value":"10.10.26, 21:40"},{"label":"Betroffene Routen","value":"3"}]}} event: read -data: {"ids":[45]} # auf einem anderen Gerät derselben Person gelesen → hier ausblenden +data: {"ids":[45]} # auf einem anderen Gerät derselben Person (oder im Portal) gelesen → hier ausblenden event: revoke data: {"ids":[45]} # zurückgezogen (z. B. Entwarnung ersetzt Meldung) → ausblenden @@ -1018,8 +1053,9 @@ GET /api/v1/client/plugins//media/ (auch HEAD) (`action` optional, `[A-Za-z0-9_.:-]{1,64}` — auch die `id` einer `quick_action`; höchstens 200 `seqs`). * Antwort: `{"ok":true}`. -* `read` und `dismissed` werden an die anderen Geräte derselben Person als - `event: read` weitergegeben. +* Nur `read` wird an die anderen Geräte derselben Person als `event: read` + weitergegeben (und dort als gelesen gespeichert). `dismissed` gilt nur für + dieses Gerät. ### `GET /api/v1/client/push/inbox?limit=100&before=` diff --git a/public/js/notifications.js b/public/js/notifications.js index 848ded00..239120e0 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -197,10 +197,12 @@ const total = count(o.total); const delivered = count(o.delivered); const read = count(o.read); + // read elsewhere before it reached the device: not delivered, but nothing is missing either + const elsewhere = count(o.elsewhere); if (bool(o.silent)) return { key: 'notify.status.silent', params: {}, tone: 'muted' }; if (!total) return { key: null, text: '–', params: {}, tone: 'muted' }; if (read >= total) return { key: 'notify.status.read', params: { read, total }, tone: 'good' }; - return { key: 'notify.status.delivered', params: { delivered, total }, tone: delivered >= total ? 'good' : 'warn' }; + return { key: 'notify.status.delivered', params: { delivered, total }, tone: delivered + elsewhere >= total ? 'good' : 'warn' }; } /** Status column of the history (ok / waiting / partial). */ function histStatusOf(item) { @@ -208,15 +210,27 @@ const total = count(o.total); const delivered = count(o.delivered); const read = count(o.read); - const open = Math.max(0, total - delivered); + const elsewhere = count(o.elsewhere); + const open = Math.max(0, total - delivered - elsewhere); if (!total) return bool(o.silent) ? { key: 'notify.status.silent', params: {}, tone: 'muted' } : { key: null, text: '–', params: {}, tone: 'muted' }; const status = ['ok', 'partial', 'waiting'].indexOf(o.status) >= 0 ? o.status : (open ? 'waiting' : 'ok'); if (status === 'partial' && open) return { key: 'notify.hist.status_partial', params: { delivered, total, n: open }, tone: 'crit' }; if (status === 'waiting' && open) return { key: 'notify.hist.status_waiting', params: { delivered, total, n: open }, tone: 'warn' }; if (read >= total) return { key: 'notify.status.read', params: { read, total }, tone: 'good' }; + if (elsewhere) return { key: 'notify.hist.status_elsewhere', params: { delivered, total, n: elsewhere }, tone: 'good' }; return { key: 'notify.hist.status_ok', params: { delivered, total }, tone: 'good' }; } + /** Zustellprotokoll: marked read because the person read it on another device or in the portal. */ + function readElsewhere(dv) { return isObj(dv) && !!dv.read_at && ['device', 'portal', 'elsewhere'].indexOf(dv.read_via) >= 0; } + /** The state shown for a delivery: "Anderswo gelesen" apart from "Gelesen". */ + function deliveryState(dv) { + const s = isObj(dv) ? str(dv.state) : ''; + return s === 'read' && readElsewhere(dv) ? 'read_elsewhere' : s; + } + /** Text key of the read part of a delivery line ("geöffnet …", "auf anderem Gerät gelesen …"). */ + function readKey(dv) { return readElsewhere(dv) ? 'notify.proto.read_' + dv.read_via : 'notify.proto.read_at'; } + /** Seconds as a duration key: {key, params} for the "seit …" texts. */ function durationOf(ms) { const s = Math.max(0, Math.floor(num(ms, 0) / 1000)); @@ -342,6 +356,7 @@ TABS, PRIORITIES, GROUPS, SOURCES, FILTERS, DEVICE_STATES, RULE_FIELDS, SETTINGS_FIELDS, TTL_CHOICES, HISTORY_LIMIT, GROUP_SOURCE, fieldCodes, emailLocked, norm, parseHash, hashFor, prioOf, prioTone, idList, normRecipients, sameRecipients, hasRecipient, normRule, copyRule, ruleDiff, ruleMatches, groupRules, ruleLocked, recipientParts, statusOf, histStatusOf, durationOf, spanOf, choicesWith, + readElsewhere, deliveryState, readKey, latencyNumber, reachOf, noPush, ttlChoices, sendBody, sourceKind, historyQuery, normSettings, settingsDiff, rangeError, }; if (!win || !win.document) return pure; @@ -1463,6 +1478,7 @@ const btn = was ? $('nc-hist-body').querySelector('button[data-id="' + cssEsc(was) + '"]') : null; if (btn) btn.focus(); } + function readText(dv) { return T(readKey(dv), { time: fmtTime(dv.read_at, true) }); } function deliveryLine(dv) { const parts = []; if (dv.delivered_at) { @@ -1472,14 +1488,17 @@ else if (dv.latency_ms != null) s += ' (' + latencyText(dv.latency_ms) + ')'; else if (via) s += ' (' + via + ')'; parts.push(s); - parts.push(dv.read_at ? T('notify.proto.read_at', { time: fmtTime(dv.read_at, true) }) : T('notify.proto.not_read')); + parts.push(dv.read_at ? readText(dv) : T('notify.proto.not_read')); + } else if (readElsewhere(dv)) { + // never reached this device: it is not sent any more either + parts.push(T('notify.proto.not_delivered'), readText(dv)); } else if (dv.queued_at) { parts.push(T('notify.proto.queued_at', { time: fmtWhen(dv.queued_at, true) })); } if (dv.action) parts.push(T('notify.proto.action', { name: str(dv.action) })); return parts.join(' · '); } - function stateTone(s) { return { read: 'good', delivered: 'info', sent: 'info', queued: 'warn', dismissed: 'muted', expired: 'crit', failed: 'crit', revoked: 'muted' }[s] || 'muted'; } + function stateTone(s) { return { read: 'good', read_elsewhere: 'muted', delivered: 'info', sent: 'info', queued: 'warn', dismissed: 'muted', expired: 'crit', failed: 'crit', revoked: 'muted' }[s] || 'muted'; } function renderDetail(id, res, error) { const box = clear($('nc-proto')); const head = el('div', { class: 'nc-aside-head' }); @@ -1528,10 +1547,10 @@ box.appendChild(el('h3', { class: 'nc-label', text: T('notify.proto.deliveries') })); if (!dl.length) box.appendChild(el('p', { class: 'nc-hint', text: T('notify.proto.no_deliveries') })); else { - box.appendChild(el('ul', { class: 'nc-deliveries' }, dl.map((dv) => el('li', { class: 'nc-delivery', 'data-state': str(dv.state) }, [ + box.appendChild(el('ul', { class: 'nc-deliveries' }, dl.map((dv) => el('li', { class: 'nc-delivery', 'data-state': deliveryState(dv) }, [ el('div', { class: 'nc-delivery-head' }, [ el('b', { text: [str(dv.device_name), str(dv.user_name)].filter(Boolean).join(' · ') || '#' + str(dv.token_id) }), - el('span', { class: 'nc-status nc-pill-sm', 'data-tone': stateTone(dv.state), text: label('notify.dstate.', str(dv.state)) }), + el('span', { class: 'nc-status nc-pill-sm', 'data-tone': stateTone(deliveryState(dv)), text: label('notify.dstate.', deliveryState(dv)) }), ]), el('div', { class: 'nc-sub', text: deliveryLine(dv) }), ])))); diff --git a/src/db/migrationList.js b/src/db/migrationList.js index eebb824d..c99ea5fe 100644 --- a/src/db/migrationList.js +++ b/src/db/migrationList.js @@ -3887,6 +3887,31 @@ const migrations = [ ALTER TABLE ip_block_failures ADD COLUMN unknown_user INTEGER NOT NULL DEFAULT 0;`, detect: (db) => hasColumn(db, 'ip_block_failures', 'weight') && hasColumn(db, 'ip_block_failures', 'unknown_user'), }, + { + version: 144, + name: 'notify_read_via', + // Benachrichtigungen (docs/feature-notification-center.md "Gelesen-Abgleich"): + // where the "gelesen" of a delivery row comes from — + // read_via 'self' the device itself sent read/dismissed (ack) + // 'device' the person read it on another of their devices + // 'portal' the person read it in the portal ("Gelesen", + // "Alle als gelesen") + // 'elsewhere' read on another device or in the portal — rows + // from before this column (origin not recorded) + // NULL not read (yet) + // The admin's Zustellprotokoll shows a row not read by the device itself + // as "Anderswo gelesen" and does not count it as delivered. + // Existing rows: the origin was never stored. A device's own ack always + // set delivered_at, the read-sync never did — so a read row without + // delivered_at was read elsewhere; all other read rows count as 'self' + // (a row the device had received and that was then read elsewhere cannot + // be told apart any more). + sql: ` + ALTER TABLE notification_deliveries ADD COLUMN read_via TEXT; + UPDATE notification_deliveries SET read_via = CASE WHEN delivered_at IS NULL THEN 'elsewhere' ELSE 'self' END + WHERE read_at IS NOT NULL;`, + detect: (db) => hasColumn(db, 'notification_deliveries', 'read_via'), + }, ]; module.exports = { migrations }; diff --git a/src/i18n/de.json b/src/i18n/de.json index 8864f562..9f0ecfd5 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -4100,6 +4100,7 @@ "notify.hist.status_ok": "{{delivered}}/{{total}}", "notify.hist.status_waiting": "{{delivered}}/{{total}} · {{n}} wartet", "notify.hist.status_partial": "{{delivered}}/{{total}} · {{n}} nicht zugestellt", + "notify.hist.status_elsewhere": "{{delivered}}/{{total}} · {{n}} anderswo gelesen", "notify.hist.silent_meta": "still zugestellt", "notify.hist.pick": "Wähle eine Nachricht, um ihr Zustellprotokoll zu sehen.", "notify.proto.eyebrow": "Zustellprotokoll", @@ -4112,6 +4113,10 @@ "notify.proto.latency_via": "({{latency}}, {{via}})", "notify.proto.read_at": "geöffnet {{time}}", "notify.proto.not_read": "noch nicht geöffnet", + "notify.proto.not_delivered": "nicht zugestellt", + "notify.proto.read_device": "auf anderem Gerät gelesen {{time}}", + "notify.proto.read_portal": "im Portal gelesen {{time}}", + "notify.proto.read_elsewhere": "anderswo gelesen {{time}}", "notify.proto.queued_at": "wartet seit {{time}}", "notify.proto.action": "Aktion „{{name}}“", "notify.proto.email_sent": "Zusätzlich per E-Mail gesendet um {{time}}.", @@ -4125,6 +4130,7 @@ "notify.dstate.sent": "Gesendet", "notify.dstate.delivered": "Zugestellt", "notify.dstate.read": "Gelesen", + "notify.dstate.read_elsewhere": "Anderswo gelesen", "notify.dstate.dismissed": "Ausgeblendet", "notify.dstate.expired": "Abgelaufen", "notify.dstate.failed": "Fehlgeschlagen", @@ -4138,6 +4144,7 @@ "notify.tl.queued": "In der Warteschlange", "notify.tl.delivered": "Zugestellt", "notify.tl.read": "Gelesen", + "notify.tl.read_elsewhere": "Anderswo gelesen", "notify.tl.email": "E-Mail gesendet", "notify.tl.webhook": "Webhook ausgelöst", "notify.tl.resent": "Erneut gesendet", @@ -6463,6 +6470,7 @@ "push.timeline.sent": "An {{count}} Geräte gesendet", "push.timeline.delivered": "Auf {{count}} Geräten zugestellt", "push.timeline.read": "Auf {{count}} Geräten gelesen", + "push.timeline.read_elsewhere": "Anderswo gelesen: {{count}} Geräte als gelesen markiert", "push.timeline.email": "E-Mail gesendet", "push.timeline.revoked": "Zurückgezogen (Entwarnung)", "push.chat_hidden": "Chat-Nachricht (Inhalt privat)", diff --git a/src/i18n/en.json b/src/i18n/en.json index f50280a5..bceb7fb5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4100,6 +4100,7 @@ "notify.hist.status_ok": "{{delivered}}/{{total}}", "notify.hist.status_waiting": "{{delivered}}/{{total}} · {{n}} waiting", "notify.hist.status_partial": "{{delivered}}/{{total}} · {{n}} not delivered", + "notify.hist.status_elsewhere": "{{delivered}}/{{total}} · {{n}} read elsewhere", "notify.hist.silent_meta": "delivered silently", "notify.hist.pick": "Pick a message to see its delivery log.", "notify.proto.eyebrow": "Delivery log", @@ -4112,6 +4113,10 @@ "notify.proto.latency_via": "({{latency}}, {{via}})", "notify.proto.read_at": "opened {{time}}", "notify.proto.not_read": "not opened yet", + "notify.proto.not_delivered": "not delivered", + "notify.proto.read_device": "read on another device {{time}}", + "notify.proto.read_portal": "read in the portal {{time}}", + "notify.proto.read_elsewhere": "read elsewhere {{time}}", "notify.proto.queued_at": "waiting since {{time}}", "notify.proto.action": "Action “{{name}}”", "notify.proto.email_sent": "Also sent by email at {{time}}.", @@ -4125,6 +4130,7 @@ "notify.dstate.sent": "Sent", "notify.dstate.delivered": "Delivered", "notify.dstate.read": "Read", + "notify.dstate.read_elsewhere": "Read elsewhere", "notify.dstate.dismissed": "Dismissed", "notify.dstate.expired": "Expired", "notify.dstate.failed": "Failed", @@ -4138,6 +4144,7 @@ "notify.tl.queued": "Queued", "notify.tl.delivered": "Delivered", "notify.tl.read": "Read", + "notify.tl.read_elsewhere": "Read elsewhere", "notify.tl.email": "Email sent", "notify.tl.webhook": "Webhook fired", "notify.tl.resent": "Sent again", @@ -6463,6 +6470,7 @@ "push.timeline.sent": "Sent to {{count}} devices", "push.timeline.delivered": "Delivered to {{count}} devices", "push.timeline.read": "Read on {{count}} devices", + "push.timeline.read_elsewhere": "Read elsewhere: {{count}} devices marked as read", "push.timeline.email": "E-mail sent", "push.timeline.revoked": "Withdrawn (all clear)", "push.chat_hidden": "Chat message (content private)", diff --git a/src/services/notify/admin.js b/src/services/notify/admin.js index 09de67f5..35f4bf5b 100644 --- a/src/services/notify/admin.js +++ b/src/services/notify/admin.js @@ -10,16 +10,28 @@ const config = require('./config'); const { ENDPOINT } = require('./constants'); const DONE = "('delivered', 'read', 'dismissed')"; +// The device itself read it (or swiped it away). A read row without +// read_via (cannot occur after migration 144) counts as read on the device. +const SELF_READ = "(read_at IS NOT NULL AND COALESCE(read_via, 'self') = 'self')"; +// Marked read because the person read it on another device or in the portal. +const ELSEWHERE_READ = "(read_at IS NOT NULL AND COALESCE(read_via, 'self') <> 'self')"; function placeholders(list) { return list.map(() => '?').join(','); } -/** Delivery counts per notification id. */ +/** + * Delivery counts per notification id. `delivered`: the device confirmed it + * (delivered_at); `read`: read on the device itself; `elsewhere`: never + * reached the device, the person read it elsewhere first (nothing is sent + * any more — not "zugestellt", but not missing either). + */ function statsFor(ids) { const out = new Map(); if (!ids.length) return out; const rows = getDb().prepare(`SELECT notification_id AS id, COUNT(*) AS total, - SUM(CASE WHEN state IN ${DONE} THEN 1 ELSE 0 END) AS delivered, - SUM(CASE WHEN read_at IS NOT NULL THEN 1 ELSE 0 END) AS read, + SUM(CASE WHEN delivered_at IS NOT NULL THEN 1 ELSE 0 END) AS delivered, + SUM(CASE WHEN ${SELF_READ} THEN 1 ELSE 0 END) AS read, + SUM(CASE WHEN delivered_at IS NULL AND ${ELSEWHERE_READ} THEN 1 ELSE 0 END) AS elsewhere, + SUM(CASE WHEN ${ELSEWHERE_READ} THEN 1 ELSE 0 END) AS read_elsewhere, SUM(silent) AS silent, SUM(CASE WHEN state IN ('queued', 'sent') THEN 1 ELSE 0 END) AS waiting FROM notification_deliveries WHERE notification_id IN (${placeholders(ids)}) GROUP BY notification_id`).all(...ids); @@ -30,7 +42,7 @@ function statsFor(ids) { function statusOf(n, st) { const live = !n.expires_at || n.expires_at > store.iso(); if (n.release_at || (live && st && st.waiting > 0)) return 'waiting'; - if (!st || st.delivered === st.total) return 'ok'; + if (!st || st.delivered + (st.elsewhere || 0) >= st.total) return 'ok'; return 'partial'; } @@ -76,7 +88,7 @@ function recipientsLabel(n, t) { } function itemOf(n, st, t) { - const s = st || { total: 0, delivered: 0, read: 0, silent: 0 }; + const s = st || { total: 0, delivered: 0, read: 0, elsewhere: 0, silent: 0 }; // a chat notification (docs/plugins.md "Chat notifications") is a private // message between people: the administrator sees that it went out, never its text const chat = n.kind === 'message'; @@ -95,6 +107,7 @@ function itemOf(n, st, t) { delivered: s.delivered || 0, total: s.total || 0, read: s.read || 0, + elsewhere: s.elsewhere || 0, silent: n.silent === 1 || (s.total > 0 && s.silent === s.total), status: statusOf(n, st), }; @@ -170,7 +183,7 @@ function overview(t) { direct: conns.filter((c) => c.via === 'direct').length, tunnel: conns.filter((c) => c.via === 'tunnel').length, delivered_24h: db.prepare('SELECT COUNT(*) AS n FROM notification_deliveries WHERE delivered_at >= ?').get(day).n, - read_24h: db.prepare('SELECT COUNT(*) AS n FROM notification_deliveries WHERE read_at >= ?').get(day).n, + read_24h: db.prepare(`SELECT COUNT(*) AS n FROM notification_deliveries WHERE read_at >= ? AND ${SELF_READ}`).get(day).n, queued: queued.n, queued_devices: queued.devices, failed_7d: db.prepare(`SELECT COUNT(*) AS n FROM notification_deliveries WHERE state = 'expired' AND queued_at >= ?`).get(week).n, @@ -218,8 +231,9 @@ function devices() { JOIN notifications n ON n.id = d.notification_id WHERE d.state = 'queued' AND n.revoked_at IS NULL AND (n.expires_at IS NULL OR n.expires_at > ?) GROUP BY d.token_id`).all(nowIso) .map((r) => [r.token_id, r])); - const acks = new Map(db.prepare(`SELECT token_id, MAX(COALESCE(read_at, delivered_at)) AS at FROM notification_deliveries - WHERE delivered_at IS NOT NULL OR read_at IS NOT NULL GROUP BY token_id`).all().map((r) => [r.token_id, r.at])); + // "Bestätigt": the last ack the device itself sent — a read elsewhere is none + const acks = new Map(db.prepare(`SELECT token_id, MAX(COALESCE(CASE WHEN ${SELF_READ} THEN read_at END, delivered_at)) AS at + FROM notification_deliveries WHERE delivered_at IS NOT NULL OR ${SELF_READ} GROUP BY token_id`).all().map((r) => [r.token_id, r.at])); const list = rows.map((r) => { const conn = stream.get(r.id); let state = 'unsupported'; @@ -291,6 +305,9 @@ function detail(id, t) { sent_at: d.sent_at, delivered_at: d.delivered_at, read_at: d.read_at, + // who set read_at: 'self' (this device), 'device' (another device of the + // person), 'portal', 'elsewhere' (one of the two, rows from before the origin was stored) — null: not read + read_via: d.read_at ? (d.read_via || 'self') : null, latency_ms: d.delivered_at && d.queued_at ? Math.max(0, Date.parse(d.delivered_at) - Date.parse(d.queued_at)) : null, action: d.action || null, })); @@ -302,8 +319,11 @@ function detail(id, t) { if (firstSent) timeline.push({ at: firstSent, kind: 'sent', text: t('push.timeline.sent', { count: String(rows.filter((r) => r.sent_at).length) }) }); const firstDelivered = first('delivered_at'); if (firstDelivered) timeline.push({ at: firstDelivered, kind: 'delivered', text: t('push.timeline.delivered', { count: String(st ? st.delivered : 0) }) }); - const firstRead = first('read_at'); + const selfRows = rows.filter((r) => r.read_at && !store.readElsewhere(r)); + const firstRead = selfRows.map((r) => r.read_at).sort()[0] || null; if (firstRead) timeline.push({ at: firstRead, kind: 'read', text: t('push.timeline.read', { count: String(st ? st.read : 0) }) }); + const firstElsewhere = rows.filter((r) => store.readElsewhere(r)).map((r) => r.read_at).sort()[0] || null; + if (firstElsewhere) timeline.push({ at: firstElsewhere, kind: 'read_elsewhere', text: t('push.timeline.read_elsewhere', { count: String(st ? st.read_elsewhere : 0) }) }); if (n.email_state === 'sent' && n.email_sent_at) timeline.push({ at: n.email_sent_at, kind: 'email', text: t('push.timeline.email') }); if (n.revoked_at) timeline.push({ at: n.revoked_at, kind: 'revoked', text: t('push.timeline.revoked') }); const expired = rows.filter((r) => r.state === 'expired').length; diff --git a/src/services/notify/hub.js b/src/services/notify/hub.js index ed154a9c..231ebf6a 100644 --- a/src/services/notify/hub.js +++ b/src/services/notify/hub.js @@ -673,12 +673,16 @@ function syncRead(userId, ids, exceptToken = null) { } } -/** After a device ack: read/dismissed reach the person's other devices. */ +/** + * After a device ack: a real `read` (opened, tapped, a button, "gelesen") + * reaches the person's other devices. `dismissed` (swiped away, closed) + * stays on that device: the person has not read it. + */ function onAck(tokenId, userId, changed, state) { - if (state !== 'read' && state !== 'dismissed') return; + if (state !== 'read') return; const ids = [...new Set(changed.map((c) => c.notification_id))]; if (!ids.length || userId == null) return; - store.markReadForUser(userId, ids, { exceptToken: tokenId }); + store.markReadForUser(userId, ids, { exceptToken: tokenId, via: 'device' }); syncRead(userId, ids, tokenId); } @@ -688,7 +692,7 @@ function markRead(userId, ids) { // "all" = the inbox: chat notifications are not part of it (store.NOT_CHAT_SQL) const list = ids || db.prepare(`SELECT DISTINCT d.notification_id AS id FROM notification_deliveries d JOIN notifications n ON n.id = d.notification_id WHERE d.user_id = ? AND d.state IN ('queued', 'sent', 'delivered') AND ${store.NOT_CHAT_SQL}`).all(userId).map((r) => r.id); - const rows = store.markReadForUser(userId, list); + const rows = store.markReadForUser(userId, list, { via: 'portal' }); const changedIds = [...new Set(rows.map((r) => r.notification_id))]; syncRead(userId, changedIds); return changedIds.length; diff --git a/src/services/notify/store.js b/src/services/notify/store.js index da5ac23c..20fe3f04 100644 --- a/src/services/notify/store.js +++ b/src/services/notify/store.js @@ -157,27 +157,36 @@ const ACK_FROM = { dismissed: ['queued', 'sent', 'delivered', 'read'], }; +/** read_via of a row the device did not read itself (migration 144). */ +function readElsewhere(row) { return !!row && row.read_at != null && row.read_via != null && row.read_via !== 'self'; } + /** * A device confirms its own deliveries. Returns the rows that changed - * ({ seq, notification_id, user_id }). `read`/`dismissed` also mark the - * same person's other deliveries of those notifications as read. + * ({ seq, notification_id, user_id }). Only the device's own rows change; + * the read-sync to the person's other devices is hub.onAck (real `read` only). */ function ack(tokenId, seqs, state, action) { const db = getDb(); const t = iso(); const from = ACK_FROM[state]; const list = seqs.length ? seqs : [-1]; - const rows = db.prepare(`SELECT seq, notification_id, user_id, state FROM notification_deliveries + const rows = db.prepare(`SELECT seq, notification_id, user_id, state, delivered_at, read_at, read_via FROM notification_deliveries WHERE token_id = ? AND seq IN (${list.map(() => '?').join(',')})`).all(tokenId, ...list); const changed = []; db.transaction(() => { for (const r of rows) { const fields = []; const args = []; + // Any ack proves the device has the notification — also one already + // marked read elsewhere (read-sync) before the device confirmed it. + if (r.delivered_at == null && r.state !== 'expired' && r.state !== 'suppressed') { fields.push('delivered_at = ?'); args.push(t); } if (from.includes(r.state)) { fields.push('state = ?'); args.push(state); - if (state === 'delivered' || r.state === 'queued' || r.state === 'sent') { fields.push('delivered_at = COALESCE(delivered_at, ?)'); args.push(t); } - if (state === 'read' || state === 'dismissed') { fields.push('read_at = COALESCE(read_at, ?)'); args.push(t); } + // read_via tells who set read_at: kept when it was already set (read elsewhere, then swiped away here) + if ((state === 'read' || state === 'dismissed') && r.read_at == null) { fields.push('read_at = ?', "read_via = 'self'"); args.push(t); } + } else if (state === 'read' && r.state === 'read' && readElsewhere(r)) { + // read elsewhere first, now opened on this device too: its own read from now on + fields.push('read_at = ?', "read_via = 'self'"); args.push(t); } if (action) { fields.push('action = ?'); args.push(action); } if (!fields.length) continue; @@ -191,8 +200,11 @@ function ack(tokenId, seqs, state, action) { /** * Mark the deliveries of `notificationIds` of one person as read (read-sync, * portal "gelesen"). Returns the token ids whose rows changed, per id. + * `via`: where the person read it — 'device' (another of their devices) or + * 'portal'; stored as read_via, the admin sees "Anderswo gelesen". + * delivered_at stays as it is: the device did not get anything new. */ -function markReadForUser(userId, notificationIds, { exceptToken = null } = {}) { +function markReadForUser(userId, notificationIds, { exceptToken = null, via = 'device' } = {}) { if (!notificationIds.length) return []; const db = getDb(); const t = iso(); @@ -200,8 +212,10 @@ function markReadForUser(userId, notificationIds, { exceptToken = null } = {}) { const rows = db.prepare(`SELECT seq, token_id, notification_id FROM notification_deliveries WHERE user_id = ? AND notification_id IN (${ph}) AND state IN ('queued', 'sent', 'delivered') ${exceptToken != null ? 'AND token_id <> ?' : ''}`).all(...[userId, ...notificationIds, ...(exceptToken != null ? [exceptToken] : [])]); - const upd = db.prepare(`UPDATE notification_deliveries SET state = 'read', read_at = COALESCE(read_at, ?) WHERE seq = ?`); - db.transaction(() => { for (const r of rows) upd.run(t, r.seq); })(); + const upd = db.prepare(`UPDATE notification_deliveries SET state = 'read', read_at = COALESCE(read_at, ?), + read_via = CASE WHEN read_at IS NULL THEN ? ELSE read_via END WHERE seq = ?`); + const v = via === 'portal' ? 'portal' : 'device'; + db.transaction(() => { for (const r of rows) upd.run(t, v, r.seq); })(); return rows; } @@ -273,7 +287,7 @@ module.exports = { NOT_CHAT_SQL, now, iso, wire, parseJson, createNotification, getNotification, updateNotification, deleteNotification, latestByCollapse, - addDeliveries, requeue, deliveriesOf, markSent, pendingForToken, deliveryRow, payload, imageUrlOf, ack, markReadForUser, + addDeliveries, requeue, deliveriesOf, markSent, pendingForToken, deliveryRow, payload, imageUrlOf, ack, markReadForUser, readElsewhere, inbox, unreadCount, queuedCount, revokedForToken, devicePrefs, touchDevice, seen, setDevicePrefs, _setClock, diff --git a/tests/notify_admin_api.test.js b/tests/notify_admin_api.test.js index 3076e289..cdc4665f 100644 --- a/tests/notify_admin_api.test.js +++ b/tests/notify_admin_api.test.js @@ -83,7 +83,7 @@ describe('GET /overview', () => { assert.equal(r.body.kpis.queued, 1, 'anna is offline'); assert.equal(r.body.kpis.queued_devices, 1); assert.ok(r.body.kpis.devices_total >= 3); - assert.deepEqual(keys(r.body.recent[0]), ['created_at', 'delivered', 'event_id', 'id', 'priority', 'read', 'recipients_label', 'silent', 'source', 'title', 'topic', 'total']); + assert.deepEqual(keys(r.body.recent[0]), ['created_at', 'delivered', 'elsewhere', 'event_id', 'id', 'priority', 'read', 'recipients_label', 'silent', 'source', 'title', 'topic', 'total']); assert.equal(r.body.recent[0].total, 2); assert.equal(r.body.recent[0].source, `manual:${adminId}`); assert.deepEqual(r.body.hub, { enabled: true, endpoint: '/api/v1/client/push', keepalive_s: 25, retention_h: 72, max_queue: 200, allow_direct: true }); @@ -280,7 +280,7 @@ describe('history', () => { it('GET /history: items, filters, paging', async () => { let r = await get('/history').expect(200); assert.deepEqual(keys(r.body), ['items', 'next_before', 'ok']); - assert.deepEqual(keys(r.body.items[0]), ['body', 'created_at', 'delivered', 'event_id', 'id', 'priority', 'read', 'recipients_label', 'silent', 'source', 'status', 'title', 'total']); + assert.deepEqual(keys(r.body.items[0]), ['body', 'created_at', 'delivered', 'elsewhere', 'event_id', 'id', 'priority', 'read', 'recipients_label', 'silent', 'source', 'status', 'title', 'total']); assert.equal(r.body.items.length, 2); assert.equal(r.body.next_before, null); const m = r.body.items.find((i) => i.id === manualId); @@ -307,7 +307,7 @@ describe('history', () => { const r = await get(`/history/${manualId}`).expect(200); assert.deepEqual(keys(r.body), ['deliveries', 'email', 'notification', 'ok', 'timeline']); assert.equal(r.body.notification.id, manualId); - assert.deepEqual(keys(r.body.deliveries[0]), ['action', 'delivered_at', 'device_name', 'latency_ms', 'queued_at', 'read_at', 'sent_at', 'state', 'token_id', 'user_name', 'via']); + assert.deepEqual(keys(r.body.deliveries[0]), ['action', 'delivered_at', 'device_name', 'latency_ms', 'queued_at', 'read_at', 'read_via', 'sent_at', 'state', 'token_id', 'user_name', 'via']); const a = r.body.deliveries.find((d) => d.token_id === annaPhone.tokenId); assert.equal(a.state, 'read'); assert.equal(a.action, 'details'); diff --git a/tests/notify_read_elsewhere.test.js b/tests/notify_read_elsewhere.test.js new file mode 100644 index 00000000..de193477 --- /dev/null +++ b/tests/notify_read_elsewhere.test.js @@ -0,0 +1,281 @@ +'use strict'; + +// Read-sync of the notification center (docs/feature-notification-center.md +// "Gelesen-Abgleich"): only a real `read` reaches the person's other devices +// (stored as read_via 'device' / 'portal'), a `dismissed` (swiped away) stays +// on the device that sent it. The admin read models count only what the device +// itself confirmed: "zugestellt" = delivered_at, "gelesen" = read on the +// device, "Bestätigt" = the device's own last ack; a row read elsewhere is +// "Anderswo gelesen". Migration 144 classifies existing rows. + +const crypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); + +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const { setup, teardown } = require('./helpers/setup'); +const H = require('./helpers/notify'); + +let app, agent, hub, stream, store, admin, adminId, anna, annaPhone, annaLaptop; + +before(async () => { + ({ app, agent } = await setup()); + hub = require('../src/services/notify/hub'); + stream = require('../src/services/notify/stream'); + store = require('../src/services/notify/store'); + admin = require('../src/services/notify/admin'); + adminId = H.db().prepare("SELECT id FROM users WHERE username = 'admin'").get().id; + anna = H.makeUser('anna', { language: 'de' }); + annaPhone = H.makeDevice(anna, { name: 'anna-phone' }); + annaLaptop = H.makeDevice(anna, { name: 'anna-laptop', platform: 'windows' }); +}); +after(() => { stream._resetForTest(); teardown(); }); +beforeEach(() => { stream._resetForTest(); H.clearNotifications(); }); + +const ack = (dev, seqs, state, action) => { + const r = supertest(app).post('/api/v1/client/push/ack').set('X-API-Token', dev.raw) + .set('X-Client-Platform', 'android').set('X-Client-Version', '2.0.0'); + return r.send({ seqs, state, ...(action ? { action } : {}) }).expect(200, { ok: true }); +}; +/** An open stream of `dev` that records what the server writes. */ +function fakeConn(dev, userId) { + const out = []; + const res = { write: (s) => { out.push(String(s)); return true; }, end() {}, once() {} }; + stream.add({ tokenId: dev.tokenId, userId, via: 'tunnel', res, connectedAt: new Date().toISOString() }); + return { readEvents: () => out.filter((s) => /^event: read$/m.test(s)) }; +} +const toAnna = (title = 'Hallo') => hub.sendManual({ userId: adminId, target: { type: 'users', ids: [anna] }, title, body: 'Text', priority: 'normal', ttlS: null }); +const row = (id, dev) => H.deliveries(id).find((d) => d.token_id === dev.tokenId); +const t = (key, vars) => `${key}${vars ? JSON.stringify(vars) : ''}`; + +describe('dismissed stays on the device', () => { + it('swiping away on the phone leaves the laptop unread and sends no read event', async () => { + const laptop = fakeConn(annaLaptop, anna); + const r = toAnna(); + const phoneSeq = row(r.id, annaPhone).seq; + await ack(annaPhone, [phoneSeq], 'dismissed'); + const phone = row(r.id, annaPhone); + assert.equal(phone.state, 'dismissed'); + assert.equal(phone.read_via, 'self'); + assert.ok(phone.delivered_at && phone.read_at); + const lap = row(r.id, annaLaptop); + assert.equal(lap.state, 'sent', 'the laptop keeps it'); + assert.equal(lap.read_at, null); + assert.equal(lap.read_via, null); + assert.deepEqual(laptop.readEvents(), []); + // the laptop still counts it as unread and gets it in its inbox as such + assert.equal(store.unreadCount(annaLaptop.tokenId), 1); + assert.deepEqual(store.inbox(annaLaptop.tokenId).map((i) => i.state), ['delivered']); + }); + + it('an offline device still gets a dismissed notification when it reconnects', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'dismissed'); + assert.deepEqual(store.pendingForToken(annaLaptop.tokenId).map((p) => p.notification_id), [r.id]); + }); +}); + +describe('read reaches the other devices, marked as read elsewhere', () => { + it('a read on the phone: laptop row read with read_via device, no delivered_at, read event', async () => { + const laptop = fakeConn(annaLaptop, anna); + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + const phone = row(r.id, annaPhone); + assert.equal(phone.state, 'read'); + assert.equal(phone.read_via, 'self'); + const lap = row(r.id, annaLaptop); + assert.equal(lap.state, 'read'); + assert.equal(lap.read_via, 'device'); + assert.equal(lap.delivered_at, null, 'the laptop never confirmed it'); + assert.ok(lap.read_at); + assert.equal(laptop.readEvents().length, 1); + assert.match(laptop.readEvents()[0], new RegExp(`"ids":\\[${r.id}\\]`)); + // the apps keep seeing it as read and do not get it again + assert.deepEqual(store.inbox(annaLaptop.tokenId).map((i) => i.state), ['read']); + assert.equal(store.unreadCount(annaLaptop.tokenId), 0); + assert.deepEqual(store.pendingForToken(annaLaptop.tokenId), []); + }); + + it('a dismiss after a read keeps the origin of read_at', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + const before0 = row(r.id, annaLaptop); + await ack(annaLaptop, [before0.seq], 'dismissed'); + const lap = row(r.id, annaLaptop); + assert.equal(lap.state, 'dismissed'); + assert.equal(lap.read_via, 'device'); + assert.equal(lap.read_at, before0.read_at); + assert.ok(lap.delivered_at, 'any ack of the device confirms it has the notification'); + }); + + it('opened on the laptop after it was read elsewhere: its own read from then on', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + await ack(annaLaptop, [row(r.id, annaLaptop).seq], 'read'); + const lap = row(r.id, annaLaptop); + assert.equal(lap.state, 'read'); + assert.equal(lap.read_via, 'self'); + assert.ok(lap.delivered_at); + }); + + it('a late "delivered" of the laptop records the delivery, the state stays read', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + await ack(annaLaptop, [row(r.id, annaLaptop).seq], 'delivered'); + const lap = row(r.id, annaLaptop); + assert.equal(lap.state, 'read'); + assert.equal(lap.read_via, 'device'); + assert.ok(lap.delivered_at); + const st = admin.statsFor([r.id]).get(r.id); + assert.equal(st.delivered, 2); + assert.equal(st.elsewhere, 0); + assert.equal(st.read, 1); + }); + + it('portal "gelesen" marks every device with read_via portal', () => { + const r = toAnna(); + assert.equal(hub.markRead(anna, [r.id]), 1); + for (const dev of [annaPhone, annaLaptop]) { + const d = row(r.id, dev); + assert.equal(d.state, 'read'); + assert.equal(d.read_via, 'portal'); + assert.equal(d.delivered_at, null); + } + }); +}); + +describe('admin read models', () => { + it('statsFor / statusOf: read elsewhere is neither delivered nor missing', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + const st = admin.statsFor([r.id]).get(r.id); + assert.equal(st.total, 2); + assert.equal(st.delivered, 1, 'only the phone confirmed it'); + assert.equal(st.read, 1, 'read on one device'); + assert.equal(st.elsewhere, 1); + assert.equal(st.read_elsewhere, 1); + assert.equal(admin.statusOf(store.getNotification(r.id), st), 'ok'); + const h = admin.history({ filter: 'all' }, t).items.find((i) => i.id === r.id); + assert.deepEqual([h.delivered, h.total, h.read, h.elsewhere, h.status], [1, 2, 1, 1, 'ok']); + assert.equal(admin.history({ filter: 'undelivered' }, t).items.some((i) => i.id === r.id), false); + }); + + it('a device that neither got nor read it is still partial / waiting', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'dismissed'); + const st = admin.statsFor([r.id]).get(r.id); + assert.deepEqual([st.delivered, st.read, st.elsewhere], [1, 1, 0]); + assert.equal(admin.statusOf(store.getNotification(r.id), st), 'waiting'); + H.db().prepare("UPDATE notifications SET expires_at = '2000-01-01T00:00:00.000Z' WHERE id = ?").run(r.id); + assert.equal(admin.statusOf(store.getNotification(r.id), st), 'partial'); + }); + + it('overview: read_24h counts only reads on the device itself', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + const k = admin.overview(t).kpis; + assert.equal(k.delivered_24h, 1); + assert.equal(k.read_24h, 1); + const recent = admin.overview(t).recent.find((i) => i.id === r.id); + assert.deepEqual([recent.delivered, recent.read, recent.elsewhere], [1, 1, 1]); + }); + + it('detail: read_via per delivery and a timeline entry "anderswo gelesen"', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + const res = await agent.get(`/api/v1/notify/history/${r.id}`).expect(200); + const byToken = new Map(res.body.deliveries.map((d) => [d.token_id, d])); + assert.equal(byToken.get(annaPhone.tokenId).read_via, 'self'); + assert.equal(byToken.get(annaLaptop.tokenId).read_via, 'device'); + assert.equal(byToken.get(annaLaptop.tokenId).delivered_at, null); + assert.equal(byToken.get(annaLaptop.tokenId).state, 'read', 'the stored state is unchanged'); + const tl = new Map(res.body.timeline.map((x) => [x.kind, x])); + // the admin session speaks English + assert.equal(tl.get('delivered').text, 'Delivered to 1 devices'); + assert.equal(tl.get('read').text, 'Read on 1 devices'); + assert.equal(tl.get('read_elsewhere').text, 'Read elsewhere: 1 devices marked as read'); + assert.equal(tl.get('read').at, byToken.get(annaPhone.tokenId).read_at); + // unread rows: read_via null + const r2 = toAnna('Zwei'); + const res2 = await agent.get(`/api/v1/notify/history/${r2.id}`).expect(200); + assert.ok(res2.body.deliveries.every((d) => d.read_via === null)); + assert.equal(res2.body.timeline.some((x) => x.kind === 'read_elsewhere'), false); + }); + + it('devices: "Bestätigt" is the last ack the device itself sent', async () => { + const r = toAnna(); + await ack(annaPhone, [row(r.id, annaPhone).seq], 'read'); + const list = admin.devices(); + const phone = list.find((d) => d.token_id === annaPhone.tokenId); + const lap = list.find((d) => d.token_id === annaLaptop.tokenId); + assert.equal(phone.last_ack_at, row(r.id, annaPhone).read_at); + assert.equal(lap.last_ack_at, null, 'read on the phone is no ack of the laptop'); + hub.markRead(anna, null); + assert.equal(admin.devices().find((d) => d.token_id === annaLaptop.tokenId).last_ack_at, null, 'nor is the portal'); + await ack(annaLaptop, [row(r.id, annaLaptop).seq], 'delivered'); + assert.equal(admin.devices().find((d) => d.token_id === annaLaptop.tokenId).last_ack_at, row(r.id, annaLaptop).delivered_at); + }); +}); + +describe('admin page helpers', () => { + const N = require('../public/js/notifications.js'); + it('deliveryState / readKey', () => { + assert.equal(N.deliveryState({ state: 'read', read_at: 'x', read_via: 'self' }), 'read'); + assert.equal(N.deliveryState({ state: 'read', read_at: 'x', read_via: null }), 'read', 'rows without origin: read on the device'); + for (const via of ['device', 'portal', 'elsewhere']) { + assert.equal(N.deliveryState({ state: 'read', read_at: 'x', read_via: via }), 'read_elsewhere'); + assert.equal(N.readKey({ read_at: 'x', read_via: via }), 'notify.proto.read_' + via); + } + assert.equal(N.deliveryState({ state: 'dismissed', read_at: 'x', read_via: 'device' }), 'dismissed'); + assert.equal(N.readKey({ read_at: 'x', read_via: 'self' }), 'notify.proto.read_at'); + assert.equal(N.readElsewhere(null), false); + }); + it('statusOf / histStatusOf with rows read elsewhere', () => { + assert.deepEqual(N.histStatusOf({ total: 2, delivered: 1, read: 1, elsewhere: 1, status: 'ok' }), + { key: 'notify.hist.status_elsewhere', params: { delivered: 1, total: 2, n: 1 }, tone: 'good' }); + assert.equal(N.histStatusOf({ total: 3, delivered: 1, elsewhere: 1, status: 'waiting' }).params.n, 1); + assert.deepEqual(N.statusOf({ total: 2, delivered: 1, read: 1, elsewhere: 1 }), + { key: 'notify.status.delivered', params: { delivered: 1, total: 2 }, tone: 'good' }); + }); + it('the new texts exist in DE and EN', () => { + const de = require('../src/i18n/de.json'); + const en = require('../src/i18n/en.json'); + const keys = ['notify.dstate.read_elsewhere', 'notify.proto.not_delivered', 'notify.proto.read_device', 'notify.proto.read_portal', + 'notify.proto.read_elsewhere', 'notify.hist.status_elsewhere', 'notify.tl.read_elsewhere', 'push.timeline.read_elsewhere']; + for (const k of keys) { assert.ok(de[k], 'de ' + k); assert.ok(en[k], 'en ' + k); } + assert.equal(de['notify.dstate.read_elsewhere'], 'Anderswo gelesen'); + assert.equal(en['notify.dstate.read_elsewhere'], 'Read elsewhere'); + }); +}); + +describe('migration 144 (notify_read_via)', () => { + it('existing read rows without delivered_at become "elsewhere", the other read rows "self"', () => { + const Database = require('better-sqlite3'); + const m = require('../src/db/migrationList').migrations.find((x) => x.version === 144); + assert.equal(m.name, 'notify_read_via'); + const db = new Database(':memory:'); + try { + db.exec(`CREATE TABLE notification_deliveries (seq INTEGER PRIMARY KEY AUTOINCREMENT, notification_id INTEGER NOT NULL, + token_id INTEGER NOT NULL, user_id INTEGER, state TEXT NOT NULL DEFAULT 'queued', via TEXT, action TEXT, + silent INTEGER NOT NULL DEFAULT 0, queued_at TEXT, sent_at TEXT, delivered_at TEXT, read_at TEXT, UNIQUE (notification_id, token_id))`); + const ins = db.prepare('INSERT INTO notification_deliveries (notification_id, token_id, state, delivered_at, read_at) VALUES (?, ?, ?, ?, ?)'); + ins.run(1, 1, 'read', '2026-11-01T10:00:00.000Z', '2026-11-01T10:01:00.000Z'); // read on the device + ins.run(1, 2, 'read', null, '2026-11-01T10:01:00.000Z'); // synced: never reached the device + ins.run(1, 3, 'dismissed', '2026-11-01T10:00:00.000Z', '2026-11-01T10:02:00.000Z'); // swiped away on the device + ins.run(1, 4, 'dismissed', null, '2026-11-01T10:01:00.000Z'); // synced, swiped later + ins.run(1, 5, 'delivered', '2026-11-01T10:00:00.000Z', null); + ins.run(1, 6, 'queued', null, null); + ins.run(1, 7, 'expired', null, null); + assert.equal(m.detect(db), false); + db.exec(m.sql); + assert.equal(m.detect(db), true); + const via = db.prepare('SELECT token_id, read_via FROM notification_deliveries ORDER BY token_id').all().map((r) => r.read_via); + assert.deepEqual(via, ['self', 'elsewhere', 'self', 'elsewhere', null, null, null]); + } finally { db.close(); } + }); + + it('is applied on the test database', () => { + assert.equal(H.db().prepare('SELECT name FROM migration_history WHERE version = 144').get().name, 'notify_read_via'); + }); +});