Files
silo-server/internal/notifications/request_notifier.go
T
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).

- Extend release_events with a kind discriminator and add a movie
  availability spine (movie_availability + kind-keyed
  notification_content_seed_state; first full scan seeds silently so
  upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
  grouping, per-channel backoff, and auto-disable; request events post
  best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
  SSRF guard, embed limits, HMAC signing; shared type/name validation
  extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
  Channels section in the notifications admin settings UI

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

97 lines
3.4 KiB
Go

package notifications
import (
"context"
"encoding/json"
"fmt"
"github.com/Silo-Server/silo-server/internal/requests"
"github.com/oklog/ulid/v2"
)
// DeliveryTypeRequestFulfilled is the operational notice posted to the
// requesting profile once its media request is present in the catalog
// (docs/superpowers/plans/notifications/06, item 2). Its reason_flags carry
// {"request_id","tmdb_id","media_type"} instead of reason booleans; a partial
// unique index on (profile_id, request_id) makes the insert idempotent, and
// the per-webhook notify_requests flag gates the webhook channel.
const DeliveryTypeRequestFulfilled = "request.fulfilled"
// RequestFulfilledFlags is the decoded reason_flags shape for
// request.fulfilled deliveries.
type RequestFulfilledFlags struct {
RequestID string `json:"request_id"`
TMDBID int `json:"tmdb_id"`
MediaType string `json:"media_type"`
}
// parseRequestFulfilledFlags decodes a request.fulfilled delivery's
// reason_flags; other types decode to the zero value.
func parseRequestFulfilledFlags(raw []byte) RequestFulfilledFlags {
var flags RequestFulfilledFlags
if len(raw) > 0 {
_ = json.Unmarshal(raw, &flags)
}
return flags
}
// RequestFulfillmentNotifier adapts the notification system to
// requests.FulfillmentNotifier: it gates on the profile's master toggle and
// dispatches one durable request.fulfilled delivery across all channels.
type RequestFulfillmentNotifier struct {
system *System
}
// NewRequestFulfillmentNotifier creates the adapter.
func NewRequestFulfillmentNotifier(system *System) *RequestFulfillmentNotifier {
return &RequestFulfillmentNotifier{system: system}
}
// NotifyFulfilled implements requests.FulfillmentNotifier. contentID is the
// matched catalog item: deliveryRowSelect joins media_items on series_id, so
// that one field renders the title, poster, and deep link for movies and
// series alike. Returning nil without dispatching (master toggle off, missing
// attribution) still counts as handled — the caller stamps the request either
// way.
func (n *RequestFulfillmentNotifier) NotifyFulfilled(ctx context.Context, req requests.Request, contentID string) error {
if n == nil || n.system == nil {
return nil
}
// Server-channel broadcast first: it is community-facing and must not be
// gated by the requester's personal preferences or attribution. Detached
// and best-effort — a failure here must never block the
// fulfilled_notified_at stamp, or the per-profile path would re-fire.
n.system.PostServerChannelRequestEvent(ctx, ServerChannelEventRequestFulfilled, requestEventInfoFor(req))
if req.RequestedByProfileID == "" || req.RequestedByUserID <= 0 {
return nil // legacy rows without attribution have no recipient
}
prefs, err := n.system.Preferences.Get(ctx, req.RequestedByProfileID)
if err != nil {
return err
}
if !prefs.Enabled {
return nil
}
flags, err := json.Marshal(RequestFulfilledFlags{
RequestID: req.ID,
TMDBID: req.TMDBID,
MediaType: string(req.MediaType),
})
if err != nil {
return fmt.Errorf("marshal request fulfilled flags: %w", err)
}
delivery := Delivery{
ID: ulid.Make().String(),
UserID: req.RequestedByUserID,
ProfileID: req.RequestedByProfileID,
SeriesID: &contentID,
Type: DeliveryTypeRequestFulfilled,
ReasonFlags: flags,
}
_, err = n.system.DispatchOperational(ctx, delivery, OperationalDispatch{
WebhookFilter: func(hook Webhook) bool { return hook.NotifyRequests },
})
return err
}