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, own webhook with a JSON body), 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 !set.Channels(kind).Any() { 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 out channelItems 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"]} out.add(set.Channels(q.Kind), item) } if err := s.deliver(uid, "Neuigkeiten", out); 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) } } // channelItems collects what goes out on each channel. type channelItems struct { email, discord, webhook []notifyItem } func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) { if ch.Email { c.email = append(c.email, items...) } if ch.Discord { c.discord = append(c.discord, items...) } if ch.Webhook { c.webhook = append(c.webhook, items...) } } // deliver sends the items to the user's mail address, Discord webhook and/or own webhook. func (s *Server) deliver(userID, heading string, out channelItems) error { emailItems, discordItems := out.email, out.discord 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 } } if len(out.webhook) > 0 && u.CustomWebhookURL != "" { if err := s.sendNotifyWebhook(u.CustomWebhookURL, heading, out.webhook); 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) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error { if s.notifyWebhookHook != nil { return s.notifyWebhookHook(webhookURL, heading, items) } return s.sendWebhookDigest(webhookURL, heading, items) } // sendWebhookDigest posts the bundle as JSON: {"event":"digest","heading":…,"items":[{kind,title,text}],…}. func (s *Server) sendWebhookDigest(webhookURL, heading string, items []notifyItem) error { if s.cfg.DemoMode { return errDemoOutgoing } allowPrivate := s.allowPrivateWebhooks() if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil { return err } list := make([]map[string]string, 0, len(items)) for _, it := range items { list = append(list, map[string]string{"kind": it.kind, "title": it.title, "text": it.text}) } body, _ := json.Marshal(map[string]interface{}{ "event": "digest", "heading": heading, "items": list, "link": s.notifyLink(), "timestamp": domain.NowISO(), }) 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 (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(`
| MindSift |
| ` + html.EscapeString(heading) + ` |
| Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein. |