Files
memby/server/internal/api/admin_searches.go
2026-08-09 08:25:50 +12:00

101 lines
3.7 KiB
Go

package api
import (
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The console's window on search history.
//
// Two shapes of the same table, because they answer different questions. The summary says
// what the household looks for, which is what a library is bought and organised against;
// the log says what happened just now, which is what an operator needs the moment somebody
// reports that search is not finding something — it shows the query exactly as it was
// typed, by whom, and when.
const (
// searchTermLimit caps the summary. Long enough to show a tail, short enough that the
// table is read rather than scrolled.
searchTermLimit = 25
// searchEventLimit caps the log. It is a window on recent activity, not an export.
searchEventLimit = 100
// searchWindowDays is the widest window the page offers, and it is the retention
// period rather than a round number: RecordSearch prunes to it, so a page offering
// more would draw a flat line for the difference.
searchWindowDays = int(store.SearchRetention / (24 * time.Hour))
)
type adminSearchesResponse struct {
Days int `json:"days"`
Retention int `json:"retentionDays"`
Totals store.SearchTotals `json:"totals"`
Terms []store.SearchTerm `json:"terms"`
Recent []store.SearchEvent `json:"recent"`
TermLimit int `json:"termLimit"`
EventLimit int `json:"eventLimit"`
}
func (s *Server) handleAdminSearches(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
days := queryInt(r, "days", 7, searchWindowDays)
since := time.Now().UTC().AddDate(0, 0, -days)
totals, err := s.store.SearchTotals(ctx, since)
if err != nil {
s.loggerFor(ctx).Error("search totals failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read search history")
return
}
terms, err := s.store.SearchTerms(ctx, since, searchTermLimit)
if err != nil {
s.loggerFor(ctx).Error("search terms failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read search history")
return
}
recent, err := s.store.SearchEvents(ctx, since, searchEventLimit)
if err != nil {
s.loggerFor(ctx).Error("search events failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read search history")
return
}
// A name a search cannot be attributed to costs the column and nothing else: the
// queries are the page, and an operator reading it after a household member's last
// session expired must still see what was searched for.
if users, err := s.store.KnownUsers(ctx); err == nil {
recent = nameSearchEvents(recent, users)
} else {
s.loggerFor(ctx).Warn("search history names unresolved", "error", err)
}
writeJSON(w, http.StatusOK, adminSearchesResponse{
Days: days,
Retention: searchWindowDays,
Totals: totals,
Terms: terms,
Recent: recent,
TermLimit: searchTermLimit,
EventLimit: searchEventLimit,
})
}
// nameSearchEvents fills in who made each search.
//
// Resolved here rather than joined in SQL because the log is capped and the household is
// small: one read of sessions serves a whole page, where a join would repeat the lookup
// per row. An id with no session left is returned unnamed rather than dropped — the
// console prints the id, which still distinguishes one searcher from another.
func nameSearchEvents(events []store.SearchEvent, users []store.KnownUser) []store.SearchEvent {
names := make(map[string]string, len(users))
for _, user := range users {
if user.Username != "" {
names[user.ID] = user.Username
}
}
for i := range events {
events[i].Username = names[events[i].UserID]
}
return events
}