Files
2026-08-19 06:57:59 +12:00

164 lines
6.5 KiB
Go

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)
}