Files
silo-server/internal/api/handlers/notifications.go
a196b0844e feat(notifications): add Android FCM push delivery via the relay (#409)
Extend the push pipeline to Android devices through the Silo push
relay's /v1/fcm/send endpoint. push_devices gains platform-conditional
FCM token columns (encrypted at rest with row AAD, hashed like APNs
tokens), the generic POST /notifications/push/devices endpoint the
Android client already calls registers FCM tokens, and fanout,
operational dispatch, retries, and terminal UNREGISTERED device
disabling all reuse the existing Apple machinery. Delivery is gated by
a new notifications.android_push_delivery_enabled setting, advertised
through the capability endpoint's android_push block, and testable via
POST /admin/notifications/push/fcm/test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:44:39 -04:00

440 lines
16 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 notificationApplePushDisplayResponse = notifications.NotificationDisplay
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))
}
// HandleApplePushDisplay handles GET /notifications/push/apple/display/{delivery_id}.
// It returns only compact display metadata for notification-service-extension
// enrichment, scoped to the active profile.
func (h *NotificationsHandler) HandleApplePushDisplay(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
profileID := apimw.GetProfileID(r.Context())
id := chi.URLParam(r, "delivery_id")
if id == "" {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
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, notificationApplePushDisplayResponse(notifications.BuildNotificationDisplay(*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.
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()),
}
}
applePush := capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}
androidPush := capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}
// Like web push above, availability requires both the wiring (cipher/store)
// and the admin delivery toggle: Available must mean "setup will actually
// deliver", not "the server could store a token".
if h.system.PushDevices != nil && h.system.PushDevices.Available() {
if h.system.Settings.ApplePushDeliveryEnabled(r.Context()) {
applePush = capabilityPush{
Available: true,
Provider: notifications.PushProviderSiloRelay,
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
}
}
if h.system.Settings.AndroidPushDeliveryEnabled(r.Context()) {
androidPush = capabilityPush{
Available: true,
Provider: notifications.PushProviderSiloRelay,
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
}
}
}
writeJSON(w, http.StatusOK, capabilityResponse{
InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())},
ApplePush: applePush,
AndroidPush: androidPush,
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()),
})
}