0.2.77
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user