feat(notify): bundled notifications, task watchers, daily summary
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Per-user routing (own mail / own Discord webhook) for task alarms
(sent at once), shares, completed shared tasks and watched tasks;
settings as JSON on the user (GET /auth/me, PATCH /auth/settings).
- Everything but alarms is queued and sent as one message per user
after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an
HTML version, Discord as one embed. Three failed attempts drop it.
- Own changes never notify the actor, except on watched tasks when
asked for; "paused" stops everything including alarms.
- Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks.
- Daily summary at a chosen time: overdue, today, tomorrow, plus the
count of the next 7 days; nothing due, nothing sent.
- Migration 7; jobs do not run in the demo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
362dd3b4c6
commit
a69bcbaf10
22 files changed
+1014
-17
No files matched your search
@@ -67,6 +67,9 @@ SMTP_FROM=
|
|||||||
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
|
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
|
||||||
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
|
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
|
||||||
SMTP_FROM_NAME=MindSift
|
SMTP_FROM_NAME=MindSift
|
||||||
|
# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele
|
||||||
|
# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort.
|
||||||
|
NOTIFY_DIGEST_MINUTES=5
|
||||||
# true = nur verschlüsselt senden.
|
# true = nur verschlüsselt senden.
|
||||||
SMTP_TLS=true
|
SMTP_TLS=true
|
||||||
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Benachrichtigungen pro Ereignis und Kanal: fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles.
|
||||||
|
- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`.
|
||||||
|
- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
|
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -24,6 +24,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
||||||
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
||||||
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
||||||
|
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||||
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
|
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
|
||||||
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
|
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
|
||||||
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
||||||
@@ -40,7 +41,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||||
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
||||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||||
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||||
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
|
|||||||
|
|
||||||
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
|
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
|
||||||
|
|
||||||
|
## Benachrichtigungen
|
||||||
|
|
||||||
|
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) und eigener Discord-Webhook aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
|
||||||
|
|
||||||
## Link-Vorschau
|
## Link-Vorschau
|
||||||
|
|
||||||
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, `<title>`, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
|
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, `<title>`, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
|||||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||||
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
||||||
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
||||||
|
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
||||||
|
| `task_watchers` | Beobachtete Aufgaben | `task_id`, `user_id`, `created_at` |
|
||||||
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- SMTP_PASS=${SMTP_PASS:-}
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- SMTP_FROM=${SMTP_FROM:-}
|
||||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
|
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
|
||||||
|
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
|
||||||
- SMTP_TLS=${SMTP_TLS:-true}
|
- SMTP_TLS=${SMTP_TLS:-true}
|
||||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||||
# Impressum / Datenschutz
|
# Impressum / Datenschutz
|
||||||
|
|||||||
+27
-1
@@ -13,6 +13,11 @@ import (
|
|||||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||||
|
|
||||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||||
|
return s.sendEmailHTML(toEmail, subject, bodyText, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEmailHTML sends text plus an optional HTML version (multipart/alternative).
|
||||||
|
func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
|
||||||
if s.cfg.DemoMode {
|
if s.cfg.DemoMode {
|
||||||
return errDemoOutgoing
|
return errDemoOutgoing
|
||||||
}
|
}
|
||||||
@@ -20,7 +25,7 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
|
if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
|
||||||
step := ""
|
step := ""
|
||||||
if se, ok := err.(*smtpStepError); ok {
|
if se, ok := err.(*smtpStepError); ok {
|
||||||
step = se.Step
|
step = se.Step
|
||||||
@@ -123,6 +128,27 @@ func (s *Server) startAlarmTicker() {
|
|||||||
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
|
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Channels come from the notification settings (row "due"); a pause holds the alarm back
|
||||||
|
set := s.store.NotificationSettingsOf(a.UserID)
|
||||||
|
if set.Paused {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channels := []string{}
|
||||||
|
if set.Events.Due.Email {
|
||||||
|
channels = append(channels, "email")
|
||||||
|
}
|
||||||
|
if set.Events.Due.Discord {
|
||||||
|
channels = append(channels, "discord")
|
||||||
|
}
|
||||||
|
for _, ch := range a.AlarmChannels {
|
||||||
|
if ch == "webhook" {
|
||||||
|
channels = append(channels, "webhook")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.AlarmEmail == "" {
|
||||||
|
a.AlarmEmail = a.UserEmail
|
||||||
|
}
|
||||||
|
a.AlarmChannels = channels
|
||||||
if len(a.AlarmChannels) == 0 {
|
if len(a.AlarmChannels) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
stats, _ := s.store.GetDashboardStats(userID)
|
stats, _ := s.store.GetDashboardStats(userID)
|
||||||
|
ns := s.store.NotificationSettingsOf(userID)
|
||||||
|
user.NotificationSettings = &ns
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"user": user,
|
"user": user,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
@@ -448,6 +450,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||||
// Needed to change password or e-mail address (FLAW-30)
|
// Needed to change password or e-mail address (FLAW-30)
|
||||||
CurrentPassword string `json:"currentPassword"`
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
// Which notifications go where (notify.go)
|
||||||
|
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -528,6 +532,14 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
if changesPassword || changesEmail {
|
if changesPassword || changesEmail {
|
||||||
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
|
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
|
||||||
}
|
}
|
||||||
|
if req.NotificationSettings != nil {
|
||||||
|
if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns := s.store.NotificationSettingsOf(userID)
|
||||||
|
u.NotificationSettings = &ns
|
||||||
writeJSON(w, http.StatusOK, u)
|
writeJSON(w, http.StatusOK, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if req.TeamID != "" {
|
if req.TeamID != "" {
|
||||||
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
|
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
|
||||||
|
if err == nil {
|
||||||
|
defer s.notifyShared(userID, "team", req.TeamID, "die Notiz", s.docTitle(userID, docID))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID))
|
||||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
|
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
|
||||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||||
writeJSON(w, http.StatusCreated, share)
|
writeJSON(w, http.StatusCreated, share)
|
||||||
@@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, users)
|
writeJSON(w, http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) docTitle(userID, docID string) string {
|
||||||
|
if d, err := s.store.GetDocumentByID(userID, docID); err == nil && d.Title != "" {
|
||||||
|
return d.Title
|
||||||
|
}
|
||||||
|
return "Unbenannte Notiz"
|
||||||
|
}
|
||||||
@@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.notifyShared(userID, share.TargetType, share.TargetID, "den Ordner", s.store.SharedFolderName(folderID))
|
||||||
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
|
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
|
||||||
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
|
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
|
||||||
for _, docID := range docIDs {
|
for _, docID := range docIDs {
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if l, err := s.store.GetListByID(currentUserID, listID); err == nil {
|
||||||
|
s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title)
|
||||||
|
}
|
||||||
if targetType == "team" {
|
if targetType == "team" {
|
||||||
members, _ := s.store.GetTeamMembers(share.TargetID)
|
members, _ := s.store.GetTeamMembers(share.TargetID)
|
||||||
for _, tm := range members {
|
for _, tm := range members {
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
|
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
|
||||||
|
if tl, err := s.store.GetTaskListByID(userID, listID); err == nil {
|
||||||
|
s.notifyShared(userID, req.TargetType, req.TargetID, "die Aufgabenliste", tl.Title)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusCreated, share)
|
writeJSON(w, http.StatusCreated, share)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.markWatching(userID, thoughts)
|
||||||
writeJSON(w, http.StatusOK, thoughts)
|
writeJSON(w, http.StatusOK, thoughts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +279,8 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.notifyDocChanged(docID, head)
|
s.notifyDocChanged(docID, head)
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
||||||
|
s.notifyTaskChange(userID, before, updated, false)
|
||||||
|
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreError(w, http.StatusInternalServerError, err)
|
writeStoreError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if th != nil {
|
||||||
|
s.notifyTaskChange(userID, th, nil, true)
|
||||||
|
}
|
||||||
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
|
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -117,7 +117,7 @@ func senderAddress(raw, name string) (envelope, header string, err error) {
|
|||||||
|
|
||||||
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
|
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
|
||||||
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
|
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
|
||||||
func buildMessage(from, to, subject, body string, now time.Time) []byte {
|
func buildMessage(from, to, subject, body, html string, now time.Time) []byte {
|
||||||
idBytes := make([]byte, 12)
|
idBytes := make([]byte, 12)
|
||||||
_, _ = rand.Read(idBytes)
|
_, _ = rand.Read(idBytes)
|
||||||
domain := ehloName(from)
|
domain := ehloName(from)
|
||||||
@@ -129,12 +129,26 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
|
|||||||
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
|
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
|
||||||
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
|
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
|
||||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||||
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
part := func(contentType, text string) {
|
||||||
|
fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType)
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
qp := quotedprintable.NewWriter(&buf)
|
qp := quotedprintable.NewWriter(&buf)
|
||||||
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")))
|
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n")))
|
||||||
_ = qp.Close()
|
_ = qp.Close()
|
||||||
buf.WriteString("\r\n")
|
buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
if html == "" {
|
||||||
|
part("text/plain", body)
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
// Text for simple clients, HTML for the rest
|
||||||
|
boundary := "ms-" + hex.EncodeToString(idBytes)
|
||||||
|
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
|
||||||
|
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||||
|
part("text/plain", body)
|
||||||
|
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||||
|
part("text/html", html)
|
||||||
|
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,14 +213,14 @@ func authenticate(client *smtp.Client, cfg smtpSettings) error {
|
|||||||
|
|
||||||
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
|
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
|
||||||
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
|
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
|
||||||
func sendMail(cfg smtpSettings, to, subject, body string) error {
|
func sendMail(cfg smtpSettings, to, subject, body, html string) error {
|
||||||
err := sendMailOnce(cfg, to, subject, body)
|
err := sendMailOnce(cfg, to, subject, body, html)
|
||||||
var first *authAttemptError
|
var first *authAttemptError
|
||||||
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
|
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg.authMech = "LOGIN"
|
cfg.authMech = "LOGIN"
|
||||||
retryErr := sendMailOnce(cfg, to, subject, body)
|
retryErr := sendMailOnce(cfg, to, subject, body, html)
|
||||||
var second *authAttemptError
|
var second *authAttemptError
|
||||||
if retryErr == nil || !errors.As(retryErr, &second) {
|
if retryErr == nil || !errors.As(retryErr, &second) {
|
||||||
return retryErr
|
return retryErr
|
||||||
@@ -220,7 +234,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
|
|||||||
|
|
||||||
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||||
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
|
func sendMailOnce(cfg smtpSettings, to, subject, body, html string) error {
|
||||||
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
|
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &smtpStepError{"Absender", err}
|
return &smtpStepError{"Absender", err}
|
||||||
@@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return &smtpStepError{"DATA", err}
|
return &smtpStepError{"DATA", err}
|
||||||
}
|
}
|
||||||
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil {
|
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, html, time.Now())); err != nil {
|
||||||
_ = wc.Close()
|
_ = wc.Close()
|
||||||
return &smtpStepError{"DATA", err}
|
return &smtpStepError{"DATA", err}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,13 +175,13 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
|
|||||||
f := startFakeSMTP(t, "")
|
f := startFakeSMTP(t, "")
|
||||||
|
|
||||||
// The certificate is self-signed: refused unless verification is switched off
|
// The certificate is self-signed: refused unless verification is switched off
|
||||||
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo")
|
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo", "")
|
||||||
var stepErr *smtpStepError
|
var stepErr *smtpStepError
|
||||||
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
|
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
|
||||||
t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
|
t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil {
|
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut", ""); err != nil {
|
||||||
t.Fatalf("send with SkipVerify failed: %v", err)
|
t.Fatalf("send with SkipVerify failed: %v", err)
|
||||||
}
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -206,7 +206,7 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
|
|||||||
|
|
||||||
func TestSendMailReportsTheRefusingStep(t *testing.T) {
|
func TestSendMailReportsTheRefusingStep(t *testing.T) {
|
||||||
f := startFakeSMTP(t, "451 4.3.2 Internal server error")
|
f := startFakeSMTP(t, "451 4.3.2 Internal server error")
|
||||||
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo")
|
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", "")
|
||||||
var stepErr *smtpStepError
|
var stepErr *smtpStepError
|
||||||
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
|
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
|
||||||
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
|
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
|
||||||
@@ -218,7 +218,7 @@ func TestSendMailWithDisplayNameSender(t *testing.T) {
|
|||||||
f := startFakeSMTP(t, "")
|
f := startFakeSMTP(t, "")
|
||||||
cfg := f.settings(true)
|
cfg := f.settings(true)
|
||||||
cfg.From = "MindSift Alarme <alarm@firma.de>"
|
cfg.From = "MindSift Alarme <alarm@firma.de>"
|
||||||
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil {
|
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo", ""); err != nil {
|
||||||
t.Fatalf("send failed: %v", err)
|
t.Fatalf("send failed: %v", err)
|
||||||
}
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -244,7 +244,7 @@ func TestAuthFallsBackToLogin(t *testing.T) {
|
|||||||
f := startFakeSMTP(t, "")
|
f := startFakeSMTP(t, "")
|
||||||
f.authMechs = "PLAIN LOGIN"
|
f.authMechs = "PLAIN LOGIN"
|
||||||
f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
||||||
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
|
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil {
|
||||||
t.Fatalf("LOGIN fallback should succeed: %v", err)
|
t.Fatalf("LOGIN fallback should succeed: %v", err)
|
||||||
}
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -257,7 +257,7 @@ func TestAuthFallsBackToLogin(t *testing.T) {
|
|||||||
func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
|
func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
|
||||||
f := startFakeSMTP(t, "")
|
f := startFakeSMTP(t, "")
|
||||||
f.authMechs = "LOGIN"
|
f.authMechs = "LOGIN"
|
||||||
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
|
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil {
|
||||||
t.Fatalf("LOGIN-only server: %v", err)
|
t.Fatalf("LOGIN-only server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
|
|||||||
g.authMechs = "PLAIN LOGIN"
|
g.authMechs = "PLAIN LOGIN"
|
||||||
g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
||||||
g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
|
g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
|
||||||
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo")
|
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo", "")
|
||||||
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
|
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
|
||||||
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
|
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one
|
||||||
|
// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks
|
||||||
|
// the channels per event kind (own mail address, own Discord webhook from the alarm settings), can
|
||||||
|
// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the
|
||||||
|
// one who made them, except on watched tasks when the user asked for it. The demo sends nothing.
|
||||||
|
|
||||||
|
const notifyMaxAttempts = 3
|
||||||
|
|
||||||
|
// notifyItem is one line of a bundled message.
|
||||||
|
type notifyItem struct {
|
||||||
|
kind, title, text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify queues an event for the recipients that want it (settings, pause, own-change rule).
|
||||||
|
func (s *Server) notify(kind, actorID string, recipients []string, payload map[string]string) {
|
||||||
|
if s.cfg.DemoMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, uid := range recipients {
|
||||||
|
if uid == "" || seen[uid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[uid] = true
|
||||||
|
set := s.store.NotificationSettingsOf(uid)
|
||||||
|
if set.Paused {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch := set.Channels(kind); !ch.Email && !ch.Discord {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.QueueNotification(uid, kind, payload); err != nil {
|
||||||
|
slog.Warn("Benachrichtigung konnte nicht vorgemerkt werden", "user_id", uid, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) actorName(userID string) string {
|
||||||
|
if u, err := s.store.GetUserByID(userID); err == nil && u != nil && u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
return "Jemand"
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyShared tells the people a note, folder or list was shared with (teams: all members).
|
||||||
|
func (s *Server) notifyShared(actorID, targetType, targetID, what, title string) {
|
||||||
|
var recipients []string
|
||||||
|
if targetType == "team" {
|
||||||
|
members, _ := s.store.GetTeamMembers(targetID)
|
||||||
|
for _, m := range members {
|
||||||
|
recipients = append(recipients, m.UserID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recipients = []string{targetID}
|
||||||
|
}
|
||||||
|
s.notify("shared", actorID, recipients, map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"text": fmt.Sprintf("%s hat %s „%s“ mit dir geteilt", s.actorName(actorID), what, title),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyTaskChange covers watchers (any change) and list members (task completed).
|
||||||
|
func (s *Server) notifyTaskChange(actorID string, before, after *domain.Thought, deleted bool) {
|
||||||
|
t := after
|
||||||
|
if t == nil {
|
||||||
|
t = before
|
||||||
|
}
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(strings.SplitN(t.Content, "\n", 2)[0])
|
||||||
|
name := s.actorName(actorID)
|
||||||
|
var change string
|
||||||
|
switch {
|
||||||
|
case deleted:
|
||||||
|
change = "gelöscht"
|
||||||
|
case before != nil && after != nil && !before.IsCompleted && after.IsCompleted:
|
||||||
|
change = "erledigt"
|
||||||
|
case before != nil && after != nil && before.IsCompleted && !after.IsCompleted:
|
||||||
|
change = "wieder geöffnet"
|
||||||
|
case before != nil && after != nil && before.DueDate != after.DueDate:
|
||||||
|
change = "Fälligkeit geändert auf " + domain.HumanDue(after.DueDate)
|
||||||
|
case before != nil && after != nil && before.Content != after.Content:
|
||||||
|
change = "Text geändert"
|
||||||
|
case before != nil && after != nil && before.Priority != after.Priority:
|
||||||
|
change = "Priorität geändert auf " + after.Priority
|
||||||
|
case before != nil && after != nil && before.TaskListID != after.TaskListID:
|
||||||
|
change = "in eine andere Liste verschoben"
|
||||||
|
default:
|
||||||
|
change = "geändert"
|
||||||
|
}
|
||||||
|
payload := map[string]string{"title": title, "text": fmt.Sprintf("%s hat „%s“ %s", name, title, change)}
|
||||||
|
watchers := s.store.TaskWatchers(t.ID)
|
||||||
|
s.notify("watched", actorID, watchers, payload)
|
||||||
|
|
||||||
|
if change == "erledigt" && t.TaskListID != "" {
|
||||||
|
watching := map[string]bool{}
|
||||||
|
for _, w := range watchers {
|
||||||
|
watching[w] = true
|
||||||
|
}
|
||||||
|
var members []string
|
||||||
|
for _, uid := range s.taskAudience(t) {
|
||||||
|
if !watching[uid] { // watchers already hear about it
|
||||||
|
members = append(members, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.notify("taskDone", actorID, members, payload)
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
s.store.ForgetTaskWatchers(t.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Background jobs ---
|
||||||
|
|
||||||
|
func (s *Server) startNotificationJobs() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for now := range ticker.C {
|
||||||
|
s.flushNotifications(now)
|
||||||
|
s.sendDailySummaries(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushNotifications sends one bundled message per recipient whose oldest event waited long enough.
|
||||||
|
func (s *Server) flushNotifications(now time.Time) {
|
||||||
|
minutes := s.cfg.NotifyDigestMinutes
|
||||||
|
if minutes < 1 {
|
||||||
|
minutes = 5
|
||||||
|
}
|
||||||
|
window := time.Duration(minutes) * time.Minute
|
||||||
|
users, err := s.store.DueNotificationUsers(now.Add(-window))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Benachrichtigungen konnten nicht gelesen werden", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, uid := range users {
|
||||||
|
queued, err := s.store.QueuedNotifications(uid)
|
||||||
|
if err != nil || len(queued) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set := s.store.NotificationSettingsOf(uid)
|
||||||
|
var emailItems, discordItems []notifyItem
|
||||||
|
ids := make([]int64, 0, len(queued))
|
||||||
|
maxAttempts := 0
|
||||||
|
for _, q := range queued {
|
||||||
|
ids = append(ids, q.ID)
|
||||||
|
if q.Attempts > maxAttempts {
|
||||||
|
maxAttempts = q.Attempts
|
||||||
|
}
|
||||||
|
if set.Paused {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
||||||
|
ch := set.Channels(q.Kind)
|
||||||
|
if ch.Email {
|
||||||
|
emailItems = append(emailItems, item)
|
||||||
|
}
|
||||||
|
if ch.Discord {
|
||||||
|
discordItems = append(discordItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil {
|
||||||
|
if maxAttempts+1 >= notifyMaxAttempts {
|
||||||
|
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
||||||
|
_ = s.store.DeleteQueuedNotifications(ids)
|
||||||
|
} else {
|
||||||
|
_ = s.store.BumpQueuedNotifications(ids)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = s.store.DeleteQueuedNotifications(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver sends the items to the user's mail address and/or Discord webhook.
|
||||||
|
func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error {
|
||||||
|
u, err := s.store.GetUserByID(userID)
|
||||||
|
if err != nil || u == nil || !u.IsActive {
|
||||||
|
return nil // account gone: nothing to deliver
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
if len(emailItems) > 0 {
|
||||||
|
to := u.AlarmEmail
|
||||||
|
if to == "" {
|
||||||
|
to = u.Email
|
||||||
|
}
|
||||||
|
subject := "MindSift: " + heading
|
||||||
|
if len(emailItems) == 1 {
|
||||||
|
subject = "MindSift: " + emailItems[0].text
|
||||||
|
}
|
||||||
|
if err := s.sendNotifyEmail(to, subject, notifyText(heading, emailItems, s.notifyLink()), notifyHTML(heading, emailItems, s.notifyLink())); err != nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(discordItems) > 0 && u.DiscordWebhookURL != "" {
|
||||||
|
if err := s.sendNotifyDiscord(u.DiscordWebhookURL, heading, discordItems); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNotifyEmail / sendNotifyDiscord are replaced in tests.
|
||||||
|
func (s *Server) sendNotifyEmail(to, subject, text, htmlBody string) error {
|
||||||
|
if s.notifyEmailHook != nil {
|
||||||
|
return s.notifyEmailHook(to, subject, text, htmlBody)
|
||||||
|
}
|
||||||
|
return s.sendEmailHTML(to, subject, text, htmlBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyItem) error {
|
||||||
|
if s.notifyDiscordHook != nil {
|
||||||
|
return s.notifyDiscordHook(webhookURL, heading, items)
|
||||||
|
}
|
||||||
|
return s.sendDiscordDigest(webhookURL, heading, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) notifyLink() string {
|
||||||
|
if !s.cfg.BaseURLSet {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimRight(s.cfg.BaseURL, "/") + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendDiscordDigest posts one embed with a line per item (at most 10).
|
||||||
|
func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyItem) error {
|
||||||
|
if s.cfg.DemoMode {
|
||||||
|
return errDemoOutgoing
|
||||||
|
}
|
||||||
|
allowPrivate := s.allowPrivateWebhooks()
|
||||||
|
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, 11)
|
||||||
|
for i, it := range items {
|
||||||
|
if i == 10 {
|
||||||
|
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-10))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, "• "+it.text)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"embeds": []map[string]interface{}{{
|
||||||
|
"title": "MindSift: " + heading,
|
||||||
|
"description": strings.Join(lines, "\n"),
|
||||||
|
"color": 2450411,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyText(heading string, items []notifyItem, link string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(heading + "\n\n")
|
||||||
|
for _, it := range items {
|
||||||
|
b.WriteString("• " + it.text + "\n")
|
||||||
|
}
|
||||||
|
if link != "" {
|
||||||
|
b.WriteString("\nÖffnen: " + link + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\nDiese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyHTML: simple table layout with inline styles, no external resources (works in most clients).
|
||||||
|
func notifyHTML(heading string, items []notifyItem, link string) string {
|
||||||
|
var cards strings.Builder
|
||||||
|
for _, it := range items {
|
||||||
|
cards.WriteString(`<tr><td style="padding:12px 16px;border:1px solid #e3e6ea;border-radius:8px;background:#ffffff;font-size:14px;color:#1f2328">` +
|
||||||
|
html.EscapeString(it.text) + `</td></tr><tr><td style="height:8px"></td></tr>`)
|
||||||
|
}
|
||||||
|
button := ""
|
||||||
|
if link != "" {
|
||||||
|
button = `<tr><td style="padding-top:8px"><a href="` + html.EscapeString(link) +
|
||||||
|
`" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px">In MindSift öffnen</a></td></tr>`
|
||||||
|
}
|
||||||
|
return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">` +
|
||||||
|
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto">` +
|
||||||
|
`<tr><td style="font-size:18px;font-weight:bold;color:#2563eb;padding-bottom:4px">MindSift</td></tr>` +
|
||||||
|
`<tr><td style="font-size:15px;color:#1f2328;padding-bottom:16px">` + html.EscapeString(heading) + `</td></tr>` +
|
||||||
|
cards.String() + button +
|
||||||
|
`<tr><td style="font-size:12px;color:#6b7280;padding-top:20px">Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.</td></tr>` +
|
||||||
|
`</table></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily summary ---
|
||||||
|
|
||||||
|
// sendDailySummaries sends each user who wants one their summary in the 30 minutes after the chosen
|
||||||
|
// time, once a day. Nothing due → no message.
|
||||||
|
func (s *Server) sendDailySummaries(now time.Time) {
|
||||||
|
day := now.Format("2006-01-02")
|
||||||
|
for _, uid := range s.store.ActiveUserIDs() {
|
||||||
|
set := s.store.NotificationSettingsOf(uid)
|
||||||
|
ds := set.DailySummary
|
||||||
|
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
|
||||||
|
if err != nil || now.Before(at) || now.Sub(at) >= 30*time.Minute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items := s.summaryItems(uid, now)
|
||||||
|
if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mail, discord []notifyItem
|
||||||
|
if ds.Email {
|
||||||
|
mail = items
|
||||||
|
}
|
||||||
|
if ds.Discord {
|
||||||
|
discord = items
|
||||||
|
}
|
||||||
|
if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil {
|
||||||
|
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// summaryItems: overdue, today, tomorrow (with time), plus how many follow in the next 7 days.
|
||||||
|
func (s *Server) summaryItems(userID string, now time.Time) []notifyItem {
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
|
||||||
|
tasks, err := s.store.OpenTasksDueBefore(userID, now.AddDate(0, 0, 8).Format("2006-01-02"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].DueDate < tasks[j].DueDate })
|
||||||
|
var items []notifyItem
|
||||||
|
later := 0
|
||||||
|
for _, t := range tasks {
|
||||||
|
d := t.DueDate
|
||||||
|
if len(d) > 10 {
|
||||||
|
d = d[:10]
|
||||||
|
}
|
||||||
|
label := t.Title
|
||||||
|
if t.ListTitle != "" {
|
||||||
|
label += " (" + t.ListTitle + ")"
|
||||||
|
}
|
||||||
|
clock := ""
|
||||||
|
if len(t.DueDate) >= 16 {
|
||||||
|
clock = ", " + t.DueDate[11:16] + " Uhr"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < today:
|
||||||
|
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Überfällig seit " + domain.HumanDue(t.DueDate) + ": " + label})
|
||||||
|
case d == today:
|
||||||
|
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Heute" + clock + ": " + label})
|
||||||
|
case d == tomorrow:
|
||||||
|
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Morgen" + clock + ": " + label})
|
||||||
|
default:
|
||||||
|
later++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 0 && later > 0 {
|
||||||
|
items = append(items, notifyItem{kind: "due", text: fmt.Sprintf("Außerdem %d weitere Aufgaben in den nächsten 7 Tagen", later)})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Watching tasks ---
|
||||||
|
|
||||||
|
// handleWatchTask: POST (watch) / DELETE (stop) /tasks/{id}/watch
|
||||||
|
func (s *Server) handleWatchTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
taskID := chi.URLParam(r, "id")
|
||||||
|
if _, err := s.store.GetThoughtByID(userID, taskID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
on := r.Method == http.MethodPost
|
||||||
|
if err := s.store.WatchTask(taskID, userID, on); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"watching": on})
|
||||||
|
}
|
||||||
|
|
||||||
|
// markWatching sets IsWatching on tasks for the requesting user.
|
||||||
|
func (s *Server) markWatching(userID string, thoughts []domain.Thought) {
|
||||||
|
watched := s.store.WatchedTaskIDs(userID)
|
||||||
|
if len(watched) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range thoughts {
|
||||||
|
thoughts[i].IsWatching = watched[thoughts[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sentNote struct{ channel, to, subject, text, html string }
|
||||||
|
|
||||||
|
func notifyServer(t *testing.T) (*Server, *[]sentNote) {
|
||||||
|
t.Helper()
|
||||||
|
srv, _, _ := setupTestServer(t)
|
||||||
|
srv.cfg.NotifyDigestMinutes = 5
|
||||||
|
var sent []sentNote
|
||||||
|
srv.notifyEmailHook = func(to, subject, text, html string) error {
|
||||||
|
sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
srv.notifyDiscordHook = func(url, heading string, items []notifyItem) error {
|
||||||
|
lines := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
lines = append(lines, it.text)
|
||||||
|
}
|
||||||
|
sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return srv, &sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchedTaskChangesAreBundled(t *testing.T) {
|
||||||
|
srv, sent := notifyServer(t)
|
||||||
|
store := srv.store
|
||||||
|
router := srv.Router()
|
||||||
|
owner, ownerToken := newTestUser(t, store, "owner@example.com")
|
||||||
|
watcher, watcherToken := newTestUser(t, store, "watcher@example.com")
|
||||||
|
list, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "#000000")
|
||||||
|
task, _ := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", list.ID, "todo", "")
|
||||||
|
if _, err := store.AddTaskListShare(list.ID, "user", watcher.ID, "editor", owner.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", watcherToken, "", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("watch: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
_, strangerToken := newTestUser(t, store, "fremd@example.com")
|
||||||
|
if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", strangerToken, "", ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("stranger watch: want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two changes by the owner within the window → one bundled mail for the watcher, none for the owner
|
||||||
|
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"content":"Fenster putzen (innen)"}`, "")
|
||||||
|
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"isCompleted":true}`, "")
|
||||||
|
srv.flushNotifications(time.Now())
|
||||||
|
if len(*sent) != 0 {
|
||||||
|
t.Fatalf("nothing may go out before the window ends: %+v", *sent)
|
||||||
|
}
|
||||||
|
srv.flushNotifications(time.Now().Add(6 * time.Minute))
|
||||||
|
var mails []sentNote
|
||||||
|
for _, n := range *sent {
|
||||||
|
if n.channel == "email" {
|
||||||
|
mails = append(mails, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mails) != 1 || mails[0].to != "watcher@example.com" || !strings.Contains(mails[0].text, "Text geändert") ||
|
||||||
|
!strings.Contains(mails[0].text, "erledigt") || !strings.Contains(mails[0].html, "<html") {
|
||||||
|
t.Fatalf("expected one bundled HTML mail to the watcher, got %+v", *sent)
|
||||||
|
}
|
||||||
|
*sent = nil
|
||||||
|
srv.flushNotifications(time.Now().Add(20 * time.Minute))
|
||||||
|
if len(*sent) != 0 {
|
||||||
|
t.Fatal("queue must be empty after sending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnChangesPauseAndChannels(t *testing.T) {
|
||||||
|
srv, sent := notifyServer(t)
|
||||||
|
store := srv.store
|
||||||
|
router := srv.Router()
|
||||||
|
me, myToken := newTestUser(t, store, "me@example.com")
|
||||||
|
task, _ := store.CreateThoughtWithDetails(me.ID, "Steuer", "normal", "manual", "", "", "todo", "")
|
||||||
|
_ = store.WatchTask(task.ID, me.ID, true)
|
||||||
|
|
||||||
|
// Own change on a watched task: nothing …
|
||||||
|
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"high"}`, "")
|
||||||
|
srv.flushNotifications(time.Now().Add(10 * time.Minute))
|
||||||
|
if len(*sent) != 0 {
|
||||||
|
t.Fatalf("own change must not notify: %+v", *sent)
|
||||||
|
}
|
||||||
|
// … unless asked for, then only over the chosen channel
|
||||||
|
set := store.NotificationSettingsOf(me.ID)
|
||||||
|
set.IncludeOwnWatched = true
|
||||||
|
set.Events.Watched = domain.ChannelChoice{Discord: true}
|
||||||
|
_ = store.SetNotificationSettings(me.ID, set)
|
||||||
|
hook := "https://discord.com/api/webhooks/1/x"
|
||||||
|
_, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, &hook, nil, nil, nil)
|
||||||
|
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"low"}`, "")
|
||||||
|
srv.flushNotifications(time.Now().Add(10 * time.Minute))
|
||||||
|
if len(*sent) != 1 || (*sent)[0].channel != "discord" {
|
||||||
|
t.Fatalf("expected one Discord message, got %+v", *sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paused: nothing at all
|
||||||
|
*sent = nil
|
||||||
|
set.Paused = true
|
||||||
|
_ = store.SetNotificationSettings(me.ID, set)
|
||||||
|
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"urgent"}`, "")
|
||||||
|
srv.flushNotifications(time.Now().Add(10 * time.Minute))
|
||||||
|
if len(*sent) != 0 {
|
||||||
|
t.Fatalf("paused must send nothing: %+v", *sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings round trip over the profile API
|
||||||
|
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true}`) {
|
||||||
|
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailySummary(t *testing.T) {
|
||||||
|
srv, sent := notifyServer(t)
|
||||||
|
store := srv.store
|
||||||
|
me, _ := newTestUser(t, store, "summary@example.com")
|
||||||
|
now := time.Date(2026, 10, 30, 7, 0, 30, 0, time.Local)
|
||||||
|
set := store.NotificationSettingsOf(me.ID)
|
||||||
|
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
|
||||||
|
_ = store.SetNotificationSettings(me.ID, set)
|
||||||
|
|
||||||
|
srv.sendDailySummaries(now)
|
||||||
|
if len(*sent) != 0 {
|
||||||
|
t.Fatal("nothing due → no summary")
|
||||||
|
}
|
||||||
|
for _, td := range [][2]string{{"Altlast", "2026-10-28"}, {"Zahnarzt", "2026-10-30T15:30"}, {"Müll raus", "2026-10-31"}, {"Später", "2026-11-04"}} {
|
||||||
|
th, _ := store.CreateThoughtWithDetails(me.ID, td[0], "normal", "manual", td[1], "", "todo", "")
|
||||||
|
if _, err := store.UpdateThoughtTriage(me.ID, th.ID, "keep", "task", "", td[1], ""); err != nil { // triaged into a task
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.sendDailySummaries(now.Add(-2 * time.Minute)) // before the chosen time
|
||||||
|
if len(*sent) != 0 {
|
||||||
|
t.Fatal("no summary before the chosen time")
|
||||||
|
}
|
||||||
|
srv.sendDailySummaries(now)
|
||||||
|
if len(*sent) != 1 {
|
||||||
|
t.Fatalf("expected one summary, got %d", len(*sent))
|
||||||
|
}
|
||||||
|
text := (*sent)[0].text
|
||||||
|
for _, want := range []string{"Überfällig", "Altlast", "Heute, 15:30 Uhr: Zahnarzt", "Morgen: Müll raus", "1 weitere"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("summary lacks %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srv.sendDailySummaries(now.Add(5 * time.Minute))
|
||||||
|
if len(*sent) != 1 {
|
||||||
|
t.Fatal("only one summary per day")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ type Server struct {
|
|||||||
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
|
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
|
||||||
loginGuard *loginGuard // brute-force protection (loginguard.go)
|
loginGuard *loginGuard // brute-force protection (loginguard.go)
|
||||||
voice *voiceService // self-hosted speech recognition (voice.go)
|
voice *voiceService // self-hosted speech recognition (voice.go)
|
||||||
|
|
||||||
|
// Replace the delivery of bundled notifications in tests (notify.go)
|
||||||
|
notifyEmailHook func(to, subject, text, html string) error
|
||||||
|
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||||
@@ -62,6 +66,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
|||||||
}
|
}
|
||||||
if !cfg.DemoMode {
|
if !cfg.DemoMode {
|
||||||
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
|
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
|
||||||
|
go srv.startNotificationJobs()
|
||||||
go srv.startBackupScheduler()
|
go srv.startBackupScheduler()
|
||||||
}
|
}
|
||||||
return srv
|
return srv
|
||||||
@@ -136,6 +141,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
|
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
|
||||||
r.Get("/api/v1/media/{id}", s.handleGetMedia)
|
r.Get("/api/v1/media/{id}", s.handleGetMedia)
|
||||||
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
|
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
|
||||||
|
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
||||||
|
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
||||||
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
|
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
|
||||||
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
|
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
|
||||||
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ type Config struct {
|
|||||||
|
|
||||||
// Images and audio in notes (MEDIA_*)
|
// Images and audio in notes (MEDIA_*)
|
||||||
Media MediaConfig
|
Media MediaConfig
|
||||||
|
|
||||||
|
// NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes
|
||||||
|
NotifyDigestMinutes int
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
|
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
|
||||||
@@ -237,6 +240,7 @@ func Load() *Config {
|
|||||||
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
|
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
|
||||||
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
|
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
|
||||||
},
|
},
|
||||||
|
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
|
||||||
Media: MediaConfig{
|
Media: MediaConfig{
|
||||||
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
|
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
|
||||||
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
|
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
|
||||||
|
|||||||
@@ -135,6 +135,67 @@ type User struct {
|
|||||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||||
OrganizationID string `json:"organizationId,omitempty"`
|
OrganizationID string `json:"organizationId,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
|
||||||
|
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
|
||||||
|
type NotificationSettings struct {
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
IncludeOwnWatched bool `json:"includeOwnWatched"`
|
||||||
|
Events NotificationEvents `json:"events"`
|
||||||
|
DailySummary DailySummary `json:"dailySummary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationEvents struct {
|
||||||
|
Due ChannelChoice `json:"due"` // task alarms (sent at once)
|
||||||
|
Shared ChannelChoice `json:"shared"` // something was shared with me
|
||||||
|
TaskDone ChannelChoice `json:"taskDone"` // a shared task was completed
|
||||||
|
Watched ChannelChoice `json:"watched"` // a watched task changed
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelChoice struct {
|
||||||
|
Email bool `json:"email"`
|
||||||
|
Discord bool `json:"discord"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailySummary struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Time string `json:"time"` // HH:MM, server time zone
|
||||||
|
Email bool `json:"email"`
|
||||||
|
Discord bool `json:"discord"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
|
||||||
|
func DefaultNotificationSettings(alarmChannels []string) NotificationSettings {
|
||||||
|
due := ChannelChoice{}
|
||||||
|
for _, c := range alarmChannels {
|
||||||
|
due.Email = due.Email || c == "email"
|
||||||
|
due.Discord = due.Discord || c == "discord"
|
||||||
|
}
|
||||||
|
return NotificationSettings{
|
||||||
|
Events: NotificationEvents{
|
||||||
|
Due: due,
|
||||||
|
Shared: ChannelChoice{Email: true},
|
||||||
|
Watched: ChannelChoice{Email: true, Discord: true},
|
||||||
|
},
|
||||||
|
DailySummary: DailySummary{Time: "07:00", Email: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels returns the choice for an event kind (due, shared, taskDone, watched).
|
||||||
|
func (n NotificationSettings) Channels(kind string) ChannelChoice {
|
||||||
|
switch kind {
|
||||||
|
case "due":
|
||||||
|
return n.Events.Due
|
||||||
|
case "shared":
|
||||||
|
return n.Events.Shared
|
||||||
|
case "taskDone":
|
||||||
|
return n.Events.TaskDone
|
||||||
|
case "watched":
|
||||||
|
return n.Events.Watched
|
||||||
|
}
|
||||||
|
return ChannelChoice{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInvite struct {
|
type UserInvite struct {
|
||||||
@@ -167,6 +228,8 @@ type SubTask struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Thought struct {
|
type Thought struct {
|
||||||
|
// IsWatching: the requesting user gets notified about changes (task watchers)
|
||||||
|
IsWatching bool `json:"isWatching,omitempty"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ var migrations = []migration{
|
|||||||
{4, "Sitzungen", (*Store).migrateSessions},
|
{4, "Sitzungen", (*Store).migrateSessions},
|
||||||
{5, "Medien", (*Store).migrateMedia},
|
{5, "Medien", (*Store).migrateMedia},
|
||||||
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
||||||
|
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifications: per-user settings (JSON on the user), a queue that is sent bundled every few minutes,
|
||||||
|
// task watchers and the bookkeeping for the daily summary.
|
||||||
|
|
||||||
|
func (s *Store) migrateNotifications() error {
|
||||||
|
if err := s.addColumnIfMissing("users", "notification_settings", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.addColumnIfMissing("users", "last_daily_summary", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.execInTx(
|
||||||
|
stmt(`CREATE TABLE IF NOT EXISTS notification_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`),
|
||||||
|
stmt(`CREATE INDEX IF NOT EXISTS idx_notification_queue_user ON notification_queue(user_id, created_at)`),
|
||||||
|
stmt(`CREATE TABLE IF NOT EXISTS task_watchers (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, user_id)
|
||||||
|
)`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationSettingsOf returns the user's settings, defaults filled in for users who never saved any.
|
||||||
|
func (s *Store) NotificationSettingsOf(userID string) domain.NotificationSettings {
|
||||||
|
var raw, channels string
|
||||||
|
_ = s.db.QueryRow(`SELECT notification_settings, alarm_channels FROM users WHERE id = ?`, userID).Scan(&raw, &channels)
|
||||||
|
out := domain.DefaultNotificationSettings(parseAlarmChannels(channels))
|
||||||
|
if raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &out)
|
||||||
|
}
|
||||||
|
if !validClock(out.DailySummary.Time) {
|
||||||
|
out.DailySummary.Time = "07:00"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validClock(v string) bool {
|
||||||
|
t, err := time.Parse("15:04", v)
|
||||||
|
return err == nil && t.Format("15:04") == v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetNotificationSettings(userID string, n domain.NotificationSettings) error {
|
||||||
|
if !validClock(n.DailySummary.Time) {
|
||||||
|
n.DailySummary.Time = "07:00"
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(n)
|
||||||
|
_, err := s.db.Exec(`UPDATE users SET notification_settings = ? WHERE id = ?`, string(raw), userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueuedNotification is one event waiting to be sent bundled.
|
||||||
|
type QueuedNotification struct {
|
||||||
|
ID int64
|
||||||
|
UserID string
|
||||||
|
Kind string
|
||||||
|
Payload map[string]string
|
||||||
|
CreatedAt string
|
||||||
|
Attempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) QueueNotification(userID, kind string, payload map[string]string) error {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
_, err := s.db.Exec(`INSERT INTO notification_queue (user_id, kind, payload, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
userID, kind, string(raw), time.Now().UTC().Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueNotificationUsers lists users whose oldest waiting event is older than the bundling window.
|
||||||
|
func (s *Store) DueNotificationUsers(olderThan time.Time) ([]string, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT user_id FROM notification_queue GROUP BY user_id HAVING MIN(created_at) <= ?`,
|
||||||
|
olderThan.UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) QueuedNotifications(userID string) ([]QueuedNotification, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT id, user_id, kind, payload, created_at, attempts FROM notification_queue WHERE user_id = ? ORDER BY id`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []QueuedNotification
|
||||||
|
for rows.Next() {
|
||||||
|
var q QueuedNotification
|
||||||
|
var raw string
|
||||||
|
if rows.Scan(&q.ID, &q.UserID, &q.Kind, &raw, &q.CreatedAt, &q.Attempts) == nil {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &q.Payload)
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteQueuedNotifications(ids []int64) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM notification_queue WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) BumpQueuedNotifications(ids []int64) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := s.db.Exec(`UPDATE notification_queue SET attempts = attempts + 1 WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Task watchers ---
|
||||||
|
|
||||||
|
func (s *Store) WatchTask(taskID, userID string, on bool) error {
|
||||||
|
if on {
|
||||||
|
_, err := s.db.Exec(`INSERT OR IGNORE INTO task_watchers (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, userID, nowUTC())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ? AND user_id = ?`, taskID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) TaskWatchers(taskID string) []string {
|
||||||
|
rows, err := s.db.Query(`SELECT user_id FROM task_watchers WHERE task_id = ?`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchedTaskIDs is the set of tasks the user watches (to mark them in task lists).
|
||||||
|
func (s *Store) WatchedTaskIDs(userID string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
rows, err := s.db.Query(`SELECT task_id FROM task_watchers WHERE user_id = ?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ForgetTaskWatchers(taskID string) {
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ?`, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily summary ---
|
||||||
|
|
||||||
|
// SummaryTask is an open task with a due date, for the daily summary.
|
||||||
|
type SummaryTask struct {
|
||||||
|
Title, DueDate, ListTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveUserIDs lists all active accounts (the summary job checks their settings).
|
||||||
|
func (s *Store) ActiveUserIDs() []string {
|
||||||
|
rows, err := s.db.Query(`SELECT id FROM users WHERE is_active = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTasksDueBefore lists the user's open tasks (own ones and those of shared task lists) due before
|
||||||
|
// the given day (YYYY-MM-DD, exclusive), oldest first.
|
||||||
|
func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '')
|
||||||
|
FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id
|
||||||
|
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
|
||||||
|
AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ?
|
||||||
|
AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN (SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ?)))
|
||||||
|
ORDER BY t.due_date`, beforeDay, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []SummaryTask
|
||||||
|
for rows.Next() {
|
||||||
|
var t SummaryTask
|
||||||
|
if rows.Scan(&t.Title, &t.DueDate, &t.ListTitle) == nil {
|
||||||
|
t.Title = strings.TrimSpace(strings.SplitN(t.Title, "\n", 2)[0])
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimDailySummary marks today's summary as sent; false when it was already sent today.
|
||||||
|
func (s *Store) ClaimDailySummary(userID, day string) bool {
|
||||||
|
res, err := s.db.Exec(`UPDATE users SET last_daily_summary = ? WHERE id = ? AND last_daily_summary != ?`, day, userID, day)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedFolderName is the display name of a shared workspace folder.
|
||||||
|
func (s *Store) SharedFolderName(folderID string) string {
|
||||||
|
var name string
|
||||||
|
_ = s.db.QueryRow(`SELECT name FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&name)
|
||||||
|
if name == "" {
|
||||||
|
name = "Ordner"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
Reference in new issue
Block a user