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