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