Files
memby/server/internal/api/admin_notification_log.go
T

139 lines
5.3 KiB
Go
Raw Normal View History

2026-08-19 06:57:59 +12:00
package api
import (
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The console's window on the outbound notification log.
//
// One route rather than three, unlike the sign-in history: an operator arrives here with a
// *question* — "did the weekly summary go out", "why did nobody get told about that
// import" — and every part of the answer is the same filtered window. Splitting the table
// from its totals would mean two requests that could disagree with each other while a
// filter was being typed.
const (
// notificationPageLimit caps one page. Large enough that the ordinary answer needs no
// paging, small enough that a household with a busy week does not send a megabyte.
notificationPageLimit = 100
// notificationWindowDays is the widest window the page offers, derived from the
// retention period rather than written down: PruneNotificationLog removes anything
// older, so a page offering 180 days would draw a flat line for half of it.
notificationWindowDays = int(store.NotificationRetention / (24 * time.Hour))
)
type adminNotificationLogResponse struct {
Entries []store.NotificationLogEntry `json:"entries"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Totals store.NotificationLogTotals `json:"totals"`
Days []store.NotificationLogDay `json:"days"`
Facets store.NotificationFacets `json:"facets"`
Users []store.KnownUser `json:"users"`
Retention int `json:"retentionDays"`
}
// notificationLogFilter reads the console's question off the query string.
//
// Every list filter is comma-separated and multi-valued, because the useful questions are
// plural: "everything that failed or was skipped", "both digest kinds". A single-valued
// filter would make the common troubleshooting question take two passes.
func notificationLogFilter(r *http.Request) store.NotificationLogFilter {
query := r.URL.Query()
filter := store.NotificationLogFilter{
UserID: strings.TrimSpace(query.Get("user")),
Kinds: splitCSV(query.Get("kind")),
Channels: splitCSV(query.Get("channel")),
Statuses: splitCSV(query.Get("status")),
Sources: splitCSV(query.Get("source")),
Query: strings.TrimSpace(query.Get("q")),
Limit: queryInt(r, "limit", notificationPageLimit, 500),
Offset: queryInt(r, "offset", 0, 100000),
}
filter.From, filter.To = notificationWindow(r)
return filter
}
// notificationWindow resolves the date range.
//
// An explicit `from` wins over the day window, the rule the sign-in history follows: an
// operator who typed a date meant it, and silently narrowing it to the last week would
// answer a question they did not ask. `to` is read as the *end* of the day named, because
// somebody filtering "to the 12th" means through the 12th, not up to midnight at its start.
func notificationWindow(r *http.Request) (time.Time, time.Time) {
query := r.URL.Query()
from := parseDay(query.Get("from"))
to := parseDay(query.Get("to"))
if !to.IsZero() {
to = to.AddDate(0, 0, 1)
}
if from.IsZero() {
days := queryInt(r, "days", 7, notificationWindowDays)
if days > 0 {
from = time.Now().UTC().AddDate(0, 0, -days)
}
}
return from, to
}
func parseDay(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
day, err := time.Parse("2006-01-02", raw)
if err != nil {
return time.Time{}
}
return day
}
// handleAdminNotificationLog answers the Notifications page.
//
// The log is the page and everything else is decoration, which is why only its failure is
// a 500: a facet list or a name lookup that will not answer costs a dropdown, and an
// operator reading this page after something went wrong must still get the rows.
func (s *Server) handleAdminNotificationLog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
filter := notificationLogFilter(r)
page, err := s.store.NotificationLog(ctx, filter)
if err != nil {
s.loggerFor(ctx).Error("notification log read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the notification history")
return
}
response := adminNotificationLogResponse{
Entries: page.Entries, Total: page.Total, Limit: page.Limit, Offset: page.Offset,
Days: []store.NotificationLogDay{}, Users: []store.KnownUser{},
Retention: notificationWindowDays,
}
if totals, err := s.store.NotificationLogTotals(ctx, filter); err == nil {
response.Totals = totals
} else {
s.loggerFor(ctx).Warn("notification totals unavailable", "error", err)
}
if days, err := s.store.NotificationLogDays(ctx, filter); err == nil {
response.Days = days
}
// Facets are computed over the whole retention window rather than the current filter,
// so narrowing the table never removes the option that would widen it again.
since := time.Now().UTC().Add(-store.NotificationRetention)
if facets, err := s.store.NotificationLogFacets(ctx, since); err == nil {
response.Facets = facets
} else {
s.loggerFor(ctx).Warn("notification facets unavailable", "error", err)
}
if users, err := s.store.KnownUsers(ctx); err == nil {
response.Users = users
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, response)
}