diff --git a/Doku/architecture.md b/Doku/architecture.md index 7c66ea5..cb85596 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -82,7 +82,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in ## Hintergrundjob: Alarme -`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md). +`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md). ## Hintergrundjob: Verdichtung der Notiz-Synchronisation diff --git a/Doku/database.md b/Doku/database.md index dbc5073..462b1e2 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -20,7 +20,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera | Tabelle | Zweck | Wichtige Spalten | |---|---|---| | `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` | -| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) | +| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) | | `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` | | `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) | | `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) | diff --git a/Doku/flaws.md b/Doku/flaws.md index a29c2b9..6c2935b 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -21,7 +21,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | -| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** | +| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** | | FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel | | FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | @@ -111,7 +111,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen. ### FLAW-12: Alarm-Logik -- **Teilweise behoben:** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` zurück. Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`. Offen bleibt der letzte Punkt (kein Backoff). +- **Status: Behoben.** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` und `alarm_failures` zurück. Nach 3 Durchläufen, in denen kein Kanal zustellt, wird der Alarm aufgegeben (Warnung im Log). Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`, `TestAlarmGivesUpAfterThreeFailedRuns`. - **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`. - **Probleme:** - `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar. diff --git a/Doku/modules/tasks.md b/Doku/modules/tasks.md index e06a2cd..749746f 100644 --- a/Doku/modules/tasks.md +++ b/Doku/modules/tasks.md @@ -57,6 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa - `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt). - Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451). -Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Offen ([FLAW-12](../flaws.md)): Schlagen alle Kanäle fehl, wird jede Minute erneut versucht. URLs werden geprüft ([FLAW-05](../flaws.md)). +Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche ([FLAW-12](../flaws.md)). URLs werden geprüft ([FLAW-05](../flaws.md)). Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag. diff --git a/internal/api/alarms.go b/internal/api/alarms.go index 454d87c..9d2bf55 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -151,6 +151,8 @@ func (s *Server) startAlarmTicker() { if sentAny { _ = s.store.MarkTaskAlarmSent(a.TaskID) + } else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp { + slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title) } } } diff --git a/internal/storage/recurrence_test.go b/internal/storage/recurrence_test.go index 3bdec03..ca091eb 100644 --- a/internal/storage/recurrence_test.go +++ b/internal/storage/recurrence_test.go @@ -138,3 +138,50 @@ func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) { t.Errorf("reopening must re-arm the alarm") } } + +func TestAlarmGivesUpAfterThreeFailedRuns(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "alarmfail.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + task, _ := store.CreateThoughtWithDetails(u.ID, "Webhook kaputt", "normal", "manual", "2026-10-30", "", "todo", "") + _, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "") + pending := func() bool { + alarms, _ := store.GetDueTasksForAlarm() + for _, a := range alarms { + if a.TaskID == task.ID { + return true + } + } + return false + } + + for run := 1; run <= MaxAlarmFailures; run++ { + gaveUp, err := store.RecordTaskAlarmFailure(task.ID) + if err != nil { + t.Fatalf("run %d: %v", run, err) + } + if gaveUp != (run == MaxAlarmFailures) || pending() == (run == MaxAlarmFailures) { + t.Fatalf("run %d: gaveUp=%v pending=%v", run, gaveUp, pending()) + } + } + + // A new due date gets three fresh attempts + later := "2026-11-02" + _, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil) + if !pending() { + t.Fatalf("new due date must re-arm the alarm") + } + if gaveUp, _ := store.RecordTaskAlarmFailure(task.ID); gaveUp { + t.Errorf("failure counter must start over after a new due date") + } + // Success clears the counter + _ = store.MarkTaskAlarmSent(task.ID) + var failures int + _ = store.db.QueryRow(`SELECT alarm_failures FROM thoughts WHERE id = ?`, task.ID).Scan(&failures) + if failures != 0 { + t.Errorf("successful alarm should reset failures, got %d", failures) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index e81857c..5c2cd75 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -375,6 +375,7 @@ func (s *Store) migrate() error { // Idempotent column migrations for existing databases columnMigrations := []string{ + "ALTER TABLE thoughts ADD COLUMN alarm_failures INTEGER NOT NULL DEFAULT 0;", "ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';", "ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';", diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index 7f339a6..8cfa59c 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -251,8 +251,9 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte } _, err = tx.Exec( `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?, - alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`, - it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID, + alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END, + alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END WHERE id = ?`, + it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, isCompleted, it.ID, ) } else { _, err = tx.Exec( @@ -388,9 +389,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target _, err = s.db.Exec( `UPDATE thoughts SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?, - alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END + alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END, + alarm_failures = CASE WHEN due_date != ? THEN 0 ELSE alarm_failures END WHERE id = ? AND user_id = ?`, - string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID, + string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, dueDate, thoughtID, userID, ) if err != nil { return nil, err @@ -479,7 +481,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, } // A new due date or a reopened task gets its alarm again if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) { - _, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID) + _, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0, alarm_failures = 0 WHERE id = ?`, thoughtID) thought.AlarmSent = false } if taskListID != nil { @@ -572,8 +574,9 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) { doneInt = 1 kanban = "done" } - res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END - WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt) + res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END, + alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END + WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, doneInt, taskID, userID, doneInt) if checked && res != nil { if n, _ := res.RowsAffected(); n > 0 { s.spawnNextOccurrence(taskID) @@ -634,6 +637,20 @@ func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) { } func (s *Store) MarkTaskAlarmSent(taskID string) error { - _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID) + _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1, alarm_failures = 0 WHERE id = ?`, taskID) return err } + +// MaxAlarmFailures: after this many runs in which no channel delivered, the alarm is given up instead of +// being retried every minute. A new due date or reopening the task starts over. +const MaxAlarmFailures = 3 + +// RecordTaskAlarmFailure counts a run without any successful channel and reports whether the alarm was +// given up now. +func (s *Store) RecordTaskAlarmFailure(taskID string) (gaveUp bool, err error) { + var failures int + err = s.db.QueryRow(`UPDATE thoughts SET alarm_failures = alarm_failures + 1, + alarm_sent = CASE WHEN alarm_failures + 1 >= ? THEN 1 ELSE alarm_sent END + WHERE id = ? RETURNING alarm_failures`, MaxAlarmFailures, taskID).Scan(&failures) + return err == nil && failures >= MaxAlarmFailures, err +}