Files
silo-server/internal/api/handlers/notifications.go
T
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.

- Extract the email channel's watermark sweep into a generic
  account-channel engine; email and Discord are now thin adapters, so
  the SKIP LOCKED claim / watermark-after-send durability logic exists
  once.
- New internal/discord REST client (token exchange, identity, open DM,
  send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
  gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
  portal setup checklist, bot invite link buttons, and a test button
  that bypasses the settings read cache and is disabled while
  credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
  health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
  instant sends all day plus a daily digest recapping the whole window
  since the previous digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:51:03 -04:00

396 lines
14 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strconv"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
const (
notificationsDefaultLimit = 25
notificationsMaxLimit = 100
notificationsSyncLimit = 50
)
// NotificationsHandler serves the profile-scoped notification inbox,
// preferences, capability, and websocket-ticket endpoints. All routes are
// mounted behind RequireProfile.
type NotificationsHandler struct {
system *notifications.System
hub *evt.Hub
}
// NewNotificationsHandler creates a NotificationsHandler.
func NewNotificationsHandler(system *notifications.System, hub *evt.Hub) *NotificationsHandler {
return &NotificationsHandler{system: system, hub: hub}
}
type notificationListResponse struct {
Notifications []notifications.DeliveryRowPayload `json:"notifications"`
// NextCursor pages further into the past via the `before` query param.
// Empty when this page may be the last.
NextCursor string `json:"next_cursor,omitempty"`
}
type notificationSyncResponse struct {
Notifications []notifications.DeliveryRowPayload `json:"notifications"`
NextCursor string `json:"next_cursor,omitempty"`
UnreadCount int `json:"unread_count"`
}
type unreadCountResponse struct {
Count int `json:"count"`
}
type wsTicketResponse struct {
Ticket string `json:"ticket"`
ExpiresIn int `json:"expires_in"`
}
func parseNotificationsLimit(r *http.Request, fallback int) int {
raw := r.URL.Query().Get("limit")
if raw == "" {
return fallback
}
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return fallback
}
return min(limit, notificationsMaxLimit)
}
// HandleList handles GET /notifications (newest-first inbox page).
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
unreadOnly := r.URL.Query().Get("status") == "unread"
limit := parseNotificationsLimit(r, notificationsDefaultLimit)
var before *notifications.Cursor
if raw := r.URL.Query().Get("before"); raw != "" {
cursor, err := notifications.DecodeCursor(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid before cursor")
return
}
before = &cursor
}
rows, err := h.system.Deliveries.ListInbox(r.Context(), profileID, unreadOnly, limit, before)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
return
}
response := notificationListResponse{Notifications: h.system.PayloadsForRows(r.Context(), rows)}
if len(rows) == limit {
last := rows[len(rows)-1]
response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode()
}
writeJSON(w, http.StatusOK, response)
}
// HandleSync handles GET /notifications/sync — the forward (ascending) cursor
// sync used by clients waking from a push or reconnecting after a gap.
func (h *NotificationsHandler) HandleSync(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
limit := parseNotificationsLimit(r, notificationsSyncLimit)
var since *notifications.Cursor
if raw := r.URL.Query().Get("since"); raw != "" {
cursor, err := notifications.DecodeCursor(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid since cursor")
return
}
since = &cursor
}
rows, err := h.system.Deliveries.ListSync(r.Context(), profileID, since, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync notifications")
return
}
unread, err := h.system.Deliveries.UnreadCount(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications")
return
}
response := notificationSyncResponse{
Notifications: h.system.PayloadsForRows(r.Context(), rows),
UnreadCount: unread,
}
if len(rows) > 0 {
last := rows[len(rows)-1]
response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode()
} else if since != nil {
response.NextCursor = since.Encode()
}
writeJSON(w, http.StatusOK, response)
}
// HandleGet handles GET /notifications/{id}; 404 for other profiles' rows.
func (h *NotificationsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
id := chi.URLParam(r, "id")
row, err := h.system.Deliveries.GetByID(r.Context(), profileID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification")
return
}
if row == nil {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
writeJSON(w, http.StatusOK, h.system.PayloadForRow(r.Context(), *row))
}
// HandleUnreadCount handles GET /notifications/unread-count.
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
count, err := h.system.Deliveries.UnreadCount(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications")
return
}
writeJSON(w, http.StatusOK, unreadCountResponse{Count: count})
}
// HandleMarkRead handles POST /notifications/{id}/read. Idempotent.
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
id := chi.URLParam(r, "id")
transitioned, err := h.system.Deliveries.MarkRead(r.Context(), profileID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read")
return
}
if !transitioned {
// Already read is fine (idempotent); unknown IDs are a 404.
exists, err := h.system.Deliveries.Exists(r.Context(), profileID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read")
return
}
if !exists {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
}
if transitioned {
h.publishReadEvent(r, userID, profileID, id)
}
w.WriteHeader(http.StatusNoContent)
}
// HandleReadAll handles POST /notifications/read-all.
func (h *NotificationsHandler) HandleReadAll(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if _, err := h.system.Deliveries.MarkAllRead(r.Context(), profileID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
return
}
h.publishReadEvent(r, userID, profileID, "")
w.WriteHeader(http.StatusNoContent)
}
// publishReadEvent lets other connected tabs of the same profile reconcile
// read state. An empty id means "all read".
func (h *NotificationsHandler) publishReadEvent(r *http.Request, userID int, profileID, id string) {
if h.hub == nil {
return
}
payload := map[string]any{"profile_id": profileID}
if id != "" {
payload["id"] = id
} else {
payload["all"] = true
}
_ = h.hub.PublishJSON(r.Context(), evt.ChannelNotifications, notifications.EventNotificationRead,
payload, evt.PublishOptions{UserID: userID, ProfileID: profileID})
}
// HandleGetPreferences handles GET /notifications/preferences.
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
prefs, err := h.system.Preferences.Get(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences")
return
}
writeJSON(w, http.StatusOK, prefs)
}
type updatePreferencesRequest struct {
Enabled *bool `json:"enabled"`
NotifyFavorites *bool `json:"notify_favorites"`
NotifyWatchlist *bool `json:"notify_watchlist"`
NotifyContinueWatching *bool `json:"notify_continue_watching"`
NotifyNextUp *bool `json:"notify_next_up"`
}
// HandleUpdatePreferences handles PUT /notifications/preferences. Fields are
// optional; omitted fields keep their current value. Idempotent.
func (h *NotificationsHandler) HandleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
var req updatePreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
prefs, err := h.system.Preferences.Get(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences")
return
}
if req.Enabled != nil {
prefs.Enabled = *req.Enabled
}
if req.NotifyFavorites != nil {
prefs.NotifyFavorites = *req.NotifyFavorites
}
if req.NotifyWatchlist != nil {
prefs.NotifyWatchlist = *req.NotifyWatchlist
}
if req.NotifyContinueWatching != nil {
prefs.NotifyContinueWatching = *req.NotifyContinueWatching
}
if req.NotifyNextUp != nil {
prefs.NotifyNextUp = *req.NotifyNextUp
}
if err := h.system.Preferences.Upsert(r.Context(), prefs); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save notification preferences")
return
}
writeJSON(w, http.StatusOK, prefs)
}
type capabilityResponse struct {
InApp capabilityInApp `json:"in_app"`
ApplePush capabilityPush `json:"apple_push"`
AndroidPush capabilityPush `json:"android_push"`
WebPush capabilityWebPush `json:"web_push"`
Webhooks capabilityWebhooks `json:"webhooks"`
Email capabilityAccountChannel `json:"email"`
Discord capabilityAccountChannel `json:"discord"`
}
// capabilityAccountChannel describes an account-level digest channel (email,
// Discord DMs).
type capabilityAccountChannel struct {
Available bool `json:"available"`
// Modes lists the cadences users may pick (per-episode is an admin
// allowance); DigestHour tells the UI when daily digests go out.
Modes []string `json:"modes"`
DigestHour int `json:"digest_hour"`
}
type capabilityWebPush struct {
Available bool `json:"available"`
PublicKey string `json:"public_key,omitempty"`
}
type capabilityInApp struct {
Enabled bool `json:"enabled"`
}
type capabilityPush struct {
Available bool `json:"available"`
Provider string `json:"provider"`
SupportedModes []string `json:"supported_modes"`
}
type capabilityWebhooks struct {
Available bool `json:"available"`
MaxPerProfile int `json:"max_per_profile"`
SupportedTypes []string `json:"supported_types"`
}
// HandleCapability handles GET /notifications/capability. Clients render
// setup UI from this response instead of introspecting admin settings. Push
// channels report unavailable until they ship
// (docs/superpowers/plans/notifications/02-03).
func (h *NotificationsHandler) HandleCapability(w http.ResponseWriter, r *http.Request) {
webhooks := capabilityWebhooks{Available: false, MaxPerProfile: 0, SupportedTypes: []string{}}
if h.system.Webhooks != nil && h.system.Settings.WebhooksEnabled(r.Context()) {
webhooks = capabilityWebhooks{
Available: true,
MaxPerProfile: h.system.Settings.WebhooksMaxPerProfile(r.Context()),
SupportedTypes: []string{"discord", "generic"},
}
}
webPush := capabilityWebPush{}
if h.system.WebPush != nil && h.system.Settings.WebPushEnabled(r.Context()) {
if publicKey, err := h.system.WebPush.PublicKey(r.Context()); err == nil && publicKey != "" {
webPush = capabilityWebPush{Available: true, PublicKey: publicKey}
}
}
email := capabilityAccountChannel{Modes: []string{}}
if h.system.EmailAvailable(r.Context()) {
modes := []string{notifications.ChannelModeDailyDigest}
if h.system.Settings.EmailAllowPerEpisode(r.Context()) {
modes = append(modes,
notifications.ChannelModePerEpisode,
notifications.ChannelModePerEpisodeAndDigest)
}
email = capabilityAccountChannel{
Available: true,
Modes: modes,
DigestHour: h.system.Settings.EmailDigestHour(r.Context()),
}
}
discordCap := capabilityAccountChannel{Modes: []string{}}
if h.system.DiscordAvailable(r.Context()) {
modes := []string{notifications.ChannelModeDailyDigest}
if h.system.Settings.DiscordAllowPerEpisode(r.Context()) {
modes = append(modes,
notifications.ChannelModePerEpisode,
notifications.ChannelModePerEpisodeAndDigest)
}
discordCap = capabilityAccountChannel{
Available: true,
Modes: modes,
DigestHour: h.system.Settings.DiscordDigestHour(r.Context()),
}
}
writeJSON(w, http.StatusOK, capabilityResponse{
InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())},
ApplePush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}},
AndroidPush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}},
WebPush: webPush,
Webhooks: webhooks,
Email: email,
Discord: discordCap,
})
}
// HandleMintWSTicket handles POST /events/ws-ticket: mints a short-lived
// single-use websocket handshake ticket bound to (user, profile). Long-lived
// tokens must never ride the websocket query string — reverse-proxy access
// logs capture it.
func (h *NotificationsHandler) HandleMintWSTicket(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
ticket, ttl, err := h.system.Tickets.Mint(r.Context(), userID, profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mint websocket ticket")
return
}
writeJSON(w, http.StatusOK, wsTicketResponse{
Ticket: ticket,
ExpiresIn: int(ttl.Seconds()),
})
}