This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
+26 -3
View File
@@ -281,16 +281,26 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
return result, rows.Err()
}
// UpsertNotification writes one notification into a viewer's own list.
//
// It reports whether a row was actually inserted, which is what separates the two answers
// the source key produces: a genuine delivery, and a repeat of one already sitting in
// somebody's list. Both are ordinary — the digest job runs hourly and re-sends the same
// weekly key all evening on purpose — but the notification log has to be able to tell them
// apart, or every catch-up run would read as a second summary nobody received.
func (s *Store) UpsertNotification(
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
) error {
_, err := s.pool.Exec(ctx, `
) (bool, error) {
tag, err := s.pool.Exec(ctx, `
INSERT INTO user_notifications
(emby_user_id, source_key, kind, item_id, title, message, event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
userID, sourceKey, kind, itemID, title, message, eventAt)
return err
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
@@ -325,6 +335,19 @@ func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int6
return err
}
// MarkNotificationUnread puts a notification back to new.
//
// The counterpart to MarkNotificationRead, and deliberately a plain assignment rather than
// that one's COALESCE: read is sticky because it is set by merely looking at a row, so a
// second glance must not move the timestamp, while unread is only ever the viewer saying so
// and means exactly one thing.
func (s *Store) MarkNotificationUnread(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET read_at = NULL
WHERE id = $1 AND emby_user_id = $2`, id, userID)
return err
}
func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET dismissed_at = now()
+378
View File
@@ -0,0 +1,378 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/notify"
)
// The notification log: what Memby sent, to whom, over which channel, and what became of
// it. Written by internal/notify — nothing else writes this table, which is the whole
// point of it — and read only by the console.
//
// It is deliberately a separate table from user_notifications rather than a set of extra
// columns on it. That table is *state*: one viewer's undismissed list, which they empty.
// This is *history*: it keeps a row for a notification that was dismissed, for one that
// was never delivered, and for a broadcast that belongs to no viewer at all — none of
// which the other table can represent.
// NotificationRetention is how far back the log goes. Ninety days is long enough that a
// question about "the summary I never got last month" is still answerable, and short
// enough that the table cannot outgrow the database on a household gateway. The
// housekeeping task prunes to it; the console derives its widest window from it, so the
// page can never offer a range the data does not cover.
const NotificationRetention = 90 * 24 * time.Hour
// notificationTextLimit bounds a stored string. A notification body is a sentence or two
// by construction, and this is only here so a bug in a producer cannot write a megabyte
// per row into the audit trail.
const notificationTextLimit = 2000
// NotificationLogEntry is one delivered — or refused — notification as the console reads
// it.
type NotificationLogEntry struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
Channel string `json:"channel"`
Kind string `json:"kind"`
Source string `json:"source"`
UserID string `json:"userId,omitempty"`
Username string `json:"username,omitempty"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
ItemID string `json:"itemId,omitempty"`
Target string `json:"target,omitempty"`
SourceKey string `json:"sourceKey,omitempty"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
DurationMS int64 `json:"durationMs"`
EventAt *time.Time `json:"eventAt,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
// NotificationLogFilter is the console's question. Every field is optional and they
// combine with AND, which is what makes the filter bar above the table read the way it
// behaves.
type NotificationLogFilter struct {
UserID string
Kinds []string
Channels []string
Statuses []string
Sources []string
// Query searches the title, the body, the failure detail and the recipient's name. One
// box rather than four, because an operator arriving here is looking for a *thing* they
// half remember and does not yet know which column it is in.
Query string
From time.Time
To time.Time
Limit int
Offset int
}
// NotificationLogPage is a window on the log plus the counts the page heads itself with.
type NotificationLogPage struct {
Entries []NotificationLogEntry `json:"entries"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// NotificationLogTotals summarises the filtered window. Counted by its own query rather
// than tallied from the page, for the reason SearchTotals is: the page is capped, so
// adding it up would report the first hundred rows' totals as the window's.
type NotificationLogTotals struct {
Total int `json:"total"`
Sent int `json:"sent"`
Delivered int `json:"delivered"`
Failed int `json:"failed"`
Pending int `json:"pending"`
Skipped int `json:"skipped"`
Users int `json:"users"`
}
// NotificationFacet is one value of a filterable column and how many rows carry it. The
// console builds its dropdowns from these rather than from a list of constants, so the
// filter can neither offer a type that matches nothing nor miss one a feature added after
// the page was written — the stance the activity feed's type filter takes.
type NotificationFacet struct {
Value string `json:"value"`
Count int `json:"count"`
}
// NotificationFacets is every dropdown on the page.
type NotificationFacets struct {
Kinds []NotificationFacet `json:"kinds"`
Channels []NotificationFacet `json:"channels"`
Statuses []NotificationFacet `json:"statuses"`
Sources []NotificationFacet `json:"sources"`
}
// RecordNotification writes one row of the audit trail.
//
// It implements notify.Recorder, which is the only thing that calls it. Text is clamped
// here rather than at the caller so one careless producer cannot be the reason the console
// takes a second to draw.
func (s *Store) RecordNotification(ctx context.Context, record notify.Record) error {
if s == nil || s.pool == nil {
return nil
}
occurred := record.OccurredAt
if occurred.IsZero() {
occurred = time.Now().UTC()
}
var metadata any
if len(record.Metadata) > 0 {
metadata = []byte(record.Metadata)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO notification_log
(occurred_at, channel, kind, source, emby_user_id, username, title, body,
item_id, target, source_key, status, detail, duration_ms, event_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
occurred, string(record.Channel), record.Kind, record.Source,
record.UserID, record.Username,
clampText(record.Title), clampText(record.Body),
record.ItemID, record.Target, record.SourceKey,
string(record.Status), clampText(record.Detail),
record.DurationMS, record.EventAt, metadata)
if err != nil {
return fmt.Errorf("store: record notification: %w", err)
}
return nil
}
func clampText(value string) string {
runes := []rune(value)
if len(runes) <= notificationTextLimit {
return value
}
return string(runes[:notificationTextLimit]) + "…"
}
// notificationWhere builds the shared predicate. The log, the totals and the facets all
// answer for the *same* filtered window, so they must be filtered identically — writing
// the clause three times is how a page comes to show a total that disagrees with its own
// table.
func notificationWhere(filter NotificationLogFilter) (string, []any) {
clauses := []string{"TRUE"}
args := []any{}
add := func(clause string, value any) {
args = append(args, value)
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
}
if filter.UserID != "" {
add("emby_user_id = $%d", filter.UserID)
}
if len(filter.Kinds) > 0 {
add("kind = ANY($%d)", filter.Kinds)
}
if len(filter.Channels) > 0 {
add("channel = ANY($%d)", filter.Channels)
}
if len(filter.Statuses) > 0 {
add("status = ANY($%d)", filter.Statuses)
}
if len(filter.Sources) > 0 {
add("source = ANY($%d)", filter.Sources)
}
if !filter.From.IsZero() {
add("occurred_at >= $%d", filter.From)
}
if !filter.To.IsZero() {
add("occurred_at < $%d", filter.To)
}
if query := strings.TrimSpace(filter.Query); query != "" {
// ILIKE over four columns rather than a tsvector: this table is a few tens of
// thousands of rows on a household gateway, always read with a date bound, and the
// operator is looking for a substring of a title or an error message — which is
// exactly what full-text search is worst at.
add("(title ILIKE $%[1]d OR body ILIKE $%[1]d OR detail ILIKE $%[1]d OR username ILIKE $%[1]d)",
"%"+query+"%")
}
return strings.Join(clauses, " AND "), args
}
// NotificationLog reads the filtered window, newest first.
func (s *Store) NotificationLog(
ctx context.Context, filter NotificationLogFilter,
) (NotificationLogPage, error) {
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
where, args := notificationWhere(filter)
page := NotificationLogPage{Entries: []NotificationLogEntry{}, Limit: limit, Offset: offset}
if err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM notification_log WHERE `+where, args...,
).Scan(&page.Total); err != nil {
return page, fmt.Errorf("store: count notification log: %w", err)
}
rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, channel, kind, source, emby_user_id, username, title, body,
item_id, target, source_key, status, detail, duration_ms, event_at, metadata
FROM notification_log
WHERE `+where+`
ORDER BY occurred_at DESC, id DESC
LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2),
append(args, limit, offset)...)
if err != nil {
return page, fmt.Errorf("store: read notification log: %w", err)
}
defer rows.Close()
for rows.Next() {
var entry NotificationLogEntry
var metadata []byte
if err := rows.Scan(
&entry.ID, &entry.OccurredAt, &entry.Channel, &entry.Kind, &entry.Source,
&entry.UserID, &entry.Username, &entry.Title, &entry.Body,
&entry.ItemID, &entry.Target, &entry.SourceKey, &entry.Status, &entry.Detail,
&entry.DurationMS, &entry.EventAt, &metadata,
); err != nil {
return page, fmt.Errorf("store: scan notification log: %w", err)
}
if len(metadata) > 0 {
entry.Metadata = json.RawMessage(metadata)
}
page.Entries = append(page.Entries, entry)
}
if err := rows.Err(); err != nil {
return page, fmt.Errorf("store: read notification log: %w", err)
}
return page, nil
}
// NotificationLogTotals counts the same window the log is read with.
func (s *Store) NotificationLogTotals(
ctx context.Context, filter NotificationLogFilter,
) (NotificationLogTotals, error) {
where, args := notificationWhere(filter)
var totals NotificationLogTotals
err := s.pool.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE status = 'sent'),
count(*) FILTER (WHERE status = 'delivered'),
count(*) FILTER (WHERE status = 'failed'),
count(*) FILTER (WHERE status = 'pending'),
count(*) FILTER (WHERE status = 'skipped'),
count(DISTINCT emby_user_id) FILTER (WHERE emby_user_id <> '')
FROM notification_log
WHERE `+where, args...).Scan(
&totals.Total, &totals.Sent, &totals.Delivered, &totals.Failed,
&totals.Pending, &totals.Skipped, &totals.Users)
if err != nil {
return totals, fmt.Errorf("store: notification totals: %w", err)
}
return totals, nil
}
// NotificationLogFacets lists what the filters may offer.
//
// Deliberately computed over the retention window rather than over the operator's current
// filter: a dropdown whose options disappear as you narrow the table is one you cannot use
// to widen the question again.
func (s *Store) NotificationLogFacets(
ctx context.Context, since time.Time,
) (NotificationFacets, error) {
facets := NotificationFacets{
Kinds: []NotificationFacet{}, Channels: []NotificationFacet{},
Statuses: []NotificationFacet{}, Sources: []NotificationFacet{},
}
rows, err := s.pool.Query(ctx, `
SELECT 'kind', kind, count(*) FROM notification_log
WHERE occurred_at >= $1 AND kind <> '' GROUP BY kind
UNION ALL
SELECT 'channel', channel, count(*) FROM notification_log
WHERE occurred_at >= $1 AND channel <> '' GROUP BY channel
UNION ALL
SELECT 'status', status, count(*) FROM notification_log
WHERE occurred_at >= $1 AND status <> '' GROUP BY status
UNION ALL
SELECT 'source', source, count(*) FROM notification_log
WHERE occurred_at >= $1 AND source <> '' GROUP BY source
ORDER BY 3 DESC, 2`, since)
if err != nil {
return facets, fmt.Errorf("store: notification facets: %w", err)
}
defer rows.Close()
for rows.Next() {
var group string
var facet NotificationFacet
if err := rows.Scan(&group, &facet.Value, &facet.Count); err != nil {
return facets, fmt.Errorf("store: scan notification facet: %w", err)
}
switch group {
case "kind":
facets.Kinds = append(facets.Kinds, facet)
case "channel":
facets.Channels = append(facets.Channels, facet)
case "status":
facets.Statuses = append(facets.Statuses, facet)
case "source":
facets.Sources = append(facets.Sources, facet)
}
}
return facets, rows.Err()
}
// NotificationLogDays is the daily shape of the filtered window, for the chart above the
// table. Grouped in the database's own timezone, the stance the sign-in history takes, so
// an evening notification stays on the day it happened.
type NotificationLogDay struct {
Day string `json:"day"`
Sent int `json:"sent"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Delivered int `json:"delivered"`
}
func (s *Store) NotificationLogDays(
ctx context.Context, filter NotificationLogFilter,
) ([]NotificationLogDay, error) {
where, args := notificationWhere(filter)
rows, err := s.pool.Query(ctx, `
SELECT to_char(date_trunc('day', occurred_at), 'YYYY-MM-DD'),
count(*) FILTER (WHERE status = 'sent'),
count(*) FILTER (WHERE status = 'failed'),
count(*) FILTER (WHERE status = 'skipped'),
count(*) FILTER (WHERE status = 'delivered')
FROM notification_log
WHERE `+where+`
GROUP BY 1 ORDER BY 1`, args...)
if err != nil {
return nil, fmt.Errorf("store: notification days: %w", err)
}
defer rows.Close()
days := []NotificationLogDay{}
for rows.Next() {
var day NotificationLogDay
if err := rows.Scan(&day.Day, &day.Sent, &day.Failed, &day.Skipped, &day.Delivered); err != nil {
return nil, fmt.Errorf("store: scan notification day: %w", err)
}
days = append(days, day)
}
return days, rows.Err()
}
// PruneNotificationLog is the retention policy, run by the housekeeping scheduler.
func (s *Store) PruneNotificationLog(ctx context.Context, older time.Duration) (int64, error) {
if older <= 0 {
return 0, nil
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM notification_log WHERE occurred_at < now() - $1::interval`,
older.String())
if err != nil {
return 0, fmt.Errorf("store: prune notification log: %w", err)
}
return tag.RowsAffected(), nil
}
+129
View File
@@ -0,0 +1,129 @@
package store
import (
"strings"
"testing"
"time"
)
// notificationWhere is the one predicate the log, its totals, its daily chart and the page
// count all read with. It is worth pinning hard for two reasons: a placeholder numbered
// wrong is a query that either fails or, worse, filters on the wrong argument, and a clause
// that drifts between the four readers is a page whose total disagrees with its own table.
func TestNotificationWhereIsEmptyByDefault(t *testing.T) {
where, args := notificationWhere(NotificationLogFilter{})
if where != "TRUE" {
t.Fatalf("where = %q, want an unfiltered predicate", where)
}
if len(args) != 0 {
t.Fatalf("args = %v, want none", args)
}
}
func TestNotificationWhereNumbersPlaceholdersInOrder(t *testing.T) {
from := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
where, args := notificationWhere(NotificationLogFilter{
UserID: "u1",
Kinds: []string{"show-return", "watch-time-week"},
Channels: []string{"in-app"},
Statuses: []string{"failed", "skipped"},
Sources: []string{"watch-time-digest"},
Query: "bear",
From: from,
To: to,
})
// One placeholder per argument, in the order the arguments are appended. The search
// clause reuses its placeholder across four columns, which is why the count of distinct
// placeholders is what matters rather than the count of "$".
for i := range args {
marker := "$" + itoa(i+1)
if !strings.Contains(where, marker) {
t.Fatalf("clause %q never uses %s; the arguments and the placeholders disagree", where, marker)
}
}
if len(args) != 8 {
t.Fatalf("args = %d, want 8", len(args))
}
if args[0] != "u1" {
t.Fatalf("args[0] = %v, want the user id first", args[0])
}
if args[len(args)-1] != "%bear%" {
t.Fatalf("args[last] = %v, want the wrapped search term", args[len(args)-1])
}
if args[6] != to {
t.Fatalf("args[6] = %v, want the upper bound", args[6])
}
}
// Every filter combines with AND. A page whose controls quietly ORed together would answer
// a different question from the one the filter bar describes.
func TestNotificationWhereCombinesWithAnd(t *testing.T) {
where, _ := notificationWhere(NotificationLogFilter{
UserID: "u1", Statuses: []string{"failed"},
})
if strings.Contains(where, " OR emby_user_id") {
t.Fatalf("clause %q ORs its filters together", where)
}
if strings.Count(where, " AND ") != 2 {
t.Fatalf("clause %q does not AND both filters", where)
}
}
// An empty list is not a filter. Sending `ANY('{}')` would match nothing, so a page whose
// dropdown is on "any" would show an empty table.
func TestNotificationWhereIgnoresEmptyLists(t *testing.T) {
where, args := notificationWhere(NotificationLogFilter{
Kinds: []string{}, Channels: nil, Statuses: []string{}, Query: " ",
})
if where != "TRUE" || len(args) != 0 {
t.Fatalf("where = %q args = %v; an unset filter must not narrow anything", where, args)
}
}
// The search box covers the four columns an operator half-remembers something from, and it
// must use one placeholder for all of them — repeating the argument four times would put
// the later filters' placeholders out of step.
func TestNotificationWhereSearchesFourColumnsWithOneArgument(t *testing.T) {
where, args := notificationWhere(NotificationLogFilter{Query: "timeout"})
for _, column := range []string{"title ILIKE", "body ILIKE", "detail ILIKE", "username ILIKE"} {
if !strings.Contains(where, column) {
t.Errorf("search does not cover %s", column)
}
}
if len(args) != 1 {
t.Fatalf("args = %d, want one shared search argument", len(args))
}
if strings.Count(where, "$1") != 4 {
t.Fatalf("clause %q does not reuse $1 across all four columns", where)
}
}
// The retention constant is what the console derives its widest window from, so a change to
// one that is not a change to the other would offer a range the prune has already emptied.
func TestNotificationRetentionIsWholeDays(t *testing.T) {
if NotificationRetention%(24*time.Hour) != 0 {
t.Fatalf("retention %v is not a whole number of days", NotificationRetention)
}
if days := int(NotificationRetention / (24 * time.Hour)); days != 90 {
t.Fatalf("retention = %d days, want 90", days)
}
}
func TestClampTextBoundsAStoredString(t *testing.T) {
if got := clampText("short"); got != "short" {
t.Fatalf("clampText shortened an ordinary string to %q", got)
}
long := strings.Repeat("é", notificationTextLimit+50)
got := clampText(long)
// Counted in runes, not bytes: a body in Japanese must not be cut at a third of an
// English one's length, and never mid-character.
if runes := []rune(got); len(runes) != notificationTextLimit+1 {
t.Fatalf("clamped to %d runes, want %d plus the ellipsis", len(runes), notificationTextLimit)
}
if !strings.HasSuffix(got, "…") {
t.Fatal("a clamped string does not say that it was clamped")
}
}
+39
View File
@@ -831,3 +831,42 @@ CREATE INDEX IF NOT EXISTS library_ingest_pending_idx
WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
ON library_ingest_queue (updated_at DESC);
-- The outbound notification log: what Memby sent, to whom, over which channel, and what
-- became of it. Written only by internal/notify, which every producer now goes through,
-- so this is one audit trail rather than a per-feature guess.
--
-- Deliberately separate from user_notifications. That table is one viewer's undismissed
-- list — state they empty — where this is history: it keeps the row for a notification
-- that was dismissed, for one that was deliberately skipped, and for a broadcast that
-- belongs to no viewer at all, none of which the other table can represent.
--
-- emby_user_id is '' rather than NULL for a household broadcast, so every filter is an
-- equality test and no query needs a NULL case.
CREATE TABLE IF NOT EXISTS notification_log (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
channel TEXT NOT NULL, -- in-app | broadcast | webhook
kind TEXT NOT NULL DEFAULT '', -- show-return, watch-time-week, …
source TEXT NOT NULL DEFAULT '', -- the service that decided to send
emby_user_id TEXT NOT NULL DEFAULT '', -- '' is the whole household
username TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '', -- a destination's NAME, never its address
source_key TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, -- sent | delivered | failed | pending | skipped
detail TEXT NOT NULL DEFAULT '', -- the failure, or why it was skipped
duration_ms BIGINT NOT NULL DEFAULT 0,
event_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- The page's default read is the whole log newest-first, and every filtered read still
-- bounds on the date; the remaining three cover the columns the filter bar offers.
CREATE INDEX IF NOT EXISTS notification_log_time_idx ON notification_log (occurred_at DESC);
CREATE INDEX IF NOT EXISTS notification_log_user_idx
ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> '';
CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC);
CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);