Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions docs/feature-notification-center.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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=<id>&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}`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1018,8 +1053,9 @@ GET /api/v1/client/plugins/<plugin>/media/<mediaId> (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=<seq>`

Expand Down
31 changes: 25 additions & 6 deletions public/js/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,26 +197,40 @@
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) {
const o = isObj(item) ? item : {};
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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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' });
Expand Down Expand Up @@ -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) }),
]))));
Expand Down
25 changes: 25 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
8 changes: 8 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}}.",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}}.",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
Loading
Loading