139 lines
5.3 KiB
Go
139 lines
5.3 KiB
Go
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)
|
|
}
|