Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."
Alles Messbare war gruen: Der Service Worker zeigt die Meldung bei
geschlossener App (pruef-push-zu, 13 Pruefungen), jede Anmeldung im
Haus stand auf fehler = 0, der Push-Dienst quittierte mit 201. Nur
den Kopf auf der Leitung hat nie jemand gemessen:
Urgency: normal
Beide Dienste behandeln das ausdruecklich als "darf warten".
Android/FCM haelt solche Meldungen zurueck, solange das Telefon
doest, und stellt sie zu, wenn es aufwacht -- typischerweise beim
Entsperren oder Oeffnen der App. Apple/APNs nennt es "verzoegert,
gebuendelt oder gedrosselt". Das ist Dienes Satz, Wort fuer Wort,
und es stand als Vorgabe im eigenen Quelltext.
zuletzt_ok konnte das nie zeigen: Es beweist, dass der DIENST
angenommen hat, nicht dass das GERAET etwas angezeigt hat.
Bis hierher hing die Dringlichkeit an der Ruheregel (dringend =
regel === "nie"), mit der Begruendung, "darf das nachts stoeren?"
und "darf das warten?" haetten dieselbe Antwort. Haben sie nicht:
Ein Chat um 3 Uhr soll schweigen, um 14 Uhr aber nicht vierzig
Minuten liegen bleiben. Jetzt zwei Felder -- in DERSELBEN Zeile
derselben Artenliste, damit sie nicht auseinanderlaufen koennen.
10 von 19 Arten sind eilig (Anruf, Chat, Erwaehnung, Support, Hilfe,
Termin, Wecker, zweimal Live, Probe). Die anderen neun duerfen
warten -- waere alles eilig, waere nichts mehr eilig.
DIE FALLE BEIM BEHEBEN: dringend steuerte auch die Haltbarkeit
(TTL 150). Ein einfach auf "dringend" gestellter Chat waere nach
150 s verfallen -- wer sein Telefon drei Minuten aus hat, haette die
Nachricht GAR nicht mehr bekommen. Aus "zu spaet" waere "nie"
geworden. Deshalb sind eilig und kurzlebig getrennt; kurzlebig
bleibt genau beim Anruf und der Probe.
Der alte Name kracht jetzt, statt still "normal" zu liefern.
pruef-push-eilig.mjs (neu, 13 Pruefungen): die Entscheidung je Art
unabhaengig notiert statt aus der Artenliste abgelesen, jede Art
muss eine Entscheidung haben, und der Kopf wird durch
benachrichtige() hindurch an einem nachgebauten Push-Dienst
gemessen. Gegenprobe gelaufen: ohne das Feld meldet sie
"chat_nachricht geht mit Urgency: normal hinaus", 4 Fehler.
Die Uhr ist dort eine Eingabe, keine Annahme -- die Zeitzone wird so
gewaehlt, dass der Lauf immer auf 12 Uhr faellt, sonst waere die
Pruefung nachts rot ohne Befund.
pruef-anruf-klingelt 25 -> 26. Dabei aufgefallen: Eine ihrer
Pruefungen war gruen, obwohl die Zeile aus dem Code verschwunden war
-- sie stand nur noch in einem Kommentar, der die alte Fassung
zitiert. Quelltextpruefungen lesen dort jetzt ohne Kommentare.
Co-Authored-By: Claude Opus 5 <[email protected]>
1251 lines
57 KiB
JavaScript
1251 lines
57 KiB
JavaScript
/* =====================================================================
|
||
workspace-push.js — Benachrichtigungen, die wirklich ankommen.
|
||
|
||
Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen
|
||
aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
|
||
auf der seite fuer jeden."*
|
||
|
||
---------------------------------------------------------------------
|
||
WAS DAS LOEST
|
||
|
||
Bis hierher erscheinen Hinweise NUR, wenn man die Seite aufruft. Wer
|
||
nicht hinschaut, verpasst sie -- und genau das ist der Normalfall:
|
||
Man macht den Workspace auf, wenn man ohnehin daran denkt. Der
|
||
Perfektionsplan sagt dazu: *"Das Deck verspricht Erinnerungen. Wer
|
||
nicht hinschaut, verpasst sie."*
|
||
|
||
---------------------------------------------------------------------
|
||
OHNE FREMDEN DIENST, OHNE KONTO, OHNE GEBUEHR
|
||
|
||
Push API und Service Worker sind Browserstandard. Der Server braucht
|
||
nur ein eigenes Schluesselpaar (VAPID); die Nachricht wird fuer jedes
|
||
Geraet einzeln verschluesselt. Google, Mozilla und Apple leiten sie
|
||
weiter, sehen aber nur Zeichensalat -- der Inhalt bleibt zwischen
|
||
diesem Server und dem Browser.
|
||
|
||
Die Rechnung dafuer steht in workspace-push-krypto.js und ist gegen
|
||
die Testvektoren von RFC 8291 und 8292 geprueft.
|
||
|
||
---------------------------------------------------------------------
|
||
VIER REGELN, DIE DARUEBER ENTSCHEIDEN, OB ES BENUTZT WIRD
|
||
|
||
1. NICHTS ZWEIMAL. Jede Erinnerung wird je Person und Sache genau
|
||
einmal verschickt (Tabelle push_verschickt). Viermal am Tag
|
||
dieselbe Meldung ist der schnellste Weg, dass jemand alles
|
||
abschaltet -- und dann fehlt auch die wichtige.
|
||
|
||
2. NACHTS NICHT. Zwischen 22 und 7 Uhr wird nichts zugestellt.
|
||
Aufgehoben wird nichts: Was morgens noch gilt, geht morgens raus;
|
||
was sich erledigt hat, ist zu Recht weg.
|
||
|
||
3. JE ART ABSCHALTBAR, nicht alles oder nichts.
|
||
|
||
4. EINE BENACHRICHTIGUNG HAT IMMER EIN ZIEL. Wer sie antippt, landet
|
||
an der Stelle, um die es geht -- nicht auf der Startseite.
|
||
|
||
---------------------------------------------------------------------
|
||
WICHTIG FUERS IPHONE
|
||
|
||
Dort funktioniert Push NUR, wenn die Seite zum Home-Bildschirm
|
||
hinzugefuegt wurde. Im Safari-Reiter gibt es keine Berechtigung --
|
||
der Knopf sagt das dann auch genau so, statt einen Fehler zu zeigen.
|
||
===================================================================== */
|
||
|
||
import express from "express";
|
||
import {
|
||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||
einstellung, einstellungSetzen, heuteLokal,
|
||
gehoertAufDieseAdresse, TEAM_DOGI_ROLLEN,
|
||
} from "./workspace.js";
|
||
import { schluesselErzeugen, schicken } from "./workspace-push-krypto.js";
|
||
/* Die Hausteilung kommt aus der Datei, in der sie steht -- nicht aus
|
||
einer zweiten Fassung hier. `crew-adresse.js` haengt von nichts ab
|
||
(nur von node:fs/path), ein Kreis entsteht also nicht. */
|
||
import { AGENTUR_ROLLEN, GEHOERT_ZU_ADRESSE, istCrewAdresse } from "./crew-adresse.js";
|
||
import { rhythmus as befindenRhythmus } from "./workspace-befinden.js";
|
||
import { SEITEN } from "./rechte.js";
|
||
|
||
export const pushRouter = express.Router();
|
||
|
||
const jetzt = () => new Date().toISOString();
|
||
|
||
/* ---------------------------------------------------------------------
|
||
DIE ARTEN. Was es gibt, steht hier -- nicht in der Datenbank: Es ist
|
||
eine bewusste Auswahl, keine Nutzdaten. Wer eine neue Art braucht,
|
||
traegt sie hier ein und sie erscheint von selbst in den
|
||
Einstellungen.
|
||
|
||
---------------------------------------------------------------------
|
||
`ruhe` -- DARF DAS NACHTS KOMMEN? (29.09.2026)
|
||
|
||
Bis hierher stand das als fest verdrahtete Aufzaehlung 230 Zeilen
|
||
weiter unten in `benachrichtige`:
|
||
|
||
const dringend = art === "test" || art === "anruf";
|
||
|
||
Wer eine neue Art eintrug, sah sie nicht -- und diese Liste sagte
|
||
ueber die Ruhezeit kein Wort, obwohl sie darueber entscheidet, ob
|
||
eine Meldung jemals ankommt. Am 18.09. hat genau das eine Nacht
|
||
lang alle Anrufe verschluckt; damals wurde EINE Art ausgenommen,
|
||
statt die Frage zu stellen.
|
||
|
||
DREI ANTWORTEN, WEIL ES DREI FAELLE GIBT:
|
||
|
||
"immer" (Vorgabe) 22-7 gesperrt.
|
||
Erinnerungen. Der Server schickt sie von sich aus, weil
|
||
eine Frist naeher rueckt -- die kann bis morgen warten.
|
||
|
||
"spaet" nur 1-7 gesperrt.
|
||
Etwas LAEUFT GERADE und ist in zwei Stunden vorbei. Eine
|
||
Einladung um 22:30 will man haben; um vier Uhr nachts
|
||
ist die Sendung laengst aus, und die Meldung waere nur
|
||
noch eine Stoerung.
|
||
|
||
"nie" immer durch.
|
||
Ein Mensch drueckt gerade auf den Hoerer und wartet.
|
||
Ein Telefon, das nachts stumm bleibt, ist kein Telefon.
|
||
|
||
GEMESSEN, WARUM "spaet" gebraucht wird: An allen sieben Abenden vom
|
||
22. bis 28.09.2026 ging der Live-Ruf zwischen 20:28 und 21:06 raus.
|
||
Jedes Mal knapp vor der Sperre. Geht Filipe einmal um 22:05 live,
|
||
bekommt niemand etwas -- und niemand erfaehrt, warum.
|
||
------------------------------------------------------------------ */
|
||
export const ARTEN = [
|
||
{ schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true },
|
||
{ schluessel: "aufgabe_ueberfaellig", name: "Aufgabe ist überfällig", vorgabe: true },
|
||
{ schluessel: "termin_gleich", eilig: true, name: "Termin in einer Stunde", vorgabe: true },
|
||
/* DER WECKER (07.09.2026). Bewusst eine EIGENE Art neben
|
||
"termin_gleich": Die eine ist eine Vorgabe des Hauses fuer jeden
|
||
Termin, der andere ist eine Entscheidung DIESER Person fuer DIESEN
|
||
Termin. Wer die feste Stunden-Erinnerung abschaltet, will damit
|
||
nicht seine selbst gesetzten Wecker verlieren. */
|
||
{ schluessel: "termin_wecker", eilig: true, name: "Selbst gesetzter Wecker", vorgabe: true },
|
||
{ schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true },
|
||
{ schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true },
|
||
/* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art,
|
||
die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht
|
||
zuerst sie. */
|
||
{ schluessel: "chat_nachricht", eilig: true, name: "Neue Nachricht im Chat", vorgabe: true },
|
||
/* ---- ANGESPROCHEN WERDEN IST EINE EIGENE ART (20.09.2026) ------
|
||
|
||
Filipe: "ich will das man die leute mit @ markieren kann im chat."
|
||
|
||
Sie steht direkt hinter "Neue Nachricht", weil sie daneben
|
||
gehoert -- und sie ist trotzdem getrennt, aus demselben Grund,
|
||
aus dem Anrufe getrennt sind: Es sind zwei verschiedene
|
||
Entscheidungen.
|
||
|
||
In einem Raum mit acht Leuten ist "jemand hat geschrieben" oft
|
||
Hintergrund. "Jemand meint DICH" ist es nie. Wer das Erste stumm
|
||
stellt, will das Zweite meist behalten -- und muesste sonst
|
||
beides zusammen abschalten oder beides zusammen ertragen.
|
||
|
||
`vorgabe: true`, wie alles, was auf ein Ereignis antwortet. Und
|
||
KEINE Ausnahme von der Ruhezeit: Ein Anruf wartet auf eine
|
||
Antwort, ein "@Anna" nicht. Wer um halb vier angesprochen wird,
|
||
liest es um sieben -- das reicht. */
|
||
{ schluessel: "chat_erwaehnung", eilig: true, name: "Jemand erwähnt dich im Chat (@)", vorgabe: true },
|
||
|
||
/* ---- BEWERBUNGEN (23.09.2026) -----------------------------------
|
||
|
||
Filipe, 22.09.: „die option dass die modis sich für aufgaben
|
||
bewerben können."
|
||
Filipe, 23.09.: „nur dogfather und die rechte hand sollen annehmen
|
||
oder ablehnen können, mit einem text als notiz."
|
||
|
||
BEIDE WEGE WAREN GEBAUT UND BEIDE STUMM. Wer sich beworben hatte,
|
||
erfuhr die Antwort nur, wenn er von sich aus nachsah -- und wer
|
||
antworten sollte, erfuhr von der Bewerbung nur, wenn er von sich
|
||
aus das Brett aufmachte. Nachgemessen: `benachrichtige` kam in
|
||
workspace-zuteilung.js und workspace-vorlagen.js kein einziges Mal
|
||
vor. Das war keine vergessene Zeile, das war ein fehlendes Stück.
|
||
|
||
ZWEI ARTEN UND NICHT EINE, aus demselben Grund wie beim Chat: Die
|
||
beiden treffen verschiedene Menschen in verschiedenen Lagen. Wer
|
||
entscheidet, bekommt an einem lebhaften Tag mehrere Bitten --
|
||
das kann man stumm stellen wollen. Die ANTWORT auf die eigene
|
||
Bewerbung will niemand stumm stellen; sie kommt einmal, und man
|
||
wartet darauf. Eine gemeinsame Art hieße: beides zusammen
|
||
abschalten oder beides zusammen ertragen.
|
||
|
||
`vorgabe: true` bei beiden -- wie alles, was auf ein Ereignis
|
||
antwortet, statt es von sich aus zu erzeugen.
|
||
|
||
KEINE AUSNAHME VON DER RUHEZEIT. Eine Bewerbung wartet, ein Anruf
|
||
nicht. Wer um halb vier eine Absage bekommt, liest sie um sieben;
|
||
das ist früh genug und sehr viel besser, als nachts geweckt zu
|
||
werden. */
|
||
{ schluessel: "bewerbung_neu",
|
||
name: "Jemand bewirbt sich für eine Aufgabe", vorgabe: true },
|
||
{ schluessel: "bewerbung_antwort",
|
||
name: "Antwort auf deine eigene Bewerbung", vorgabe: true },
|
||
|
||
/* DER SUPPORT (24.09.2026).
|
||
|
||
EINE ART FUER BEIDE RICHTUNGEN -- die neue Meldung an die Leitung
|
||
und die Antwort an den Melder. Zwei Schluessel waeren zwei
|
||
Schalter in den Einstellungen fuer dieselbe Sache, und wer den
|
||
einen abschaltet, wundert sich ueber den anderen.
|
||
|
||
NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr
|
||
nachts; die Ruhezeit gilt. */
|
||
{ schluessel: "support", eilig: true,
|
||
name: "Support: neue Meldung oder Antwort darauf", vorgabe: true },
|
||
|
||
/* ---- MANAGER-ZIELE (02.10.2026) ---------------------------------
|
||
|
||
Die vier festen Monatsaufgaben. EINE Art fuer alle sieben
|
||
Anlaesse (1., 10., 15., 16., sieben Tage vorher, die letzten drei
|
||
Tage, der Glueckwunsch) und nicht sieben Schalter: Es ist
|
||
dieselbe Sache in verschiedenen Lautstaerken. Wer die Erinnerung
|
||
an seine Monatsziele abschalten will, will sie ganz abschalten --
|
||
nicht "die vom 10., aber die vom 16. behalten".
|
||
|
||
`vorgabe: true`, wie alles im Haus, was auf etwas Vereinbartes
|
||
hinweist. Wer nichts davon hoeren will, schaltet es im
|
||
Glocken-Knopf ab; das ist eine Entscheidung, die man selbst
|
||
trifft.
|
||
|
||
KEINE AUSNAHME VON DER RUHEZEIT. Eine fehlende Schulung weckt
|
||
niemanden um drei Uhr nachts. */
|
||
{ schluessel: "manager_ziele",
|
||
name: "Deine Monatsziele (Manager-Ziele)", vorgabe: true },
|
||
|
||
/* ER IST LIVE (22.09.2026).
|
||
|
||
Filipe: "die leute sollen auch automatisch eine benarichtigung
|
||
bekommen um 20 uhr dass ich live bin."
|
||
|
||
`vorgabe: true` -- das ist der Grund, aus dem die meisten
|
||
ueberhaupt hier sind. Und trotzdem abschaltbar: Wer jeden Abend
|
||
dieselbe Meldung bekommt und nie hinschaut, schaltet sonst ALLES
|
||
ab, und dann erreicht ihn auch keine Aufgabe mehr.
|
||
|
||
Eine eigene Art und nicht "termin_gleich": Das eine ist ein
|
||
Kalendereintrag, das andere ein Stream, der JETZT laeuft. Wer das
|
||
eine stumm stellt, meint selten das andere. */
|
||
{ schluessel: "dogfather_live", eilig: true, name: "DogFather ist live", vorgabe: true,
|
||
/* „spaet", nicht „immer": Ein Stream, der um 22:05 anfaengt, ist um
|
||
22:05 interessant. Mit der alten Regel waere er stumm geblieben,
|
||
und die Meldung, die Filipe ausdruecklich bestellt hat („die
|
||
leute sollen auch automatisch eine benarichtigung bekommen"),
|
||
haette an genau den Abenden nicht funktioniert, an denen es
|
||
spaeter wurde. */
|
||
ruhe: "spaet" },
|
||
|
||
/* ---- DIE REACTION LAEDT EIN (29.09.2026) ------------------------
|
||
|
||
Filipe: „ich will dass du die benarichtigungen perfektionierst.
|
||
dogfather und alle anderen sollen die benachrichtigungen perfekt
|
||
von dieser seite hier bekommen."
|
||
|
||
NACHGEMESSEN WAR DIE REACTION STUMM. `workspace-reaktion.js`
|
||
enthielt kein einziges `benachrichtige`. Geht die Sendung auf
|
||
Sendung, ruft sie `melden("reaktion", ...)` -- und das geht ueber
|
||
den Ereignisstrom, also NUR an Leute, die die Seite ohnehin schon
|
||
offen haben. Das Kino macht auf, und die Einladung verlaesst das
|
||
Haus nie.
|
||
|
||
Dasselbe Muster wie bei den Bewerbungen am 23.09.: beide Wege
|
||
gebaut, beide stumm.
|
||
|
||
EIGENE ART UND NICHT `dogfather_live`. Das eine ist sein Stream
|
||
auf TikTok (gemessen ueber `liveStatusHolen` beim Postfach), das
|
||
andere das Kino hier im Haus. Wer abends nicht auf TikTok geht,
|
||
aber zur Reaction kommt, will genau das eine und nicht das
|
||
andere -- und umgekehrt.
|
||
|
||
`ruhe: "spaet"` aus demselben Grund wie oben: Eine Reaction um
|
||
22:30 ist der Normalfall, keine Ausnahme. */
|
||
{ schluessel: "reaktion_live", eilig: true,
|
||
name: "Die Reaction geht auf Sendung", vorgabe: true, ruhe: "spaet" },
|
||
/* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------
|
||
|
||
Filipe: „sie bekommt nur eine benarichtigung das eine neue
|
||
nachricht ist aber sonst nichts irgendwas laeuft da gewaltig
|
||
schief."
|
||
|
||
Es lief zweierlei schief, und beides haengt an dieser Zeile.
|
||
|
||
ERSTENS ging das Klingeln als „chat_nachricht" hinaus. Wer im
|
||
Chat viel schreibt und die Benachrichtigungen dafuer abstellt,
|
||
stellt damit auch Anrufe ab -- ohne es zu wissen. Das sind zwei
|
||
verschiedene Entscheidungen.
|
||
|
||
ZWEITENS -- und das war der eigentliche Grund -- galt fuer sie die
|
||
RUHEZEIT. Ab 22 Uhr wird nichts mehr verschickt. Um 23:44 kam
|
||
deshalb gar keine Benachrichtigung an; ihr Geraet konnte nicht
|
||
klingeln, weil es nichts zu klingeln gab.
|
||
|
||
Fuer eine Aufgabenerinnerung ist das genau richtig. Ein Anruf ist
|
||
etwas anderes: Ihn loest ein Mensch in diesem Moment aus, und er
|
||
wartet auf eine Antwort. Ein Telefon, das nachts stumm bleibt,
|
||
ist kein Telefon. Wer nachts nicht gestoert werden will, schaltet
|
||
jetzt diese Art ab -- eine Entscheidung, die man selbst trifft,
|
||
statt einer Regel, die man nicht kennt. */
|
||
{ schluessel: "anruf", eilig: true, name: "Jemand ruft an", vorgabe: true, ruhe: "nie" },
|
||
/* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung
|
||
einmal am tag zu aktivieren wenn noch aufgaben auf sind."
|
||
|
||
ALS EINZIGE ART MIT `vorgabe: false`, und das ist keine
|
||
Nachlaessigkeit: Er ist ausdruecklich etwas zum AKTIVIEREN. Alle
|
||
anderen Arten antworten auf ein Ereignis, das gerade passiert ist
|
||
(eine Aufgabe wird faellig, jemand schreibt). Der Tagesruf kommt
|
||
ungefragt zur selben Zeit, ob es etwas Neues gibt oder nicht --
|
||
so etwas schaltet man sich selbst ein, sonst ist es Werbung.
|
||
|
||
`wertVorgabe` ist die Uhrzeit. Neun Uhr, weil die Ruhezeit bis
|
||
sieben geht und ein Ruf um 7:01 niemanden erreicht, der um acht
|
||
aufsteht. */
|
||
{ schluessel: "tagesruf", name: "Einmal am Tag: was noch offen ist",
|
||
vorgabe: false, wertVorgabe: "09:00", wertArt: "uhrzeit" },
|
||
/* DIE BEFINDENS-ANFRAGE (17.09.2026). Filipe: "mach da mehr so eine
|
||
automatisierte anfrage regelmaessig."
|
||
|
||
MIT `vorgabe: true`, anders als der Tagesruf: Sie antwortet nicht
|
||
auf ein Ereignis, aber sie kommt auch nicht taeglich -- alle 14
|
||
Tage, und nur, wenn wirklich etwas offen ist. Und sie ist der
|
||
einzige Weg, auf dem jemand ueberhaupt erfaehrt, dass wieder
|
||
gefragt wird. Waere sie zum Einschalten, bekaeme sie genau der
|
||
nicht, der sie am noetigsten braucht.
|
||
|
||
ABSCHALTEN GEHT NATUERLICH TROTZDEM -- wie bei jeder Art, in den
|
||
Einstellungen. Das ist der Unterschied zwischen einer Vorgabe und
|
||
einem Zwang. */
|
||
{ schluessel: "befinden", name: "Alle zwei Wochen: wie geht es dir?", vorgabe: true },
|
||
/* DER VERTRAULICHE MELDEWEG (19.09.2026).
|
||
|
||
Sie geht in BEIDE Richtungen: An die Leitung, wenn etwas
|
||
hereinkommt -- und an den Melder, wenn eine Antwort da ist. Genau
|
||
das ist der Punkt: Wer etwas Unangenehmes geschrieben hat, will
|
||
nicht stuendlich nachsehen muessen, ob schon jemand geantwortet
|
||
hat.
|
||
|
||
IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint
|
||
auf einem Sperrbildschirm, den auch andere sehen. */
|
||
{ schluessel: "hilfe", eilig: true, name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true },
|
||
/* Die Probe beantwortet „kommt ueberhaupt etwas an?" -- und die
|
||
stellt man sich auch um 23 Uhr. */
|
||
{ schluessel: "test", eilig: true, name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" },
|
||
];
|
||
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
|
||
|
||
/** Wie ein Vorlauf heisst, wenn er im Meldungskopf steht.
|
||
*
|
||
* In Minuten gerechnet, in Worten gemeldet: "In einer Woche" liest
|
||
* sich beim Aufwachen besser als "10080 Minuten vorher". Die Staffel
|
||
* ist dieselbe, die die Oberflaeche anbietet -- kaeme hier eine
|
||
* ungerade Zahl an, faellt sie auf den allgemeinen Satz zurueck statt
|
||
* auf einen falschen. */
|
||
export function vorlauf(minuten) {
|
||
const m = Number(minuten) || 0;
|
||
if (m >= 10080) return "Erinnerung: in einer Woche";
|
||
if (m >= 4320) return "Erinnerung: in drei Tagen";
|
||
if (m >= 1440) return "Erinnerung: morgen";
|
||
if (m >= 720) return "Erinnerung: heute";
|
||
if (m >= 120) return `Erinnerung: in ${Math.round(m / 60)} Stunden`;
|
||
if (m >= 60) return "Erinnerung: in einer Stunde";
|
||
if (m > 0) return `Erinnerung: in ${m} Minuten`;
|
||
return "Erinnerung: jetzt";
|
||
}
|
||
|
||
/* RUHEZEIT. Nichts zwischen 22 und 7 Uhr.
|
||
|
||
DIE GRENZEN SIND SEIT DEM 07.09.2026 EINSTELLBAR, und der Grund ist
|
||
eine Pruefung, die sich selbst ins Knie geschossen hat: Die
|
||
Weckerpruefung lief um 23:42 und meldete vier Fehler -- der Melder
|
||
schwieg, weil Ruhezeit war. Sie hat also den Kalender gemessen, nicht
|
||
die Software, und waere um 10 Uhr gruen gewesen. Genau die Zeitbombe,
|
||
die in der Projektnotiz steht.
|
||
|
||
Eine Pruefung darf ihre Voraussetzung herstellen duerfen. Deshalb
|
||
liest die Ruhezeit jetzt aus der Umgebung -- Vorgabe unveraendert
|
||
22 bis 7, und im Betrieb setzt niemand etwas anderes. Die Alternative
|
||
waere gewesen, in der Pruefung eine Zeit zu faelschen; das faelscht
|
||
dann irgendwann auch etwas anderes mit.
|
||
|
||
Nebennutzen: Wenn das Team eines Tages andere Zeiten will, ist es
|
||
eine Umgebungsvariable und kein Programmierauftrag. */
|
||
const RUHE_AB = Number(process.env.PUSH_RUHE_AB ?? 22);
|
||
const RUHE_BIS = Number(process.env.PUSH_RUHE_BIS ?? 7);
|
||
/* Wann eine „spaet"-Meldung aufhoert. Ein Uhr und nicht zwei: Was um
|
||
ein Uhr anfaengt, ist um drei vorbei -- eine Einladung dazu erreicht
|
||
niemanden mehr, sie weckt nur. */
|
||
const SPAET_AB = Number(process.env.PUSH_SPAET_AB ?? 1);
|
||
|
||
/** Welche Ruheregel gilt fuer diese Art?
|
||
*
|
||
* ABGELEITET AUS DER ARTENLISTE, nicht danebengeschrieben. Eine
|
||
* zweite Aufzaehlung waere genau die, die beim naechsten Eintrag
|
||
* vergessen wird -- so ist es am 18.09. beim Anruf gewesen. */
|
||
export function ruheRegel(art) {
|
||
return ARTEN.find((a) => a.schluessel === art)?.ruhe || "immer";
|
||
}
|
||
|
||
/** Ist diese Art EILIG -- also wertlos, wenn sie zu spaet kommt?
|
||
*
|
||
* WARUM DAS EINE EIGENE FRAGE IST (03.10.2026, nach Dienes Meldung).
|
||
*
|
||
* Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
|
||
* wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."
|
||
* Das war kein Fehler an ihrem Geraet und keiner am Service Worker --
|
||
* es war der Kopf `Urgency`, den wir mitschicken.
|
||
*
|
||
* Beide Push-Dienste behandeln "normal" ausdruecklich als "darf
|
||
* warten": Android/FCM haelt solche Meldungen zurueck, solange das
|
||
* Telefon doest, und stellt sie zu, wenn es aufwacht -- typischerweise,
|
||
* wenn jemand das Geraet entsperrt oder die App oeffnet. Apple/APNs
|
||
* nennt es "verzoegert, gebuendelt oder gedrosselt". Beschrieben ist
|
||
* damit genau Dienes Satz, Wort fuer Wort.
|
||
*
|
||
* Bis hierher hing die Dringlichkeit an der RUHEREGEL:
|
||
*
|
||
* const dringend = regel === "nie";
|
||
*
|
||
* Daneben stand die Begruendung, eine zweite Bedingung sei die, "die
|
||
* beim naechsten Nachschaerfen auseinanderlaeuft". Die Vorhersage war
|
||
* richtig, nur in die andere Richtung: Die zwei Fragen haben eben
|
||
* NICHT dieselbe Antwort.
|
||
*
|
||
* "Darf das nachts stoeren?" -> Chat: nein.
|
||
* "Darf das warten?" -> Chat: auch nein.
|
||
*
|
||
* Eine Nachricht um drei Uhr nachts soll schweigen -- um zwei Uhr
|
||
* nachmittags aber nicht vierzig Minuten liegen bleiben. Deshalb zwei
|
||
* Felder, aber in DERSELBEN Liste: auseinanderlaufen koennen sie nur,
|
||
* wenn sie an zwei Orten stehen.
|
||
*
|
||
* MASSSTAB: eilig ist, was durch Warten WERTLOS wird -- ein Anruf, ein
|
||
* Wecker, eine Nachricht, auf die jemand wartet, ein "wir sind live".
|
||
* Nicht eilig ist, was auch spaeter noch stimmt -- eine Aufgabe, die
|
||
* morgen faellig ist, der Tagesruf, eine Bewerbung. Waere alles eilig,
|
||
* waere nichts mehr eilig: Die Geraete duerfen uns die Dringlichkeit
|
||
* glauben, sonst drosseln sie uns -- und der Akku zahlt es mit. */
|
||
export function eiligRegel(art) {
|
||
return ARTEN.find((a) => a.schluessel === art)?.eilig === true;
|
||
}
|
||
|
||
/** Ist gerade Ruhezeit -- fuer diese Regel?
|
||
*
|
||
* `regel` steht hinten und hat eine Vorgabe, damit vorhandene
|
||
* Aufrufer (und die Pruefungen) unveraendert weiterlaufen. */
|
||
export function istRuhezeit(d = new Date(), regel = "immer") {
|
||
if (regel === "nie") return false;
|
||
const s = d.getHours();
|
||
/* „spaet" ist EIN Bereich und nicht zwei -- SPAET_AB liegt vor
|
||
RUHE_BIS. Die Rechnung von „immer" (zwei Bereiche um Mitternacht
|
||
herum) gilt hier also nicht, und sie hier mitzubenutzen waere ein
|
||
Fehler, den man erst um ein Uhr nachts sieht. */
|
||
if (regel === "spaet") return s >= SPAET_AB && s < RUHE_BIS;
|
||
return s >= RUHE_AB || s < RUHE_BIS;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Das Schluesselpaar des Servers. Einmal erzeugt, danach in den
|
||
Einstellungen -- NICHT im Code und nicht in einer Datei im Repo.
|
||
Der private Teil verlaesst den Server nie.
|
||
------------------------------------------------------------------ */
|
||
export function paarHolen() {
|
||
let roh = einstellung("vapid_paar");
|
||
if (roh) {
|
||
try {
|
||
const p = JSON.parse(roh);
|
||
if (p?.oeffentlich && p?.privat) return p;
|
||
} catch { /* kaputt -- unten neu erzeugen */ }
|
||
}
|
||
const neu = schluesselErzeugen();
|
||
einstellungSetzen("vapid_paar", JSON.stringify(neu));
|
||
console.log("[push] Neues VAPID-Schluesselpaar erzeugt.");
|
||
return neu;
|
||
}
|
||
|
||
/** Die Absenderangabe fuer VAPID. Der Push-Dienst schreibt hierhin,
|
||
* wenn dauerhaft etwas nicht stimmt. */
|
||
const ABSENDER = process.env.PUSH_ABSENDER || "mailto:[email protected]";
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Will diese Person diese Art bekommen?
|
||
------------------------------------------------------------------ */
|
||
export function willHaben(personId, art) {
|
||
const z = db().prepare(
|
||
"SELECT an FROM push_einstellungen WHERE person_id = ? AND art = ?").get(personId, art);
|
||
if (z) return !!z.an;
|
||
return ARTEN.find((a) => a.schluessel === art)?.vorgabe ?? true;
|
||
}
|
||
|
||
/** Der eingestellte Wert einer Art -- oder die Vorgabe.
|
||
*
|
||
* Dieselbe Bauart wie `willHaben`: Fehlt die Zeile, gilt was in
|
||
* `ARTEN` steht. Eine Vorgabe in der Datenbank waere eine zweite
|
||
* Wahrheit, die bei jeder Aenderung vergessen wird.
|
||
*/
|
||
export function wertVon(personId, art) {
|
||
const z = db().prepare(
|
||
"SELECT wert FROM push_einstellungen WHERE person_id = ? AND art = ?").get(personId, art);
|
||
const vorgabe = ARTEN.find((a) => a.schluessel === art)?.wertVorgabe ?? null;
|
||
return z?.wert ?? vorgabe;
|
||
}
|
||
|
||
/** Ist "hh:mm" eine Uhrzeit, die es gibt?
|
||
*
|
||
* Geprueft wird der BEREICH, nicht nur die Form: "99:99" passt auf
|
||
* jedes naive Muster und ist trotzdem keine Uhrzeit. Aus dem Netz
|
||
* kommt, was jemand schickt -- nicht, was die Oberflaeche anbietet.
|
||
*/
|
||
/** Ist der Tagesruf jetzt faellig? -- die Entscheidung allein.
|
||
*
|
||
* HERAUSGELOEST, DAMIT SIE PRUEFBAR IST. Im Rundgang steckte sie
|
||
* zwischen einer Datenbankabfrage und einem Push-Versand; um sie zu
|
||
* pruefen, haette man beides aufbauen muessen -- also haette man sie
|
||
* nicht geprueft. Eine Entscheidung, die nur aus Zahlen besteht,
|
||
* gehoert in eine Funktion, die nur aus Zahlen besteht.
|
||
*
|
||
* DAS FENSTER IST DREI STUNDEN BREIT. Der Takt laeuft alle fuenf
|
||
* Minuten; ein einfaches "jetzt >= eingestellte Zeit" wuerde nach
|
||
* einem Serverausfall den Ruf fuer neun Uhr um zwanzig Uhr
|
||
* zustellen -- eine Erinnerung an einen Tag, der vorbei ist. Wer sie
|
||
* um 21 Uhr bekommt, schaltet sie ab. Faellt der Tag aus, ist das die
|
||
* ehrlichere Antwort als eine Meldung zur falschen Zeit.
|
||
*
|
||
* @param jetztMinuten Minuten seit Mitternacht, Ortszeit.
|
||
* @param zeit "hh:mm", die eingestellte Uhrzeit.
|
||
*/
|
||
export const TAGESRUF_FENSTER = 180;
|
||
export function tagesrufFaellig(jetztMinuten, zeit) {
|
||
if (!istUhrzeit(zeit)) return false;
|
||
const [h, m] = zeit.split(":").map(Number);
|
||
const seit = jetztMinuten - (h * 60 + m);
|
||
return seit >= 0 && seit <= TAGESRUF_FENSTER;
|
||
}
|
||
|
||
export function istUhrzeit(t) {
|
||
if (typeof t !== "string" || !/^\d{2}:\d{2}$/.test(t)) return false;
|
||
const [h, m] = t.split(":").map(Number);
|
||
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
VERSCHICKEN — der einzige Weg nach draussen.
|
||
|
||
Alles laeuft hier durch: Ruhezeit, Einstellung, Doppelsperre,
|
||
Aufraeumen toter Anmeldungen. Eine zweite Stelle, die selbst
|
||
verschickt, wuerde eine dieser Regeln irgendwann vergessen.
|
||
------------------------------------------------------------------ */
|
||
/* =====================================================================
|
||
EINE BENACHRICHTIGUNG MUSS IRGENDWOHIN FUEHREN (16.09.2026)
|
||
|
||
Seit dem 15.09. entscheidet die Adresse, welche Seiten es gibt: Auf
|
||
crew. gibt es die Agenturseiten nicht. Fuer Seiten, Hinweise und
|
||
Suchtreffer ist das umgesetzt -- fuer Push nicht, und zwar aus einem
|
||
Grund, der stimmt: Eine Benachrichtigung entsteht, wenn NIEMAND auf
|
||
einer Adresse steht. Sie geht an ein Geraet, und das oeffnet die
|
||
Adresse, als die es installiert wurde.
|
||
|
||
Die Frage laesst sich trotzdem beantworten, nur anders: nicht ueber
|
||
die Adresse, sondern ueber den MENSCHEN. Wer im Team ist (rechte
|
||
Hand, Modi), kommt nur auf crew. herein -- das steht in
|
||
sitzungPasstZurAdresse und gilt in beide Richtungen. Fuer ihn gibt
|
||
es die Agenturseiten nirgends, auf keinem Geraet.
|
||
|
||
Also: Ein Ziel, das dieser Mensch nirgends oeffnen kann, wird zur
|
||
Startseite. Dort steht, was fuer ihn ansteht -- das ist immer noch
|
||
eine Auskunft, waehrend eine Weiterleitung ins Leere wie ein Fehler
|
||
aussieht.
|
||
|
||
DOGFATHER UND SPICY ARBEITEN IN BEIDEN HAEUSERN. Fuer sie gilt jedes
|
||
Ziel, das in EINEM der beiden existiert -- welches Geraet sie gerade
|
||
in der Hand haben, weiss hier niemand, und die Seitenschranke faengt
|
||
den Rest ab.
|
||
|
||
WARUM NICHT DIE BENACHRICHTIGUNG GANZ UNTERDRUECKEN: Weil sie meist
|
||
trotzdem stimmt. Ein Modi, der als Teilnehmer in einem Call steht,
|
||
SOLL erfahren, dass er gleich anfaengt -- nur nicht auf einer Seite
|
||
landen, die es fuer ihn nicht gibt. */
|
||
function zielFuer(personId, ziel) {
|
||
const heim = "/workspace/start.html";
|
||
if (!ziel) return heim;
|
||
try {
|
||
const p = db().prepare("SELECT rolle FROM personen WHERE id = ?").get(personId);
|
||
if (!p) return heim;
|
||
/* Nur das Team ist auf eine Adresse festgelegt. Alle anderen
|
||
arbeiten im Agenturhaus, und dort gibt es jede Seite. */
|
||
if (!TEAM_DOGI_ROLLEN.has(p.rolle)) return ziel;
|
||
const pfad = String(ziel).split("?")[0].split("#")[0];
|
||
return gehoertAufDieseAdresse({ rolle: p.rolle, haus: "crew" }, pfad) ? ziel : heim;
|
||
} catch (f) {
|
||
/* IM ZWEIFEL DIE STARTSEITE. Eine Benachrichtigung, die wegen
|
||
eines Datenbankfehlers gar nicht erst rausgeht, waere der
|
||
groessere Schaden. */
|
||
console.error("[push] Ziel pruefen:", f?.message);
|
||
return heim;
|
||
}
|
||
}
|
||
|
||
/** Welches Haus traegt diese Meldung? -> "crew" oder "agentur"
|
||
*
|
||
* Nur fuer das GESICHT der Benachrichtigung (Symbol und Abzeichen).
|
||
* Ueber Rechte entscheidet das hier nichts -- das tut `zielFuer` eine
|
||
* Funktion weiter oben, und zwar unveraendert.
|
||
*
|
||
* ZWEISTUFIG, UND BEIDE STUFEN GIBT ES SCHON:
|
||
*
|
||
* 1. Die Zielseite, wenn sie in genau ein Haus gehoert. Damit
|
||
* traegt die Einladung zur Reaction das Crew-Gesicht -- auch
|
||
* fuer DogFather, der in beiden Haeusern arbeitet und sonst
|
||
* ueberall dasselbe Symbol saehe.
|
||
*
|
||
* 2. Sonst das Haus der Person. `AGENTUR_ROLLEN` ist die
|
||
* vorhandene Menge; wer nicht darin steht, arbeitet im
|
||
* Crew-Haus. Wortgleich zu `nurAnderesHaus()`.
|
||
*
|
||
* IM ZWEIFEL "agentur" -- das ist der Stand von vor dem 29.09.2026.
|
||
* Ein Fehler hier darf das Gesicht aendern, nie die Zustellung. */
|
||
export function hausFuerMeldung(personId, ziel) {
|
||
try {
|
||
const pfad = String(ziel || "").split("?")[0].split("#")[0];
|
||
const eintrag = GEHOERT_ZU_ADRESSE.find((e) => e.datei === pfad);
|
||
if (eintrag) return eintrag.zuhause === istCrewAdresse ? "crew" : "agentur";
|
||
const p = db().prepare("SELECT rolle FROM personen WHERE id = ?").get(personId);
|
||
if (!p) return "agentur";
|
||
return AGENTUR_ROLLEN.has(p.rolle) ? "agentur" : "crew";
|
||
} catch (f) {
|
||
console.error("[push] Haus bestimmen:", f?.message);
|
||
return "agentur";
|
||
}
|
||
}
|
||
|
||
export async function benachrichtige(personId, art, { titel, text, ziel, merkmal, zahl }) {
|
||
if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" };
|
||
if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" };
|
||
|
||
/* WELCHE RUHEREGEL GILT, STEHT AN DER ART (29.09.2026). Hier stand
|
||
bis dahin `art === "test" || art === "anruf"` -- eine Aufzaehlung
|
||
230 Zeilen von der Artenliste entfernt, die niemand sieht, der
|
||
eine Art eintraegt.
|
||
|
||
Gefunden hat den Fall Filipes Satz „sie bekommt nur eine
|
||
benarichtigung ... irgendwas laeuft da gewaltig schief", um 23:44
|
||
Uhr. Serverseitig war alles in Ordnung; der Anruf wurde nur nie
|
||
ausgeliefert, weil es nach 22 Uhr war. Behoben wurde damals genau
|
||
dieser eine Fall -- die Frage dahinter blieb stehen, und
|
||
`dogfather_live` faellt bis heute in dieselbe Falle.
|
||
|
||
WER NACHTS SEINE RUHE WILL, schaltet die Art ab. Das ist eine
|
||
Entscheidung, die man selbst trifft, statt einer Regel, die man
|
||
nicht kennt. */
|
||
const regel = ruheRegel(art);
|
||
if (istRuhezeit(new Date(), regel)) return { verschickt: 0, grund: "ruhezeit" };
|
||
|
||
/* DRAENGELN IST ETWAS ANDERES ALS NACHTS STOEREN. Der Push-Dienst
|
||
kennt eine Dringlichkeit, die darueber entscheidet, ob ein
|
||
schlafendes Geraet dafuer aufwacht. Das gilt fuer den Anruf und
|
||
die Probe -- nicht fuer eine Einladung: Die soll ankommen, wenn
|
||
das Geraet ohnehin wach wird, und keinen Akku dafuer verbrennen. */
|
||
const eilig = eiligRegel(art);
|
||
/* KURZLEBIG IST NICHT DASSELBE WIE EILIG -- und das Zusammenlegen
|
||
waere hier der teure Fehler gewesen (03.10.2026).
|
||
|
||
Bis heute steuerte EIN Schalter beides: `dringend` hat das Geraet
|
||
geweckt UND die Haltbarkeit auf 150 Sekunden gesetzt. Haette ich
|
||
den Chat einfach auf `dringend` gestellt, waere aus "kommt vierzig
|
||
Minuten zu spaet" ein "kommt gar nicht mehr" geworden: Wer sein
|
||
Telefon drei Minuten aus hat, haette die Nachricht dann verloren.
|
||
Schlimmer als der Fehler, den ich beheben wollte.
|
||
|
||
Kurz haltbar bleibt deshalb genau das, was von selbst vorbei ist
|
||
-- der Anruf, der 120 Sekunden klingelt, und die Probe, bei der
|
||
jemand davorsteht. Das ist unveraendert `ruhe: "nie"`. */
|
||
const kurzlebig = regel === "nie";
|
||
|
||
/* Schon einmal geschickt? */
|
||
if (merkmal) {
|
||
const da = db().prepare(
|
||
"SELECT 1 FROM push_verschickt WHERE person_id = ? AND merkmal = ?").get(personId, merkmal);
|
||
if (da) return { verschickt: 0, grund: "schon_geschickt" };
|
||
}
|
||
|
||
const anmeldungen = db().prepare(
|
||
"SELECT * FROM push_anmeldungen WHERE person_id = ?").all(personId);
|
||
if (!anmeldungen.length) return { verschickt: 0, grund: "keine_geraete" };
|
||
|
||
const paar = paarHolen();
|
||
/* Das Ziel wird EINMAL bestimmt und mitgegeben -- damit eine Pruefung
|
||
nachsehen kann, wohin wirklich geschickt wurde, ohne die
|
||
Nachricht aufmachen zu muessen. */
|
||
const zielEcht = zielFuer(personId, ziel);
|
||
/* DAS HAUS WIRD AUS DEM ECHTEN ZIEL BESTIMMT, nicht aus dem
|
||
gewuenschten. Wird jemand von `zielFuer` auf die Startseite
|
||
umgeleitet, weil die Seite fuer ihn nicht existiert, soll auch das
|
||
Gesicht dazu passen -- sonst steht das Crew-Symbol ueber einer
|
||
Meldung, die auf der Agenturstartseite landet. */
|
||
const haus = hausFuerMeldung(personId, zielEcht);
|
||
/* `zahl` NUR, WENN EINE DABEI IST. Der Service Worker rührt das
|
||
Abzeichen sonst nicht an — eine Aufgabenerinnerung, die es auf
|
||
null setzte, löschte die ungelesenen Nachrichten vom
|
||
Startbildschirm. */
|
||
const inhalt = JSON.stringify({
|
||
titel, text, art, ziel: zielEcht, haus,
|
||
...(Number.isFinite(zahl) ? { zahl: Math.max(0, Math.round(zahl)) } : {}),
|
||
});
|
||
|
||
let raus = 0;
|
||
for (const a of anmeldungen) {
|
||
const e = await schicken(a, inhalt, paar, ABSENDER, { eilig, kurzlebig });
|
||
if (e.ok) {
|
||
raus++;
|
||
db().prepare("UPDATE push_anmeldungen SET zuletzt_ok = ?, fehler = 0 WHERE endpunkt = ?")
|
||
.run(jetzt(), a.endpunkt);
|
||
} else if (e.weg) {
|
||
/* Der Push-Dienst sagt: dieses Geraet gibt es nicht mehr.
|
||
Aufheben waere sinnlos und wuerde bei jedem Lauf erneut
|
||
scheitern. */
|
||
db().prepare("DELETE FROM push_anmeldungen WHERE endpunkt = ?").run(a.endpunkt);
|
||
} else {
|
||
db().prepare("UPDATE push_anmeldungen SET fehler = fehler + 1 WHERE endpunkt = ?")
|
||
.run(a.endpunkt);
|
||
}
|
||
}
|
||
|
||
if (raus && merkmal) {
|
||
db().prepare("INSERT OR REPLACE INTO push_verschickt (person_id, merkmal, zeit) VALUES (?,?,?)")
|
||
.run(personId, merkmal, jetzt());
|
||
}
|
||
/* `haus` kommt mit zurueck, damit eine Pruefung nachsehen kann,
|
||
welches Gesicht verschickt wurde, ohne die verschluesselte
|
||
Nachricht aufmachen zu muessen -- derselbe Gedanke wie beim
|
||
Ziel. */
|
||
return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt",
|
||
ziel: zielEcht, haus };
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
WAS STEHT AN? — der Takt.
|
||
|
||
Fragt dieselben Daten ab, aus denen auch die Hinweisliste auf der
|
||
Startseite entsteht. Bewusst KEINE eigenen Daten: Was erledigt wird,
|
||
verschwindet von selbst, und es gibt nichts zu pflegen, das
|
||
veralten kann.
|
||
------------------------------------------------------------------ */
|
||
export async function laufen() {
|
||
const d = db();
|
||
const heute = heuteLokal();
|
||
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400_000).toISOString().slice(0, 10);
|
||
let gesamt = 0;
|
||
|
||
const senden = async (personId, art, sache) => {
|
||
const e = await benachrichtige(personId, art, sache);
|
||
gesamt += e.verschickt;
|
||
};
|
||
|
||
try {
|
||
/* --- Aufgaben, die morgen faellig sind ---------------------------- */
|
||
for (const a of d.prepare(`
|
||
SELECT a.id, a.titel, a.verantwortlich_id FROM aufgaben a
|
||
WHERE a.status NOT IN ('erledigt', 'abgebrochen') AND a.frist = ? AND a.verantwortlich_id IS NOT NULL`).all(morgen)) {
|
||
await senden(a.verantwortlich_id, "aufgabe_faellig", {
|
||
titel: "Morgen fällig",
|
||
text: a.titel,
|
||
ziel: "/workspace/aufgaben.html",
|
||
merkmal: `aufgabe_faellig:${a.id}:${morgen}`,
|
||
});
|
||
}
|
||
|
||
/* --- Aufgaben, die ueberfaellig sind ------------------------------ */
|
||
for (const a of d.prepare(`
|
||
SELECT a.id, a.titel, a.frist, a.verantwortlich_id FROM aufgaben a
|
||
WHERE a.status NOT IN ('erledigt', 'abgebrochen') AND a.frist IS NOT NULL AND a.frist < ?
|
||
AND a.verantwortlich_id IS NOT NULL`).all(heute)) {
|
||
await senden(a.verantwortlich_id, "aufgabe_ueberfaellig", {
|
||
titel: "Überfällig",
|
||
text: a.titel,
|
||
ziel: "/workspace/aufgaben.html",
|
||
/* Das Datum gehoert ins Merkmal: Eine Aufgabe, die drei Wochen
|
||
liegen bleibt, soll nicht dreimal am Tag erinnern -- aber
|
||
einmal am Tag schon. */
|
||
merkmal: `aufgabe_ueberfaellig:${a.id}:${heute}`,
|
||
});
|
||
}
|
||
|
||
/* --- DER TAGESRUF: einmal am Tag, was noch offen ist --------------
|
||
|
||
Filipe (screen10): "eine taetige meldung einmal am tag zu
|
||
aktivieren wenn noch aufgaben auf sind."
|
||
|
||
NUR WER IHN EINGESCHALTET HAT. Die Vorgabe ist `false`, also
|
||
gibt es die Zeile nur, wenn jemand sie angelegt hat -- die
|
||
Abfrage ist damit zugleich die Teilnehmerliste. Kein Rundgang
|
||
ueber alle Personen, um dann 95 Prozent wieder wegzuwerfen.
|
||
|
||
EIN FENSTER VON DREI STUNDEN, und das ist der eigentliche
|
||
Gedanke hier. Der Takt laeuft alle fuenf Minuten; ein einfaches
|
||
"jetzt >= eingestellte Zeit" wuerde nach einem Serverausfall
|
||
den Ruf fuer neun Uhr um zwanzig Uhr zustellen -- eine
|
||
Erinnerung an einen Tag, der vorbei ist. Wer sie um 21 Uhr
|
||
bekommt, schaltet sie ab. Also: bis drei Stunden danach ja,
|
||
spaeter nicht mehr. Der Tag faellt dann aus, und das ist die
|
||
ehrlichere Antwort als eine Meldung zur falschen Zeit.
|
||
|
||
Die Ruhezeit prueft `benachrichtige` selbst. Wer sechs Uhr
|
||
einstellt, bekommt den Ruf um sieben (Ruhezeit bis 7) -- immer
|
||
noch im Fenster.
|
||
|
||
"WENN NOCH AUFGABEN AUF SIND" heisst: bei null Aufgaben kommt
|
||
nichts. Eine taegliche Meldung "du hast nichts zu tun" ist
|
||
genau die Sorte Nachricht, die dazu fuehrt, dass man die
|
||
naechste nicht mehr liest. */
|
||
const n = new Date();
|
||
const jetztMinuten = n.getHours() * 60 + n.getMinutes();
|
||
for (const e of d.prepare(
|
||
"SELECT person_id, wert FROM push_einstellungen WHERE art = 'tagesruf' AND an = 1").all()) {
|
||
const zeit = e.wert || ARTEN.find((a) => a.schluessel === "tagesruf")?.wertVorgabe || "09:00";
|
||
if (!tagesrufFaellig(jetztMinuten, zeit)) continue;
|
||
|
||
const z = d.prepare(`
|
||
SELECT COUNT(*) AS offen,
|
||
SUM(CASE WHEN frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS spaet
|
||
FROM aufgaben
|
||
WHERE verantwortlich_id = ? AND status NOT IN ('erledigt', 'abgebrochen')`)
|
||
.get(heute, e.person_id);
|
||
if (!z || !z.offen) continue;
|
||
|
||
/* Der Satz nennt die Zahl und, wenn es sie gibt, die
|
||
Ueberfaelligen -- das ist die Information, wegen der man
|
||
hinsieht. "Du hast offene Aufgaben" allein zwingt zum
|
||
Nachschauen und hilft niemandem. */
|
||
const spaet = z.spaet || 0;
|
||
await senden(e.person_id, "tagesruf", {
|
||
titel: z.offen === 1 ? "Eine Aufgabe ist noch offen" : `${z.offen} Aufgaben sind noch offen`,
|
||
text: spaet ? (spaet === 1 ? "Davon eine überfällig." : `Davon ${spaet} überfällig.`)
|
||
: "Nichts davon ist überfällig.",
|
||
ziel: "/workspace/aufgaben.html",
|
||
merkmal: `tagesruf:${heute}`,
|
||
});
|
||
}
|
||
|
||
/* --- Termine in der naechsten Stunde ------------------------------ */
|
||
const gleich = new Date(Date.now() + 60 * 60_000);
|
||
const p = (n) => String(n).padStart(2, "0");
|
||
const bis = `${gleich.getFullYear()}-${p(gleich.getMonth() + 1)}-${p(gleich.getDate())}`
|
||
+ `T${p(gleich.getHours())}:${p(gleich.getMinutes())}`;
|
||
const jetztText = (() => {
|
||
const n = new Date();
|
||
return `${n.getFullYear()}-${p(n.getMonth() + 1)}-${p(n.getDate())}`
|
||
+ `T${p(n.getHours())}:${p(n.getMinutes())}`;
|
||
})();
|
||
|
||
for (const t of d.prepare(`
|
||
SELECT t.id, t.titel, t.beginn, t.art FROM termine t
|
||
WHERE t.erledigt = 0 AND t.beginn >= ? AND t.beginn <= ?`).all(jetztText, bis)) {
|
||
/* An ALLE Teilnehmer, nicht nur an das Haupt-Gegenueber -- seit
|
||
ein Termin mehrere haben kann (05.09.2026). */
|
||
const leute = d.prepare(
|
||
"SELECT person_id FROM termin_teilnehmer WHERE termin_id = ?").all(t.id).map((r) => r.person_id);
|
||
for (const pid of new Set(leute)) {
|
||
await senden(pid, "termin_gleich", {
|
||
titel: t.art === "call" ? "Call in Kürze" : "Termin in Kürze",
|
||
text: `${t.titel} · ${t.beginn.slice(11, 16)} Uhr`,
|
||
ziel: t.art === "call" ? "/workspace/calls.html" : "/workspace/kalender.html",
|
||
merkmal: `termin_gleich:${t.id}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
/* --- DIE SELBST GESETZTEN WECKER ----------------------------------
|
||
|
||
Ein Wecker steht als ABSTAND in der Datenbank (minuten_vorher),
|
||
nicht als Zeitpunkt. Die Weckzeit wird deshalb hier gerechnet --
|
||
und ist damit auch nach einer Terminverschiebung richtig, ohne
|
||
dass irgendjemand etwas nachziehen muss.
|
||
|
||
ZWEI GRENZEN, und beide sind noetig:
|
||
|
||
faellig die Weckzeit ist erreicht (beginn - abstand <= jetzt)
|
||
nicht zu spaet der Termin liegt noch VOR uns
|
||
|
||
Ohne die zweite wuerde beim ersten Lauf nach einer Auszeit des
|
||
Dienstes jeder alte Wecker der letzten Wochen nachtraeglich
|
||
klingeln -- ein Schwall Meldungen zu Terminen, die laengst vorbei
|
||
sind. Das ist der Fall, den man beim Bauen nie sieht und der beim
|
||
ersten Neustart nach einem Ausfall sofort auffaellt.
|
||
|
||
DIE RUHEZEIT VERSCHLUCKT NICHTS. `benachrichtige` merkt sich erst
|
||
nach dem Verschicken, dass etwas raus ist -- was zwischen 22 und
|
||
7 Uhr faellig wird, geht also am Morgen raus, sobald der Termin
|
||
noch bevorsteht. Ein Wecker, der nachts still ist und dafuer
|
||
morgens klingelt, ist genau richtig; einer, der verschluckt wird,
|
||
waere schlimmer als keiner.
|
||
|
||
Rechnen auf Textebene geht hier NICHT (Abstand in Minuten), also
|
||
mit Date -- aber ausschliesslich lokal, nie ueber toISOString. */
|
||
const alsText = (dt) => {
|
||
const p2 = (n) => String(n).padStart(2, "0");
|
||
return `${dt.getFullYear()}-${p2(dt.getMonth() + 1)}-${p2(dt.getDate())}`
|
||
+ `T${p2(dt.getHours())}:${p2(dt.getMinutes())}`;
|
||
};
|
||
for (const w of d.prepare(`
|
||
SELECT w.termin_id, w.person_id, w.minuten_vorher,
|
||
t.titel, t.beginn, t.art
|
||
FROM termin_wecker w
|
||
JOIN termine t ON t.id = w.termin_id
|
||
WHERE t.erledigt = 0 AND t.beginn > ?`).all(jetztText)) {
|
||
const beginn = new Date(String(w.beginn).replace(" ", "T"));
|
||
if (Number.isNaN(beginn.getTime())) continue;
|
||
const weckzeit = new Date(beginn.getTime() - w.minuten_vorher * 60_000);
|
||
if (alsText(weckzeit) > jetztText) continue; // noch nicht so weit
|
||
|
||
await senden(w.person_id, "termin_wecker", {
|
||
titel: vorlauf(w.minuten_vorher),
|
||
text: `${w.titel} · ${String(w.beginn).slice(8, 10)}.${String(w.beginn).slice(5, 7)}. `
|
||
+ `um ${String(w.beginn).slice(11, 16)} Uhr`,
|
||
ziel: w.art === "call" ? "/workspace/calls.html" : "/workspace/kalender.html",
|
||
/* Der Abstand gehoert ins Merkmal. Ohne ihn wuerde der erste
|
||
Wecker eines Termins alle weiteren als "schon geschickt"
|
||
sperren -- und genau das Mehrfach-Wecken, um das es geht,
|
||
faende nie statt. */
|
||
merkmal: `wecker:${w.termin_id}:${w.minuten_vorher}`,
|
||
});
|
||
}
|
||
|
||
/* --- Gespraeche ohne Protokoll ------------------------------------ */
|
||
for (const t of d.prepare(`
|
||
SELECT t.id, t.titel, t.erstellt_von FROM termine t
|
||
LEFT JOIN protokolle pr ON pr.termin_id = t.id
|
||
WHERE t.art IN ('call','review') AND t.beginn < ? AND pr.id IS NULL
|
||
AND t.beginn > ? AND t.erstellt_von IS NOT NULL`)
|
||
.all(jetztText, heute + "T00:00")) {
|
||
await senden(t.erstellt_von, "protokoll_fehlt", {
|
||
titel: "Protokoll fehlt",
|
||
text: t.titel,
|
||
ziel: "/workspace/calls.html",
|
||
merkmal: `protokoll_fehlt:${t.id}`,
|
||
});
|
||
}
|
||
|
||
/* --- Faellige Follow-ups in der Pipeline --------------------------- */
|
||
for (const l of d.prepare(`
|
||
SELECT l.id, l.name, l.scout_id FROM leads l
|
||
WHERE l.naechster_followup IS NOT NULL AND l.naechster_followup <= ?
|
||
AND l.status NOT IN ('uebergeben','abgelehnt') AND l.scout_id IS NOT NULL`).all(heute)) {
|
||
await senden(l.scout_id, "followup", {
|
||
titel: "Nachfassen",
|
||
text: l.name,
|
||
ziel: "/workspace/scouting.html",
|
||
merkmal: `followup:${l.id}:${heute}`,
|
||
});
|
||
}
|
||
|
||
/* --- WIE GEHT ES DIR? Alle zwei Wochen, von selbst ---------------
|
||
|
||
Filipe (17.09.2026): "mach da mehr so eine automatisierte
|
||
anfrage regelmaessig."
|
||
|
||
WER GEFRAGT WIRD, steht nicht hier: Es sind genau die Rollen,
|
||
die befinden.html ueberhaupt aufrufen duerfen -- und die stehen
|
||
in rechte.js. Eine eigene Liste hier waere eine zweite, und die
|
||
eine vergessene waere jemand, der nie gefragt wird, ohne dass es
|
||
auffaellt. (Genau so ist am 11.09. eine Rolle durch den Rost
|
||
gefallen.)
|
||
|
||
OB FAELLIG, ENTSCHEIDET rhythmus() aus workspace-befinden.js --
|
||
dieselbe Funktion, die auch die Seite selbst fragt. Nicht "aelter
|
||
als 14 Tage" nachgerechnet: Die echte Regel ist "alt genug ODER
|
||
nie fertig geworden", und die haette ich hier vergessen.
|
||
|
||
HOECHSTENS EINMAL IN SIEBEN TAGEN, ueber das Merkmal mit der
|
||
Kalenderwoche. Ohne das kaeme die Erinnerung ab Tag 14 JEDEN Tag,
|
||
weil "faellig" faellig bleibt, bis jemand antwortet -- und eine
|
||
Erinnerung, die jeden Tag kommt, ist nach drei Tagen keine
|
||
Erinnerung mehr, sondern Laerm, den man abschaltet. */
|
||
const befindenRollen = (SEITEN["/workspace/befinden.html"] || []);
|
||
if (befindenRollen.length) {
|
||
const platz = befindenRollen.map(() => "?").join(", ");
|
||
/* Die Woche als Nummer -- gerechnet aus dem Datum, nicht aus
|
||
einer Bibliothek: Gebraucht wird "hoechstens einmal je sieben
|
||
Tage", nicht die kalendarisch richtige Wochennummer. */
|
||
const woche = Math.floor(Date.parse(heute + "T00:00:00Z") / (7 * 86400_000));
|
||
for (const person of d.prepare(
|
||
`SELECT id, name FROM personen WHERE aktiv = 1 AND rolle IN (${platz})`)
|
||
.all(...befindenRollen)) {
|
||
let lage;
|
||
try { lage = befindenRhythmus(person.id); } catch { continue; }
|
||
if (!lage?.faellig) continue;
|
||
await senden(person.id, "befinden", {
|
||
titel: "Wie geht es dir?",
|
||
text: lage.letzte
|
||
? "Die zwei Wochen sind um – es dauert zwei Minuten."
|
||
: "Zwölf Fragen über dich. Die Antworten sieht niemand außer dir.",
|
||
ziel: "/workspace/befinden.html",
|
||
merkmal: `befinden:${person.id}:${woche}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ===============================================================
|
||
ER IST LIVE -- einmal am Tag, und nur wenn er es wirklich ist
|
||
===============================================================
|
||
Filipe: "die leute sollen auch automatisch eine benarichtigung
|
||
bekommen um 20 uhr dass ich live bin."
|
||
|
||
WARUM NICHT EINFACH UM PUNKT 20 UHR.
|
||
Eine Meldung "er ist live", waehrend er nicht sendet, ist eine
|
||
Luege. Sie funktioniert genau einmal: Beim zweiten Mal weiss
|
||
jeder, dass die Meldung nichts bedeutet, und schaut nicht mehr
|
||
hin. Deshalb entscheidet der Live-Dienst, nicht die Uhr -- der
|
||
Lauf kommt alle fuenf Minuten vorbei, es dauert also hoechstens
|
||
fuenf Minuten laenger als 20:00.
|
||
|
||
ANTWORTET DER DIENST NICHT, wird NICHTS verschickt. "Weiss
|
||
nicht" ist hier keine halbe Meldung, sondern gar keine.
|
||
|
||
DAS MERKMAL HAELT ES BEI EINER PRO ABEND. Ohne das kaeme alle
|
||
fuenf Minuten eine neue, zwei Stunden lang -- 24 Stueck. Das
|
||
waere kein Hinweis mehr, sondern ein Grund, das Handy
|
||
wegzulegen. */
|
||
try {
|
||
const { liveStatusHolen } = await import("./workspace-draussen.js");
|
||
const lage = await liveStatusHolen();
|
||
if (lage.zustand === "live") {
|
||
/* WER BEKOMMT SIE: alle, die ein Geraet angemeldet haben. Die
|
||
Frage "wer gehoert dazu" beantwortet die Geraeteliste
|
||
besser als eine Rollenaufzaehlung -- wer sich angemeldet
|
||
hat, will benachrichtigt werden. Die Art ist ohnehin
|
||
einzeln abschaltbar. */
|
||
for (const z of d.prepare(
|
||
"SELECT DISTINCT person_id FROM push_anmeldungen").all()) {
|
||
await senden(z.person_id, "dogfather_live", {
|
||
titel: "DogFather ist LIVE",
|
||
text: lage.titel || "Der Stream läuft gerade – schau rein.",
|
||
ziel: "/workspace/unsere-seiten.html",
|
||
merkmal: `dogfather_live:${heute}`,
|
||
});
|
||
}
|
||
}
|
||
} catch (f) {
|
||
/* Der Live-Teil darf den restlichen Lauf nicht aufhalten --
|
||
Aufgaben und Termine sind wichtiger als ein Stream-Hinweis. */
|
||
console.error("[push] Live-Hinweis:", f?.message);
|
||
}
|
||
|
||
/* --- MANAGER-ZIELE: die vier Monatsaufgaben (02.10.2026) --------
|
||
|
||
GERECHNET WIRD IM FACHMODUL, verschickt hier. `zielRufe()`
|
||
liefert fertige Meldungen; dieser Lauf kennt weder die
|
||
Zielzahlen noch die Ampel. Andersherum waere es ein
|
||
Importkreis, und ueber einen Kreis kommen Konstanten als
|
||
`undefined` an, ohne dass irgendwo ein Fehler erscheint.
|
||
|
||
DAS MERKMAL ENTHAELT DEN TAG -- dadurch geht pro Person und Tag
|
||
hoechstens EINE Meldung heraus, auch wenn dieser Lauf alle
|
||
fuenf Minuten stattfindet. Ohne das waeren es 288 am Tag.
|
||
|
||
Eigener try-Block aus demselben Grund wie beim Live-Hinweis:
|
||
Ein Fehler in den Monatszielen darf die Aufgaben- und
|
||
Terminerinnerungen nicht mitnehmen. */
|
||
try {
|
||
const { zielRufe } = await import("./workspace-manager-ziele.js");
|
||
for (const r of zielRufe()) {
|
||
await senden(r.personId, r.art, {
|
||
titel: r.titel, text: r.text, ziel: r.ziel,
|
||
merkmal: r.merkmal, zahl: r.zahl,
|
||
});
|
||
}
|
||
} catch (f) {
|
||
console.error("[push] Manager-Ziele:", f?.message);
|
||
}
|
||
|
||
/* Alte Merkmale aufraeumen -- nach 60 Tagen braucht sie niemand
|
||
mehr, und die Tabelle soll nicht ewig wachsen. */
|
||
const grenze = new Date(Date.now() - 60 * 86400_000).toISOString();
|
||
d.prepare("DELETE FROM push_verschickt WHERE zeit < ?").run(grenze);
|
||
} catch (fehler) {
|
||
console.error("[push] Lauf:", fehler?.message);
|
||
}
|
||
return gesamt;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Der Takt. Wie bei der Sicherung: im Prozess, kein Systemdienst, kein
|
||
Cron -- eine Sache weniger, die stillschweigend ausfallen kann.
|
||
------------------------------------------------------------------ */
|
||
let takt = null;
|
||
export function pushStarten() {
|
||
if (takt) return;
|
||
/* Alle fuenf Minuten. Feiner braucht es nicht: Die feinste
|
||
Erinnerung ist "Termin in einer Stunde". */
|
||
takt = setInterval(() => { laufen().catch(() => {}); }, 5 * 60_000);
|
||
takt.unref?.();
|
||
setTimeout(() => { laufen().catch(() => {}); }, 20_000).unref?.();
|
||
}
|
||
|
||
/* =====================================================================
|
||
Die Schnittstellen
|
||
===================================================================== */
|
||
|
||
function angemeldet(req, res, next) {
|
||
const person = sitzungLesen(req);
|
||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||
req.person = person;
|
||
next();
|
||
}
|
||
|
||
function gleicheHerkunft(req, res, next) {
|
||
const herkunft = req.get("origin");
|
||
if (!herkunft) return next();
|
||
let erlaubt;
|
||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||
next();
|
||
}
|
||
|
||
pushRouter.use("/workspace/api/push", angemeldet);
|
||
|
||
/** Was der Browser braucht, um sich anzumelden: der oeffentliche
|
||
* Schluessel. Der private bleibt hier. */
|
||
pushRouter.get("/workspace/api/push/schluessel", (req, res) => {
|
||
try {
|
||
res.json({ schluessel: paarHolen().oeffentlich });
|
||
} catch (fehler) {
|
||
console.error("[push] Schluessel:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Der eigene Stand: angemeldete Geraete und die Einstellungen. */
|
||
pushRouter.get("/workspace/api/push/stand", (req, res) => {
|
||
try {
|
||
const geraete = db().prepare(`
|
||
SELECT endpunkt, geraet, erstellt, zuletzt_ok FROM push_anmeldungen
|
||
WHERE person_id = ? ORDER BY erstellt`).all(req.person.id);
|
||
const arten = ARTEN.map((a) => ({
|
||
...a, an: willHaben(req.person.id, a.schluessel),
|
||
/* Nur dort, wo die Art ueberhaupt einen Wert kennt -- sonst
|
||
staende bei sieben Arten ein `wert: null` in der Antwort und
|
||
jemand baut irgendwann ein Eingabefeld dafuer. */
|
||
...(a.wertArt ? { wert: wertVon(req.person.id, a.schluessel) } : {}),
|
||
}));
|
||
res.json({
|
||
/* Nur die Anzahl und ein Kuerzel nach aussen -- der volle
|
||
Endpunkt ist die Zustelladresse dieses Geraets und hat in der
|
||
Oberflaeche nichts zu suchen. */
|
||
geraete: geraete.map((g) => ({
|
||
kennung: g.endpunkt.slice(-12),
|
||
geraet: g.geraet || "Unbekanntes Gerät",
|
||
erstellt: g.erstellt, zuletzt_ok: g.zuletzt_ok,
|
||
})),
|
||
arten,
|
||
ruhe: { ab: RUHE_AB, bis: RUHE_BIS, jetzt: istRuhezeit() },
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[push] Stand:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein Geraet anmelden. */
|
||
pushRouter.post("/workspace/api/push/anmelden", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const { endpunkt, p256dh, auth, geraet } = req.body || {};
|
||
if (typeof endpunkt !== "string" || !/^https:\/\//.test(endpunkt) || endpunkt.length > 1000) {
|
||
return res.status(400).json({ fehler: "Die Adresse sieht nicht richtig aus." });
|
||
}
|
||
if (typeof p256dh !== "string" || typeof auth !== "string" || !p256dh || !auth) {
|
||
return res.status(400).json({ fehler: "Es fehlen die Schlüssel des Browsers." });
|
||
}
|
||
|
||
/* INSERT OR REPLACE auf dem Endpunkt: Meldet sich derselbe Browser
|
||
erneut, wird der Eintrag ersetzt statt verdoppelt -- sonst kaeme
|
||
jede Nachricht mehrfach an. */
|
||
db().prepare(`
|
||
INSERT OR REPLACE INTO push_anmeldungen
|
||
(endpunkt, person_id, p256dh, auth, geraet, erstellt, zuletzt_ok, fehler)
|
||
VALUES (?,?,?,?,?,?,NULL,0)`).run(
|
||
endpunkt, req.person.id, p256dh, auth,
|
||
String(geraet || "").slice(0, 80) || null, jetzt());
|
||
|
||
protokolliere("push_angemeldet", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: String(geraet || "").slice(0, 60),
|
||
});
|
||
res.status(201).json({ ok: true });
|
||
} catch (fehler) {
|
||
console.error("[push] Anmelden:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein Geraet abmelden. */
|
||
pushRouter.post("/workspace/api/push/abmelden", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const endpunkt = String(req.body?.endpunkt || "");
|
||
/* NUR die eigenen -- sonst koennte man fremde Geraete stumm
|
||
schalten, wenn man deren Adresse kennt. */
|
||
const e = db().prepare("DELETE FROM push_anmeldungen WHERE endpunkt = ? AND person_id = ?")
|
||
.run(endpunkt, req.person.id);
|
||
protokolliere("push_abgemeldet", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
});
|
||
res.json({ ok: true, entfernt: Number(e.changes) });
|
||
} catch (fehler) {
|
||
console.error("[push] Abmelden:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Eine Art an- oder abschalten. */
|
||
pushRouter.put("/workspace/api/push/art", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const art = String(req.body?.art || "");
|
||
if (!ARTEN_SCHLUESSEL.has(art)) return res.status(400).json({ fehler: "Diese Art gibt es nicht." });
|
||
const an = req.body?.an ? 1 : 0;
|
||
|
||
/* DER WERT WIRD NUR ANGEFASST, WENN EINER MITKAM.
|
||
|
||
`undefined` und `null` sind hier zweierlei: Wer nur den Schalter
|
||
umlegt, schickt kein `wert` -- die eingestellte Uhrzeit muss
|
||
dann stehen bleiben. Wuerde ich hier stumpf `req.body.wert`
|
||
schreiben, loeschte jedes Ein- und Ausschalten die Uhrzeit, und
|
||
beim naechsten Einschalten staende wieder neun Uhr da. Ein
|
||
stiller Datenverlust, den niemand meldet, weil er wie eine
|
||
Vorgabe aussieht. */
|
||
const definiert = Object.prototype.hasOwnProperty.call(req.body || {}, "wert");
|
||
let wert = null;
|
||
if (definiert) {
|
||
const kennt = ARTEN.find((a) => a.schluessel === art)?.wertArt;
|
||
if (!kennt) return res.status(400).json({ fehler: "Diese Art hat keinen Wert." });
|
||
wert = req.body.wert;
|
||
if (kennt === "uhrzeit" && !istUhrzeit(wert)) {
|
||
return res.status(400).json({ fehler: "Das ist keine Uhrzeit (hh:mm)." });
|
||
}
|
||
}
|
||
|
||
if (definiert) {
|
||
db().prepare(`INSERT INTO push_einstellungen (person_id, art, an, wert) VALUES (?,?,?,?)
|
||
ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an, wert = excluded.wert`)
|
||
.run(req.person.id, art, an, wert);
|
||
} else {
|
||
db().prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,?)
|
||
ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an`).run(req.person.id, art, an);
|
||
}
|
||
res.json({ ok: true, art, an: !!an, wert: wertVon(req.person.id, art) });
|
||
} catch (fehler) {
|
||
console.error("[push] Art:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Eine Probe an die eigenen Geraete. Beantwortet die einzige Frage,
|
||
* die man wirklich hat: Kommt etwas an? */
|
||
pushRouter.post("/workspace/api/push/probe", gleicheHerkunft, async (req, res) => {
|
||
try {
|
||
const e = await benachrichtige(req.person.id, "test", {
|
||
titel: "Es funktioniert",
|
||
text: "So sieht eine Benachrichtigung aus dem Workspace aus.",
|
||
ziel: "/workspace/start.html",
|
||
});
|
||
res.json(e);
|
||
} catch (fehler) {
|
||
console.error("[push] Probe:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|