MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
The custom webhook could be entered but never switched on since the notification matrix replaced the old alarm channels. It is now a third column next to e-mail and Discord for every event and the daily summary; bundles go out as JSON (event "digest"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
468 lines
15 KiB
Go
468 lines
15 KiB
Go
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(`<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 && !ds.Webhook) {
|
|
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 out channelItems
|
|
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
|
|
if err := s.deliver(uid, "Deine Aufgaben für heute", out); 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]
|
|
}
|
|
}
|