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
+1018
-21
No files matched your search
@@ -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]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user