package api import ( "context" "errors" "time" "github.com/ponzischeme89/memby/server/internal/notify" ) // The gateway's notification providers, and the two helpers every producer now calls. // // internal/notify owns the audit trail and knows nothing about Memby's channels; this file // is the other half — what "in-app" and "broadcast" actually mean here. A feature says // *what* it wants said; these decide how it is carried and what to report about it. // // The sources below name the service that decided to notify. They are stored and the // console filters on them, so they are constants rather than string literals typed at each // call site: a source spelled two ways is two rows in a dropdown for one feature. const ( notifySourceSonarrLifecycle = "sonarr-lifecycle" notifySourceShowReturn = "show-return-scan" notifySourceAutoFollow = "auto-follow" notifySourceWatchTime = "watch-time-digest" notifySourceLibraryIngest = "library-ingest" notifySourceLibrarySync = "library-sync" notifySourceDeployment = "deployment" notifySourceEmbyHealth = "emby-health" notifySourceIntegrations = "integrations" ) // registerNotifiers installs the gateway's delivery providers on the notification service. // Called once from New, so every producer can assume the channels it uses exist. func (s *Server) registerNotifiers() { if s.notify == nil { return } s.notify.Register( notify.DelivererFunc{Name: notify.ChannelInApp, Fn: s.deliverInApp}, notify.DelivererFunc{Name: notify.ChannelBroadcast, Fn: s.deliverBroadcast}, ) } // deliverInApp writes a notification into one viewer's own list. // // The three answers it can give are all real and all worth recording separately. A row was // written: sent. A row with that source key was already there: skipped, because the // producers here are deliberately re-run — the watch-time digest fires hourly and re-sends // the same weekly key all evening so a gateway that was off still delivers — and every one // of those catch-up passes would otherwise read as a summary somebody never got. And the // write failed: failed, with the database's own words, which is the only thing that would // explain a viewer's empty list. func (s *Server) deliverInApp(ctx context.Context, n notify.Notification) notify.Outcome { if s.store == nil { return notify.Failed(errors.New("no database")) } if n.UserID == "" { return notify.Failed(errors.New("an in-app notification needs a recipient")) } inserted, err := s.store.UpsertNotification( ctx, n.UserID, n.SourceKey, n.Kind, n.ItemID, n.Title, n.Body, n.EventAt) if err != nil { return notify.Failed(err) } if !inserted { return notify.Skipped("already in this viewer's list") } return notify.Sent() } // broadcastWindow travels with a broadcast notification: how long the alert stays on offer // to televisions that were switched off when it happened. const broadcastWindowKey = "windowSeconds" // deliverBroadcast publishes a service alert to every signed-in television. // // The alert itself is carried in the notification's metadata rather than in its fields, // because a clientAlert is a wire type with an id, a kind and an image tag that // notify.Notification has no business modelling. broadcast() below is the only thing that // builds one of these, so the round trip is contained. func (s *Server) deliverBroadcast(ctx context.Context, n notify.Notification) notify.Outcome { alert, ok := n.Metadata[broadcastAlertKey].(clientAlert) if !ok { return notify.Failed(errors.New("no alert to publish")) } window, _ := n.Metadata[broadcastWindowKey].(time.Duration) if window <= 0 { // An operator has this kind of news switched off. Deliberately a recorded skip // rather than silence: "the window is zero" is the answer to why nobody was told, // and it is not one anybody would find by reading the code. return notify.Skipped("this alert window is switched off") } if s.cache == nil { return notify.Failed(errors.New("no cache to publish alerts through")) } if err := s.publishAlertNow(ctx, alert, window); err != nil { return notify.Failed(err) } return notify.Sent() } // broadcastAlertKey is the metadata slot the clientAlert rides in. It is stripped before // the record is written — the alert's fields are already the record's title and body, and // storing the whole struct again would put a second copy of every banner in the log. const broadcastAlertKey = "alert" // broadcast is what every service-alert producer calls in place of publishAlert. // // It is the one place a clientAlert becomes a notification, so the console's row for a // banner says the same thing the television's bar said, with no producer having to // describe its news twice. func (s *Server) broadcast( ctx context.Context, source string, alert clientAlert, window time.Duration, ) { outcome := s.notify.Send(ctx, notify.Notification{ Channel: notify.ChannelBroadcast, Kind: alert.Kind, Source: source, Title: alert.Title, Body: alert.Message, ItemID: alert.ItemID, SourceKey: alert.ID, EventAt: alertEventTime(alert), Metadata: map[string]any{ broadcastAlertKey: alert, broadcastWindowKey: window, "label": alert.Label, }, }) if outcome.Err != nil { s.loggerFor(ctx).Warn("service alert not published", "kind", alert.Kind, "id", alert.ID, "error", outcome.Err) } } func alertEventTime(alert clientAlert) *time.Time { when, err := time.Parse(time.RFC3339, alert.AiredAt) if err != nil { return nil } return &when } // notifyUser is what every per-viewer producer calls in place of store.UpsertNotification. // // It returns whether the notification actually reached the viewer's list, which is what // the callers' own counters mean: the Sonarr scan reporting "14 notifications" must not // count fourteen repeats of one it had already sent. func (s *Server) notifyUser(ctx context.Context, n notify.Notification) bool { n.Channel = notify.ChannelInApp return s.notify.Send(ctx, n).Status == notify.StatusSent } // declineUser records a notification a viewer's own preferences refused. // // This is the half a per-feature audit trail always misses, and it is the reason the page // is worth having: "I never got the weekly summary" and "you have weekly summaries turned // off" look identical from the outside, and only a recorded skip tells them apart. It is // never delivered, so it goes through Log rather than Send. func (s *Server) declineUser(ctx context.Context, n notify.Notification, reason string) { n.Channel = notify.ChannelInApp s.notify.Log(ctx, n, notify.Skipped(reason), 0) }