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 }