Files
silo-server/internal/notifications/server_channel_request_notifier.go
T
bcf0253c09 feat(notifications): notify requesters of request status changes (#143)
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:

- New request.approved / request.declined delivery types ride the
  operational dispatch path to the requesting profile: inbox, websocket
  toast, email, Discord DM, personal webhooks (gated by the existing
  notify_requests flag), and web push. Submitted stays broadcast-only
  (the requester performed the action themselves). Title/year/decline
  reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
  an off-schedule early send (watermark-durable, last_digest_at left
  alone) instead of waiting for the digest hour. Per-episode recipients
  were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
  (migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
  their OAuth-linked identity (notifications.server_channels.
  mention_requesters, default off). Resolved lazily in the sweep worker
  only when a Discord destination is about to receive the event; the
  ping uses content-level mention with pinned allowed_mentions, and the
  Discord identity never leaks into generic webhook payloads.

Android/Apple clients render the new inbox types with their generic
fallback until they add them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:55 -04:00

71 lines
2.5 KiB
Go

package notifications
import (
"context"
"time"
"github.com/Silo-Server/silo-server/internal/requests"
)
// serverChannelRequestPostTimeout bounds one detached lifecycle post fan-out
// (up to serverChannelMaxChannels sequential sends at 10s each is the
// theoretical worst case; in practice a couple of channels are subscribed).
const serverChannelRequestPostTimeout = 60 * time.Second
// requestEventInfoFor converts a request into the payload-layer shape.
func requestEventInfoFor(req requests.Request) RequestEventInfo {
info := RequestEventInfo{
RequestID: req.ID,
TMDBID: req.TMDBID,
IMDBID: req.IMDbID,
MediaType: string(req.MediaType),
Title: req.Title,
Overview: req.Overview,
PosterPath: req.PosterPath,
RequesterName: req.RequesterUsername,
RequesterUserID: req.RequestedByUserID,
}
if req.Year != nil {
info.Year = *req.Year
}
if req.TVDBID != nil {
info.TVDBID = *req.TVDBID
}
return info
}
// PostServerChannelRequestEvent posts one request lifecycle event to opted-in
// server channels on a detached goroutine. No-op when server channels are not
// configured. Best-effort: the caller's flow never blocks on it.
func (s *System) PostServerChannelRequestEvent(ctx context.Context, event string, info RequestEventInfo) {
if s == nil || s.serverChannelWorker == nil {
return
}
// The caller's context ends with its HTTP request or reconcile pass;
// posting continues on its own deadline (the detector's detach pattern).
postCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), serverChannelRequestPostTimeout)
go func() {
defer cancel()
s.serverChannelWorker.PostRequestEvent(postCtx, event, info)
}()
}
// requesterDiscordID resolves a requester's OAuth-linked Discord user id for
// @mentions in server-channel request posts. Empty when the admin has not
// enabled requester mentions, the account never linked Discord, or the lookup
// fails — the post then falls back to the plain username. Wired into the
// sweep worker, which calls it only when a Discord channel is about to
// receive the event.
func (s *System) requesterDiscordID(ctx context.Context, userID int) string {
if userID <= 0 || s.DiscordPrefs == nil || !s.Settings.ServerChannelMentionRequesters(ctx) {
return ""
}
prefs, err := s.DiscordPrefs.Get(ctx, userID)
if err != nil {
s.logger.Warn("server channel request post: discord identity lookup failed",
"user_id", userID, "error", err)
return ""
}
return prefs.DiscordUserID
}