From d9e27da59e06a4b4209cb278989d34035c50c34d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:04:22 -0400 Subject: [PATCH] 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 --- cmd/silo/main.go | 3 + .../api/handlers/notifications_webhooks.go | 4 + internal/notifications/delivery_repo.go | 22 ---- .../notifications/operational_dispatch.go | 99 +++++++++++++++++ internal/notifications/request_notifier.go | 90 ++++++++++++++++ internal/notifications/system.go | 12 ++- internal/notifications/webhook_logic_test.go | 79 ++++++++++++++ .../notifications/webhook_payload_discord.go | 31 +++++- .../notifications/webhook_payload_generic.go | 17 +++ internal/notifications/webhook_repo.go | 14 ++- internal/notifications/webhook_sender.go | 27 ++--- internal/notifications/webhook_service.go | 5 + internal/notifications/webhook_types.go | 1 + internal/notifications/webpush_logic_test.go | 20 ++++ internal/notifications/webpush_sender.go | 11 ++ internal/requests/notify.go | 66 ++++++++++++ internal/requests/notify_test.go | 101 ++++++++++++++++++ internal/requests/repository.go | 47 ++++++++ internal/requests/service.go | 7 ++ internal/requests/service_test.go | 29 +++++ internal/requests/store.go | 6 ++ ...193802_request_fulfilled_notifications.sql | 43 ++++++++ web/src/api/types.ts | 7 ++ web/src/components/RealtimeEventsProvider.tsx | 7 ++ web/src/pages/Notifications.tsx | 11 ++ .../pages/settings/NotificationsSettings.tsx | 23 ++-- 26 files changed, 728 insertions(+), 54 deletions(-) create mode 100644 internal/notifications/operational_dispatch.go create mode 100644 internal/notifications/request_notifier.go create mode 100644 internal/requests/notify.go create mode 100644 internal/requests/notify_test.go create mode 100644 migrations/sql/20260611193802_request_fulfilled_notifications.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index d09db96e..c0a3fbaf 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1663,6 +1663,9 @@ func main() { ) requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver)) } + if notificationSystem != nil { + requestReconcileSvc.SetFulfillmentNotifier(notifications.NewRequestFulfillmentNotifier(notificationSystem)) + } taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil { autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher) diff --git a/internal/api/handlers/notifications_webhooks.go b/internal/api/handlers/notifications_webhooks.go index 132049eb..c052d7a9 100644 --- a/internal/api/handlers/notifications_webhooks.go +++ b/internal/api/handlers/notifications_webhooks.go @@ -24,6 +24,7 @@ type webhookResponse struct { NotifyWatchlist bool `json:"notify_watchlist"` NotifyContinueWatching bool `json:"notify_continue_watching"` NotifyNextUp bool `json:"notify_next_up"` + NotifyRequests bool `json:"notify_requests"` ConsecutiveFailures int `json:"consecutive_failures"` DisabledReason *string `json:"disabled_reason"` LastSuccessAt *time.Time `json:"last_success_at"` @@ -45,6 +46,7 @@ func webhookToResponse(hook notifications.Webhook) webhookResponse { NotifyWatchlist: hook.NotifyWatchlist, NotifyContinueWatching: hook.NotifyContinueWatching, NotifyNextUp: hook.NotifyNextUp, + NotifyRequests: hook.NotifyRequests, ConsecutiveFailures: hook.ConsecutiveFailures, DisabledReason: hook.DisabledReason, LastSuccessAt: hook.LastSuccessAt, @@ -63,6 +65,7 @@ type webhookRequest struct { NotifyWatchlist *bool `json:"notify_watchlist"` NotifyContinueWatching *bool `json:"notify_continue_watching"` NotifyNextUp *bool `json:"notify_next_up"` + NotifyRequests *bool `json:"notify_requests"` } func (r webhookRequest) toInput() notifications.WebhookInput { @@ -75,6 +78,7 @@ func (r webhookRequest) toInput() notifications.WebhookInput { NotifyWatchlist: r.NotifyWatchlist, NotifyContinueWatching: r.NotifyContinueWatching, NotifyNextUp: r.NotifyNextUp, + NotifyRequests: r.NotifyRequests, } } diff --git a/internal/notifications/delivery_repo.go b/internal/notifications/delivery_repo.go index 2cd5fc57..e5aa58b7 100644 --- a/internal/notifications/delivery_repo.go +++ b/internal/notifications/delivery_repo.go @@ -228,28 +228,6 @@ func (r *DeliveryRepository) GetRowByID(ctx context.Context, id string) (*Delive return &out[0], nil } -// InsertOperational inserts a single operational delivery (e.g. -// webhook.auto_disabled) outside the fanout path. Returns nil when the row -// deduped away. -func (r *DeliveryRepository) InsertOperational(ctx context.Context, delivery Delivery) (*InsertedDelivery, error) { - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("begin operational delivery tx: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - inserted, err := r.BulkInsert(ctx, tx, []Delivery{delivery}) - if err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit operational delivery: %w", err) - } - if len(inserted) == 0 { - return nil, nil - } - return &inserted[0], nil -} - // RecentUnread returns the newest unread rows for the websocket snapshot. func (r *DeliveryRepository) RecentUnread(ctx context.Context, profileID string, limit int) ([]DeliveryRow, error) { rows, err := r.pool.Query(ctx, diff --git a/internal/notifications/operational_dispatch.go b/internal/notifications/operational_dispatch.go new file mode 100644 index 00000000..4a5ab164 --- /dev/null +++ b/internal/notifications/operational_dispatch.go @@ -0,0 +1,99 @@ +package notifications + +import ( + "context" + "fmt" + + "github.com/oklog/ulid/v2" +) + +// OperationalDispatch describes how one operational delivery (a non-fanout +// notice such as webhook.auto_disabled or request.fulfilled) reaches the +// per-target channels. WebhookFilter selects which of the profile's enabled +// webhooks receive it; nil means the type must not reach webhooks at all +// (e.g. the auto-disable notice's loop guard). Web push has no per-type +// filter: profile-level gating happens before dispatch. +type OperationalDispatch struct { + WebhookFilter func(Webhook) bool +} + +// DispatchOperational durably creates one operational delivery. The inbox row +// and the per-target webhook / web push outbox rows commit in a single +// transaction — a crash afterwards delays channel sends instead of dropping +// them, because the retry workers recover pending outbox rows — then realtime +// and channel dispatch run post-commit. Returns nil when the delivery deduped +// away (the partial unique indexes make operational notices idempotent). +func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opts OperationalDispatch) (*InsertedDelivery, error) { + if s == nil { + return nil, nil + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin operational dispatch tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + inserted, err := s.Deliveries.BulkInsert(ctx, tx, []Delivery{delivery}) + if err != nil { + return nil, err + } + if len(inserted) == 0 { + return nil, nil + } + row := inserted[0] + + if opts.WebhookFilter != nil && s.webhookRepo != nil && s.Settings.WebhooksEnabled(ctx) { + hooksByProfile, err := s.webhookRepo.ListEnabledByProfiles(ctx, tx, []string{delivery.ProfileID}) + if err != nil { + return nil, err + } + attempts := make([]DeliveryAttempt, 0, 2) + for _, hook := range hooksByProfile[delivery.ProfileID] { + if !opts.WebhookFilter(hook) { + continue + } + attempts = append(attempts, DeliveryAttempt{ + ID: ulid.Make().String(), + NotificationDeliveryID: row.ID, + TargetID: hook.ID, + }) + } + if err := s.webhookRepo.EnqueueAttempts(ctx, tx, attempts); err != nil { + return nil, err + } + } + if s.webPushRepo != nil && s.Settings.WebPushEnabled(ctx) { + subsByProfile, err := s.webPushRepo.ListEnabledByProfiles(ctx, tx, []string{delivery.ProfileID}) + if err != nil { + return nil, err + } + attempts := make([]DeliveryAttempt, 0, 2) + for _, sub := range subsByProfile[delivery.ProfileID] { + attempts = append(attempts, DeliveryAttempt{ + ID: ulid.Make().String(), + NotificationDeliveryID: row.ID, + TargetID: sub.ID, + }) + } + if err := s.webPushRepo.EnqueueAttempts(ctx, tx, attempts); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit operational dispatch: %w", err) + } + + // Post-commit dispatch is best-effort: the durable inbox row covers + // websocket reconnect, and the retry workers recover the outbox rows. + full, err := s.Deliveries.GetRowByID(ctx, row.ID) + if err != nil || full == nil { + s.logger.Warn("operational delivery reload failed", + "delivery_id", row.ID, "error", err) + return &row, nil + } + if err := s.dispatcher.Dispatch(ctx, *full); err != nil { + s.logger.Warn("operational delivery dispatch failed", + "delivery_id", row.ID, "error", err) + } + return &row, nil +} diff --git a/internal/notifications/request_notifier.go b/internal/notifications/request_notifier.go new file mode 100644 index 00000000..97225c1a --- /dev/null +++ b/internal/notifications/request_notifier.go @@ -0,0 +1,90 @@ +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 +} diff --git a/internal/notifications/system.go b/internal/notifications/system.go index ec69e2c2..dfe77961 100644 --- a/internal/notifications/system.go +++ b/internal/notifications/system.go @@ -55,6 +55,10 @@ type System struct { webhookRetry *WebhookRetryWorker webPushRepo *WebPushRepository webPushDispatcher *WebPushDispatcher + // dispatcher is the same MultiDispatcher the fanout worker uses; the + // operational dispatch path shares it so every delivery reaches every + // configured channel the same way. + dispatcher Dispatcher pool *pgxpool.Pool stores userstore.UserStoreProvider @@ -94,7 +98,7 @@ func NewSystem( var sender *webhookSender if cipher != nil { webhookRepo = NewWebhookRepository(pool) - sender = newWebhookSender(webhookRepo, deliveries, cipher, settings, hub) + sender = newWebhookSender(webhookRepo, deliveries, cipher, settings) webhookService = newWebhookService(webhookRepo, cipher, settings, sender) webhookDispatcher = newWebhookDispatcher(sender) webhookRetry = newWebhookRetryWorker(sender) @@ -116,7 +120,8 @@ func NewSystem( dispatchers = append(dispatchers, webPushDispatcher) } - fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, NewMultiDispatcher(dispatchers...)) + multiDispatcher := NewMultiDispatcher(dispatchers...) + fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, multiDispatcher) if webhookRepo != nil { fanout.SetWebhookOutbox(webhookRepo, newProfileRateLimiter()) } @@ -144,6 +149,7 @@ func NewSystem( webhookRetry: webhookRetry, webPushRepo: webPushRepo, webPushDispatcher: webPushDispatcher, + dispatcher: multiDispatcher, pool: pool, stores: stores, users: users, @@ -151,7 +157,7 @@ func NewSystem( } wsDispatcher.payload = system.PayloadForRow if sender != nil { - sender.payload = system.PayloadForRow + sender.operational = system.DispatchOperational } if webPushSenderInst != nil { webPushSenderInst.payload = system.PayloadForRow diff --git a/internal/notifications/webhook_logic_test.go b/internal/notifications/webhook_logic_test.go index 91315aa5..74c5aa64 100644 --- a/internal/notifications/webhook_logic_test.go +++ b/internal/notifications/webhook_logic_test.go @@ -163,6 +163,85 @@ func TestBuildDiscordWebhookPayload(t *testing.T) { } } +func requestFulfilledTestRow() DeliveryRow { + contentID := "movie-123" + return DeliveryRow{ + Delivery: Delivery{ + ID: "01REQUEST", + ProfileID: "profile-1", + SeriesID: &contentID, + Type: DeliveryTypeRequestFulfilled, + ReasonFlags: []byte(`{"request_id":"01REQ","tmdb_id":438631,"media_type":"movie"}`), + CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + }, + SeriesTitle: "Dune", + } +} + +func TestBuildDiscordWebhookPayloadRequestFulfilled(t *testing.T) { + payload, err := BuildDiscordWebhookPayload(requestFulfilledTestRow(), false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body struct { + Embeds []struct { + Title string `json:"title"` + Description string `json:"description"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"fields"` + } `json:"embeds"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if len(body.Embeds) != 1 { + t.Fatalf("unexpected body shape: %+v", body) + } + embed := body.Embeds[0] + if embed.Title != "Dune" { + t.Fatalf("unexpected title %q", embed.Title) + } + if embed.Description != "Your media request is now available on Silo" { + t.Fatalf("unexpected description %q", embed.Description) + } + if len(embed.Fields) != 1 || embed.Fields[0].Name != "Type" || embed.Fields[0].Value != "Movie" { + t.Fatalf("expected a single Type=Movie field, got %+v", embed.Fields) + } +} + +func TestGenericWebhookPayloadRequestFulfilled(t *testing.T) { + payload, err := BuildGenericWebhookPayload(requestFulfilledTestRow(), "hook-1", false) + if err != nil { + t.Fatalf("build failed: %v", err) + } + var body struct { + Type string `json:"type"` + Series *struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"series"` + Request *struct { + ID string `json:"id"` + TMDBID int `json:"tmdb_id"` + MediaType string `json:"media_type"` + } `json:"request"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if body.Type != DeliveryTypeRequestFulfilled { + t.Fatalf("unexpected type %q", body.Type) + } + if body.Request == nil || body.Request.ID != "01REQ" || body.Request.TMDBID != 438631 || body.Request.MediaType != "movie" { + t.Fatalf("unexpected request block: %+v", body.Request) + } + if body.Series == nil || body.Series.ID != "movie-123" || body.Series.Title != "Dune" { + t.Fatalf("unexpected series block: %+v", body.Series) + } +} + func TestBuildDiscordWebhookPayloadTestMarker(t *testing.T) { payload, err := BuildDiscordWebhookPayload(webhookTestRow(), true) if err != nil { diff --git a/internal/notifications/webhook_payload_discord.go b/internal/notifications/webhook_payload_discord.go index 09fde7fb..d1845df5 100644 --- a/internal/notifications/webhook_payload_discord.go +++ b/internal/notifications/webhook_payload_discord.go @@ -66,6 +66,9 @@ func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { title = truncateWithEllipsis(title, discordTitleLimit) description := "New episode available on Silo" + if row.Type == DeliveryTypeRequestFulfilled { + description = "Your media request is now available on Silo" + } footerText := "Silo" if row.SeriesTitle != "" { footerText = "Silo • " + truncateWithEllipsis(row.SeriesTitle, discordFooterLimit-16) @@ -82,6 +85,11 @@ func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { Inline: true, }) } + if row.Type == DeliveryTypeRequestFulfilled { + if mediaType := requestMediaTypeLabel(row.ReasonFlags); mediaType != "" { + fields = append(fields, discordEmbedField{Name: "Type", Value: mediaType, Inline: true}) + } + } if row.SeasonNumber != nil { fields = append(fields, discordEmbedField{ Name: "Season", @@ -116,7 +124,15 @@ func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) { } func discordEmbedTitle(row DeliveryRow) string { - if row.Type != DeliveryTypeEpisodeAvailable { + switch row.Type { + case DeliveryTypeRequestFulfilled: + if row.SeriesTitle != "" { + return row.SeriesTitle + } + return "Request fulfilled" + case DeliveryTypeEpisodeAvailable: + // Falls out of the switch into the episode title assembly below. + default: return genericNotificationTitle } series := row.SeriesTitle @@ -137,6 +153,19 @@ func discordEmbedTitle(row DeliveryRow) string { } } +// requestMediaTypeLabel renders a request.fulfilled delivery's media type as +// a display label; unknown values render nothing. +func requestMediaTypeLabel(reasonFlags []byte) string { + switch parseRequestFulfilledFlags(reasonFlags).MediaType { + case "movie": + return "Movie" + case "series": + return "Series" + default: + return "" + } +} + func discordEmbedColor(flags ReasonFlags) int { switch { case flags.Favorite: diff --git a/internal/notifications/webhook_payload_generic.go b/internal/notifications/webhook_payload_generic.go index f3e08710..6609191e 100644 --- a/internal/notifications/webhook_payload_generic.go +++ b/internal/notifications/webhook_payload_generic.go @@ -28,6 +28,9 @@ type genericWebhookBody struct { Reasons ReasonFlags `json:"reason_flags"` Series *genericWebhookSeries `json:"series,omitempty"` Episode *genericWebhookEpisode `json:"episode,omitempty"` + // Request is present for request.fulfilled deliveries; the catalog item is + // in Series (movies included — the field carries the matched item). + Request *genericWebhookRequest `json:"request,omitempty"` } type genericWebhookSeries struct { @@ -42,6 +45,12 @@ type genericWebhookEpisode struct { EpisodeNumber *int `json:"episode_number,omitempty"` } +type genericWebhookRequest struct { + ID string `json:"id"` + TMDBID int `json:"tmdb_id,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + // BuildGenericWebhookPayload renders a delivery as canonical Silo JSON. Pure // function. func BuildGenericWebhookPayload(row DeliveryRow, webhookID string, test bool) ([]byte, error) { @@ -72,6 +81,14 @@ func BuildGenericWebhookPayload(row DeliveryRow, webhookID string, test bool) ([ EpisodeNumber: row.EpisodeNumber, } } + if row.Type == DeliveryTypeRequestFulfilled { + flags := parseRequestFulfilledFlags(row.ReasonFlags) + body.Request = &genericWebhookRequest{ + ID: flags.RequestID, + TMDBID: flags.TMDBID, + MediaType: flags.MediaType, + } + } return json.Marshal(body) } diff --git a/internal/notifications/webhook_repo.go b/internal/notifications/webhook_repo.go index 7374f008..ef988489 100644 --- a/internal/notifications/webhook_repo.go +++ b/internal/notifications/webhook_repo.go @@ -25,6 +25,7 @@ const webhookColumns = ` id, user_id, profile_id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled, notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, + notify_requests, consecutive_failures, disabled_reason, last_success_at, last_failure_at, last_failure_status, last_failure_message, created_at, updated_at` @@ -35,6 +36,7 @@ func scanWebhook(row pgx.Row) (*Webhook, error) { &hook.ID, &hook.UserID, &hook.ProfileID, &hook.Name, &hook.Type, &hook.URLCiphertext, &hook.URLHost, &hook.SigningSecretCiphertext, &hook.Enabled, &hook.NotifyFavorites, &hook.NotifyWatchlist, &hook.NotifyContinueWatching, &hook.NotifyNextUp, + &hook.NotifyRequests, &hook.ConsecutiveFailures, &hook.DisabledReason, &hook.LastSuccessAt, &hook.LastFailureAt, &hook.LastFailureStatus, &hook.LastFailureMessage, &hook.CreatedAt, &hook.UpdatedAt, @@ -138,11 +140,13 @@ func (r *WebhookRepository) Insert(ctx context.Context, hook Webhook) error { INSERT INTO notification_webhooks (id, user_id, profile_id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled, - notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up, + notify_requests) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, hook.ID, hook.UserID, hook.ProfileID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled, - hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp) + hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp, + hook.NotifyRequests) if err != nil { if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { return ErrWebhookNameTaken @@ -161,12 +165,14 @@ func (r *WebhookRepository) Update(ctx context.Context, hook Webhook) error { signing_secret_ciphertext = $6, enabled = $7, notify_favorites = $8, notify_watchlist = $9, notify_continue_watching = $10, notify_next_up = $11, - consecutive_failures = $12, disabled_reason = $13, + notify_requests = $12, + consecutive_failures = $13, disabled_reason = $14, updated_at = now() WHERE id = $1`, hook.ID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled, hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp, + hook.NotifyRequests, hook.ConsecutiveFailures, hook.DisabledReason) if err != nil { if strings.Contains(err.Error(), "notification_webhooks_profile_name_key") { diff --git a/internal/notifications/webhook_sender.go b/internal/notifications/webhook_sender.go index 2bb1406b..8a50288b 100644 --- a/internal/notifications/webhook_sender.go +++ b/internal/notifications/webhook_sender.go @@ -9,7 +9,6 @@ import ( "sync" "time" - evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/secret" "github.com/oklog/ulid/v2" ) @@ -69,9 +68,10 @@ type webhookSender struct { cipher *secret.Cipher settings *Settings client *http.Client - hub *evt.Hub - payload func(ctx context.Context, row DeliveryRow) DeliveryRowPayload - logger *slog.Logger + // operational posts the auto-disable notice through the system's shared + // durable dispatch path. Wired by NewSystem after construction. + operational func(ctx context.Context, delivery Delivery, opts OperationalDispatch) (*InsertedDelivery, error) + logger *slog.Logger } func newWebhookSender( @@ -79,14 +79,12 @@ func newWebhookSender( deliveries *DeliveryRepository, cipher *secret.Cipher, settings *Settings, - hub *evt.Hub, ) *webhookSender { sender := &webhookSender{ webhooks: webhooks, deliveries: deliveries, cipher: cipher, settings: settings, - hub: hub, logger: slog.Default().With("component", "notifications.webhooks"), } sender.client = newWebhookHTTPClient(func() bool { @@ -267,20 +265,13 @@ func (s *webhookSender) disableWebhook(ctx context.Context, hook *Webhook, resul Type: DeliveryTypeWebhookAutoDisabled, ReasonFlags: noticeFlags, } - inserted, err := s.deliveries.InsertOperational(ctx, notice) - if err != nil || inserted == nil { - s.logger.Warn("webhook auto-disable notice insert failed", "webhook_id", hook.ID, "error", err) + if s.operational == nil { return } - notice.ID = inserted.ID - notice.CreatedAt = inserted.CreatedAt - if s.hub != nil { - payload := PayloadForRow(DeliveryRow{Delivery: notice}) - if s.payload != nil { - payload = s.payload(ctx, DeliveryRow{Delivery: notice}) - } - _ = s.hub.PublishJSON(ctx, evt.ChannelNotifications, EventNotificationCreated, - payload, evt.PublishOptions{UserID: hook.UserID, ProfileID: hook.ProfileID}) + // Nil WebhookFilter: the notice must never re-dispatch as a webhook, or a + // broken webhook would loop forever. + if _, err := s.operational(ctx, notice, OperationalDispatch{}); err != nil { + s.logger.Warn("webhook auto-disable notice dispatch failed", "webhook_id", hook.ID, "error", err) } } diff --git a/internal/notifications/webhook_service.go b/internal/notifications/webhook_service.go index d1cd932d..dca26874 100644 --- a/internal/notifications/webhook_service.go +++ b/internal/notifications/webhook_service.go @@ -46,6 +46,7 @@ type WebhookInput struct { NotifyWatchlist *bool NotifyContinueWatching *bool NotifyNextUp *bool + NotifyRequests *bool } func validateWebhookName(name string) (string, error) { @@ -130,6 +131,7 @@ func (s *WebhookService) Create(ctx context.Context, userID int, profileID strin NotifyWatchlist: boolOrDefault(input.NotifyWatchlist, true), NotifyContinueWatching: boolOrDefault(input.NotifyContinueWatching, true), NotifyNextUp: boolOrDefault(input.NotifyNextUp, true), + NotifyRequests: boolOrDefault(input.NotifyRequests, true), } hook.URLCiphertext, err = s.cipher.Encrypt(rawURL, webhookURLAAD(hook.ID)) if err != nil { @@ -215,6 +217,9 @@ func (s *WebhookService) Update(ctx context.Context, profileID, id string, input if input.NotifyNextUp != nil { hook.NotifyNextUp = *input.NotifyNextUp } + if input.NotifyRequests != nil { + hook.NotifyRequests = *input.NotifyRequests + } if err := s.repo.Update(ctx, *hook); err != nil { if errors.Is(err, ErrWebhookNameTaken) { diff --git a/internal/notifications/webhook_types.go b/internal/notifications/webhook_types.go index b94517bb..9ab71d13 100644 --- a/internal/notifications/webhook_types.go +++ b/internal/notifications/webhook_types.go @@ -46,6 +46,7 @@ type Webhook struct { NotifyWatchlist bool NotifyContinueWatching bool NotifyNextUp bool + NotifyRequests bool ConsecutiveFailures int DisabledReason *string LastSuccessAt *time.Time diff --git a/internal/notifications/webpush_logic_test.go b/internal/notifications/webpush_logic_test.go index 3d821ef9..ffb078c8 100644 --- a/internal/notifications/webpush_logic_test.go +++ b/internal/notifications/webpush_logic_test.go @@ -32,6 +32,26 @@ func TestBuildWebPushPayload(t *testing.T) { } }) + t.Run("request fulfilled deep-links to the matched item", func(t *testing.T) { + raw, err := buildWebPushPayload(requestFulfilledTestRow(), "https://cdn.example.com/poster.jpg") + if err != nil { + t.Fatal(err) + } + var payload webPushPayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if payload.Title != "Dune is now available" { + t.Fatalf("unexpected title %q", payload.Title) + } + if payload.URL != "/item/movie-123" { + t.Fatalf("unexpected url %q", payload.URL) + } + if payload.Icon != "https://cdn.example.com/poster.jpg" { + t.Fatalf("unexpected icon %q", payload.Icon) + } + }) + t.Run("webhook auto-disable routes to settings", func(t *testing.T) { row := DeliveryRow{Delivery: Delivery{ID: "01X", Type: DeliveryTypeWebhookAutoDisabled}} raw, err := buildWebPushPayload(row, "") diff --git a/internal/notifications/webpush_sender.go b/internal/notifications/webpush_sender.go index 7c0fdb5c..118bf4df 100644 --- a/internal/notifications/webpush_sender.go +++ b/internal/notifications/webpush_sender.go @@ -81,6 +81,17 @@ func buildWebPushPayload(row DeliveryRow, posterURL string) ([]byte, error) { payload.URL = "/item/" + *row.EpisodeID } payload.Icon = posterURL + case DeliveryTypeRequestFulfilled: + if row.SeriesTitle != "" { + payload.Title = row.SeriesTitle + " is now available" + } else { + payload.Title = "Your request is now available" + } + payload.Body = "Your media request has arrived in the library." + if row.SeriesID != nil { + payload.URL = "/item/" + *row.SeriesID + } + payload.Icon = posterURL case DeliveryTypeWebhookAutoDisabled: payload.Title = "A webhook stopped working" payload.Body = "Open notification settings to fix it." diff --git a/internal/requests/notify.go b/internal/requests/notify.go new file mode 100644 index 00000000..a5b983ff --- /dev/null +++ b/internal/requests/notify.go @@ -0,0 +1,66 @@ +package requests + +import ( + "context" + "log/slog" +) + +// FulfillmentNotifier delivers the request.fulfilled notification once a +// completed request's media is confirmed present in the catalog. contentID is +// the matched catalog item id. Implementations must tolerate repeat calls for +// the same request (delivery creation is idempotent); returning nil means the +// request counts as handled and will not be retried. +type FulfillmentNotifier interface { + NotifyFulfilled(ctx context.Context, req Request, contentID string) error +} + +// SetFulfillmentNotifier wires the notification system into the reconcile +// service. Optional; without it completed requests are never notified. +func (s *Service) SetFulfillmentNotifier(n FulfillmentNotifier) { s.notifier = n } + +// notifyFulfilledLimit bounds one notification pass; the remainder lands on +// the next reconcile run. +const notifyFulfilledLimit = 100 + +// notifyFulfilledPending notifies completed requests whose media has arrived +// in the catalog. Requests completed by an integration before the library +// scan imports the files stay pending (fulfilled_notified_at IS NULL) and are +// re-checked every run until presence confirms — the notification means +// "watchable in Silo", not "download finished". The delivery insert is +// idempotent (partial unique index per request), so the notify-then-stamp +// ordering can never double-send: a crash between the two retries into a +// dedupe no-op. +func (s *Service) notifyFulfilledPending(ctx context.Context) { + if s.notifier == nil { + return + } + candidates, err := s.store.ListFulfilledUnnotified(ctx, notifyFulfilledLimit) + if err != nil { + slog.WarnContext(ctx, "request fulfill-notify: list candidates failed", "err", err) + return + } + for _, req := range candidates { + if ctx.Err() != nil { + return + } + matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(*req)}) + if err != nil { + slog.WarnContext(ctx, "request fulfill-notify: presence lookup failed", + "request_id", req.ID, "tmdb_id", req.TMDBID, "err", err) + continue + } + match := matches[req.TMDBID] + if !match.Available { + continue // not in the catalog yet; retry next run + } + if err := s.notifier.NotifyFulfilled(ctx, *req, match.ContentID); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: dispatch failed", + "request_id", req.ID, "err", err) + continue + } + if err := s.store.MarkFulfilledNotified(ctx, req.ID); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: mark failed", + "request_id", req.ID, "err", err) + } + } +} diff --git a/internal/requests/notify_test.go b/internal/requests/notify_test.go new file mode 100644 index 00000000..cbd2cd08 --- /dev/null +++ b/internal/requests/notify_test.go @@ -0,0 +1,101 @@ +package requests + +import ( + "context" + "errors" + "testing" +) + +type fakeNotifier struct { + requestIDs []string + contentIDs []string + err error +} + +func (f *fakeNotifier) NotifyFulfilled(_ context.Context, req Request, contentID string) error { + if f.err != nil { + return f.err + } + f.requestIDs = append(f.requestIDs, req.ID) + f.contentIDs = append(f.contentIDs, contentID) + return nil +} + +func completedRequestFixture(id string, tmdbID int) *Request { + return &Request{ + ID: id, + MediaType: MediaTypeMovie, + TMDBID: tmdbID, + Title: "Fixture Movie", + Status: StatusCompleted, + Outcome: OutcomeActive, + RequestedByUserID: 7, + RequestedByProfileID: "profile-1", + } +} + +func TestNotifyFulfilledPendingNotifiesAndMarks(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + presence := &fakePresence{available: map[MediaType]map[int]bool{ + MediaTypeMovie: {42: true}, + }} + notifier := &fakeNotifier{} + service := NewService(store, &fakeTMDBClient{}, presence) + service.SetFulfillmentNotifier(notifier) + + service.notifyFulfilledPending(context.Background()) + + if len(notifier.requestIDs) != 1 || notifier.requestIDs[0] != "req1" { + t.Fatalf("expected one notification for req1, got %v", notifier.requestIDs) + } + if want := fakePresenceContentID(MediaTypeMovie, 42); notifier.contentIDs[0] != want { + t.Fatalf("expected content id %q, got %q", want, notifier.contentIDs[0]) + } + if len(store.notified) != 1 || store.notified[0] != "req1" { + t.Fatalf("expected req1 marked notified, got %v", store.notified) + } +} + +func TestNotifyFulfilledPendingWaitsForCatalogMatch(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + notifier := &fakeNotifier{} + service := NewService(store, &fakeTMDBClient{}, &fakePresence{}) + service.SetFulfillmentNotifier(notifier) + + service.notifyFulfilledPending(context.Background()) + + if len(notifier.requestIDs) != 0 { + t.Fatalf("expected no notification before catalog match, got %v", notifier.requestIDs) + } + if len(store.notified) != 0 { + t.Fatalf("expected request to stay pending, got marked %v", store.notified) + } + if len(store.unnotified) != 1 { + t.Fatalf("expected request to remain in the pending set") + } +} + +func TestNotifyFulfilledPendingRetriesAfterNotifierError(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + presence := &fakePresence{available: map[MediaType]map[int]bool{ + MediaTypeMovie: {42: true}, + }} + notifier := &fakeNotifier{err: errors.New("dispatch failed")} + service := NewService(store, &fakeTMDBClient{}, presence) + service.SetFulfillmentNotifier(notifier) + + service.notifyFulfilledPending(context.Background()) + + if len(store.notified) != 0 { + t.Fatalf("a failed dispatch must not mark the request notified, got %v", store.notified) + } + if len(store.unnotified) != 1 { + t.Fatalf("expected request to remain pending for the next run") + } +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 89ceae42..71a08462 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -374,6 +374,53 @@ func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int return out, nil } +// ListFulfilledUnnotified returns completed requests whose fulfillment +// notification has not fired, oldest first. The horizon bounds how long a +// completed request keeps being presence-polled when its media never appears +// in the catalog (requests completed before the feature shipped are stamped +// by the migration backfill, so the horizon is defense in depth). +func (r *Repository) ListFulfilledUnnotified(ctx context.Context, limit int) ([]*Request, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + WHERE outcome = 'active' + AND status = 'completed' + AND fulfilled_notified_at IS NULL + AND completed_at > now() - interval '30 days' + ORDER BY completed_at ASC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list fulfilled unnotified requests: %w", err) + } + defer rows.Close() + + var out []*Request + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate fulfilled unnotified requests: %w", err) + } + return out, nil +} + +// MarkFulfilledNotified stamps the fulfillment-notification marker. Idempotent. +func (r *Repository) MarkFulfilledNotified(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE media_requests SET fulfilled_notified_at = now() + WHERE id = $1 AND fulfilled_notified_at IS NULL`, id) + if err != nil { + return fmt.Errorf("mark request fulfill-notified: %w", err) + } + return nil +} + func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) { sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter) return r.listRequests(ctx, sqlText, args) diff --git a/internal/requests/service.go b/internal/requests/service.go index 9bd57125..420e98fe 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -47,6 +47,7 @@ type Service struct { router RequestRouterProvider entitlements EntitlementResolver requesterIdentity RequesterIdentityResolver + notifier FulfillmentNotifier Now func() time.Time } @@ -719,6 +720,12 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe result.Skipped++ } } + // Presence-gated fulfillment notifications: completion above (and via the + // per-target aggregate path) only marks status; the notification fires + // once the media is confirmed present in the catalog. + if s.notifier != nil { + s.notifyFulfilledPending(ctx) + } return result, nil } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index b7550a83..fbdbabd4 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1308,6 +1308,8 @@ type fakeStore struct { requests map[string]*Request targets map[string][]Target targetSeq int64 + unnotified []string + notified []string listIntegrationsCalls int getSettingsCalls int @@ -1453,6 +1455,33 @@ func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Reque return f.candidates, nil } +func (f *fakeStore) ListFulfilledUnnotified(context.Context, int) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*Request, 0, len(f.unnotified)) + for _, id := range f.unnotified { + if req := f.requests[id]; req != nil { + copy := *req + out = append(out, ©) + } + } + return out, nil +} + +func (f *fakeStore) MarkFulfilledNotified(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + kept := f.unnotified[:0] + for _, pending := range f.unnotified { + if pending != id { + kept = append(kept, pending) + } + } + f.unnotified = kept + f.notified = append(f.notified, id) + return nil +} + func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/requests/store.go b/internal/requests/store.go index c70a8bae..2b0d8ebf 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -18,6 +18,12 @@ type Store interface { CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) GetRequest(ctx context.Context, id string) (*Request, error) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) + // ListFulfilledUnnotified returns completed requests whose fulfillment + // notification has not fired yet (presence-gated notify pass). + ListFulfilledUnnotified(ctx context.Context, limit int) ([]*Request, error) + // MarkFulfilledNotified stamps a request's fulfillment-notification + // marker so the notify pass stops considering it. Idempotent. + MarkFulfilledNotified(ctx context.Context, id string) error ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) diff --git a/migrations/sql/20260611193802_request_fulfilled_notifications.sql b/migrations/sql/20260611193802_request_fulfilled_notifications.sql new file mode 100644 index 00000000..c1aedaf3 --- /dev/null +++ b/migrations/sql/20260611193802_request_fulfilled_notifications.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- +goose StatementBegin +-- request.fulfilled notifications (docs/superpowers/plans/notifications/06, +-- item 2): notify the requesting profile once its media request is actually +-- present in the catalog. + +-- Notify marker. NULL means "completed but the fulfillment notification has +-- not fired yet"; the reconcile task keeps presence-checking such requests and +-- stamps this after the delivery is created (or suppressed by preferences). +ALTER TABLE public.media_requests + ADD COLUMN fulfilled_notified_at timestamptz; + +-- Flood safety: requests completed before this feature shipped must never +-- notify. Stamp them as already handled so only future completions fire. +UPDATE public.media_requests +SET fulfilled_notified_at = COALESCE(completed_at, now()) +WHERE status = 'completed'; + +-- The reconcile task scans for pending notifications on every run. +CREATE INDEX media_requests_fulfill_notify_idx + ON public.media_requests (completed_at) + WHERE status = 'completed' AND fulfilled_notified_at IS NULL; + +-- Per-webhook opt-out for request notifications. Defaults on: every channel +-- the requester configured should tell them (06, item 2). +ALTER TABLE public.notification_webhooks + ADD COLUMN notify_requests boolean NOT NULL DEFAULT true; + +-- At-most-once per (profile, request): the operational insert path uses +-- ON CONFLICT DO NOTHING, so a reconcile crash-retry or multi-node race +-- dedupes here instead of double-notifying. +CREATE UNIQUE INDEX notification_deliveries_profile_request_key + ON public.notification_deliveries (profile_id, (reason_flags->>'request_id')) + WHERE type = 'request.fulfilled'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS public.notification_deliveries_profile_request_key; +ALTER TABLE public.notification_webhooks DROP COLUMN IF EXISTS notify_requests; +DROP INDEX IF EXISTS public.media_requests_fulfill_notify_idx; +ALTER TABLE public.media_requests DROP COLUMN IF EXISTS fulfilled_notified_at; +-- +goose StatementEnd diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 55e00409..7ebe48b0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2225,10 +2225,15 @@ export type EventChannel = | "notifications"; export interface NotificationReasonFlags { + // episode.available reasons favorite?: boolean; watchlist?: boolean; continue_watching?: boolean; next_up?: boolean; + // request.fulfilled operational payload + request_id?: string; + tmdb_id?: number; + media_type?: string; } export interface AppNotification { @@ -2292,6 +2297,7 @@ export interface NotificationWebhook { notify_watchlist: boolean; notify_continue_watching: boolean; notify_next_up: boolean; + notify_requests: boolean; consecutive_failures: number; disabled_reason: string | null; last_success_at: string | null; @@ -2311,6 +2317,7 @@ export interface NotificationWebhookInput { notify_watchlist?: boolean; notify_continue_watching?: boolean; notify_next_up?: boolean; + notify_requests?: boolean; } export interface NotificationWebhookTestResult { diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index 3e6a071b..1f11b95b 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -529,6 +529,13 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { toast(`New episode of ${notification.series_title}`, { description: [episodeCode, notification.episode_title].filter(Boolean).join(" — "), }); + } else if (notification.type === "request.fulfilled") { + toast( + notification.series_title + ? `${notification.series_title} is now available` + : "Your request is now available", + { description: "Your media request has arrived in the library." }, + ); } return; } diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx index 4ccee7c5..84971e88 100644 --- a/web/src/pages/Notifications.tsx +++ b/web/src/pages/Notifications.tsx @@ -46,6 +46,9 @@ function notificationTitle(notification: AppNotification): string { if (notification.type === "episode.available") { return notification.series_title || "New episode available"; } + if (notification.type === "request.fulfilled") { + return notification.series_title || "Request available"; + } // Unknown types render with a generic fallback by design — the type // registry is extensible. return "Notification"; @@ -58,6 +61,14 @@ function notificationDescription(notification: AppNotification): string { [code, notification.episode_title].filter(Boolean).join(" — ") || "New episode available" ); } + if (notification.type === "request.fulfilled") { + const mediaType = notification.reason_flags?.media_type; + return mediaType === "movie" + ? "Your requested movie is now available" + : mediaType === "series" + ? "Your requested series is now available" + : "Your request is now available"; + } return notification.type; } diff --git a/web/src/pages/settings/NotificationsSettings.tsx b/web/src/pages/settings/NotificationsSettings.tsx index 9596dfd7..15a6a651 100644 --- a/web/src/pages/settings/NotificationsSettings.tsx +++ b/web/src/pages/settings/NotificationsSettings.tsx @@ -68,7 +68,14 @@ const REASON_FIELDS = [ { key: "notify_next_up", label: "Next Up" }, ] as const; -type ReasonKey = (typeof REASON_FIELDS)[number]["key"]; +// Webhooks additionally carry the request.fulfilled toggle; it is not an +// episode reason, so the profile preferences section keeps REASON_FIELDS. +const WEBHOOK_NOTIFY_FIELDS = [ + ...REASON_FIELDS, + { key: "notify_requests", label: "Requests" }, +] as const; + +type WebhookNotifyKey = (typeof WEBHOOK_NOTIFY_FIELDS)[number]["key"]; function formatRelativeTime(value: string | null): string | null { if (!value) { @@ -307,11 +314,12 @@ function WebhookFormDialog({ const update = useUpdateNotificationWebhook(); const [name, setName] = useState(webhook?.name ?? ""); const [url, setUrl] = useState(""); - const [reasons, setReasons] = useState>({ + const [reasons, setReasons] = useState>({ notify_favorites: webhook?.notify_favorites ?? true, notify_watchlist: webhook?.notify_watchlist ?? true, notify_continue_watching: webhook?.notify_continue_watching ?? true, notify_next_up: webhook?.notify_next_up ?? true, + notify_requests: webhook?.notify_requests ?? true, }); const pending = create.isPending || update.isPending; const editing = webhook != null; @@ -384,11 +392,14 @@ function WebhookFormDialog({
- {REASON_FIELDS.map((field) => { + {WEBHOOK_NOTIFY_FIELDS.map((field) => { + // Requests have no per-profile reason toggle; only the master + // switch suppresses them. const globallyDisabled = globalPrefs != null && (!globalPrefs.enabled || - !(globalPrefs[field.key as keyof NotificationPreferences] as boolean)); + (field.key !== "notify_requests" && + !(globalPrefs[field.key as keyof NotificationPreferences] as boolean))); return (
@@ -446,7 +457,7 @@ function WebhookCard({ const failing = webhook.last_failure_at != null && (webhook.last_success_at == null || webhook.last_failure_at > webhook.last_success_at); - const enabledReasons = REASON_FIELDS.filter( + const enabledReasons = WEBHOOK_NOTIFY_FIELDS.filter( (field) => webhook[field.key as keyof NotificationWebhook] as boolean, ).map((field) => field.label); @@ -468,7 +479,7 @@ function WebhookCard({
- {enabledReasons.length === REASON_FIELDS.length + {enabledReasons.length === WEBHOOK_NOTIFY_FIELDS.length ? "All reasons" : enabledReasons.length > 0 ? enabledReasons.join(" · ")