Files
silo-server/internal/notifications/request_notifier.go
T
QuickandClaude Fable 5 d9e27da59e feat(notifications): request-fulfilled notifications across all channels
Notify the requesting profile once its media request is actually present
in the catalog (roadmap 06, item 2). Completion transitions stay
notification-agnostic; a presence-gated pass at the end of each
reconcile run fires the notice, so it means "watchable in Silo", not
"download finished".

- New System.DispatchOperational: delivery insert + webhook/web-push
  outbox enqueue in one transaction, post-commit multi-dispatch. The
  webhook auto-disable notice now rides the same path (replacing its
  hand-rolled hub publish and the now-removed InsertOperational), which
  also delivers auto-disable notices over web push.
- At-most-once delivery: partial unique index on
  (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at
  marker on media_requests, backfilled for pre-existing completed
  requests so deploys never flood.
- Per-webhook notify_requests toggle (default on) through repo, service,
  API, and settings UI; gated independently of the episode reason flags.
- request.fulfilled rendering in web inbox, realtime toast, web push
  payload, and Discord/generic webhook payloads, deep-linking to the
  matched catalog item.

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

91 lines
3.1 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
}
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
}