101 lines
3.7 KiB
Go
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
|
||
|
|
}
|