@@ -2,6 +2,7 @@ package server
22
33import (
44 "context"
5+ "encoding/json"
56 "html/template"
67 "log/slog"
78 "net/http"
@@ -27,43 +28,66 @@ var (
2728)
2829
2930// userProfile is the chat-console popover payload — a small at-a-glance view of
30- // a chatter, events-derived per the tripbot-events-table-design ADR.
31+ // a chatter, events-derived per the tripbot-events-table-design ADR. The JSON
32+ // tags are the wire format the standalone tripbot-console reads via
33+ // GET /api/user/{username} (it has no DB access of its own and proxies here).
3134type userProfile struct {
32- Username string
33- Found bool
34- IsBot bool
35- Miles float32 // lifetime
36- MonthlyMiles float32 // current month
37- Sessions int64
38- FirstSeen time.Time
39- LastSeen time.Time
35+ Username string `json:"username"`
36+ Found bool `json:"found"`
37+ IsBot bool `json:"is_bot"`
38+ Miles float32 `json:"miles"` // lifetime
39+ MonthlyMiles float32 `json:"monthly_miles"` // current month
40+ Sessions int64 `json:"sessions"`
41+ FirstSeen time.Time `json:"first_seen"`
42+ LastSeen time.Time `json:"last_seen"`
43+ }
44+
45+ // gatherUserProfile reads a chatter's at-a-glance stats through the DB seams.
46+ // Operator-triggered (one click), not a hot path, so the extra monthly-score
47+ // query is fine. An empty username or no matching row returns Found=false.
48+ func gatherUserProfile (ctx context.Context , username string ) userProfile {
49+ username = strings .ToLower (strings .TrimSpace (username ))
50+ prof := userProfile {Username : username }
51+ if username == "" {
52+ return prof
53+ }
54+ if u := findUser (ctx , username ); u .ID != 0 {
55+ prof .Found = true
56+ prof .IsBot = u .IsBot
57+ prof .Miles = u .Miles
58+ prof .MonthlyMiles = monthlyMiles (ctx , u )
59+ prof .FirstSeen = u .DateCreated
60+ prof .LastSeen = u .LastSeen
61+ prof .Sessions = sessionCount (ctx , username )
62+ }
63+ return prof
4064}
4165
4266// userProfileHandler serves GET /admin/user/{username}: the HTML fragment the
43- // live console pops over when an operator clicks a username. Timeout/ban
44- // actions are planned here (they need the broadcaster token's
67+ // in-tripbot live console pops over when an operator clicks a username.
68+ // Timeout/ban actions are planned here (they need the broadcaster token's
4569// moderator:manage:banned_users scope).
4670func userProfileHandler (w http.ResponseWriter , r * http.Request ) {
47- username := strings .ToLower (strings .TrimSpace (mux .Vars (r )["username" ]))
48- prof := userProfile {Username : username }
49- if username != "" {
50- if u := findUser (r .Context (), username ); u .ID != 0 {
51- prof .Found = true
52- prof .IsBot = u .IsBot
53- prof .Miles = u .Miles
54- prof .MonthlyMiles = monthlyMiles (r .Context (), u )
55- prof .FirstSeen = u .DateCreated
56- prof .LastSeen = u .LastSeen
57- prof .Sessions = sessionCount (r .Context (), username )
58- }
59- }
71+ prof := gatherUserProfile (r .Context (), mux .Vars (r )["username" ])
6072 w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
6173 w .Header ().Set ("Cache-Control" , "no-store" )
6274 if err := userProfileTmpl .Execute (w , prof ); err != nil {
6375 slog .ErrorContext (r .Context (), "couldn't render user profile" , "err" , err )
6476 }
6577}
6678
79+ // userProfileAPIHandler serves GET /api/user/{username}: the same data as
80+ // userProfileHandler but as JSON, for the standalone tripbot-console to render
81+ // its own popover (the console holds no DB access — it links over to here).
82+ func userProfileAPIHandler (w http.ResponseWriter , r * http.Request ) {
83+ prof := gatherUserProfile (r .Context (), mux .Vars (r )["username" ])
84+ w .Header ().Set ("Content-Type" , "application/json; charset=utf-8" )
85+ w .Header ().Set ("Cache-Control" , "no-store" )
86+ if err := json .NewEncoder (w ).Encode (prof ); err != nil {
87+ slog .ErrorContext (r .Context (), "couldn't encode user profile" , "err" , err )
88+ }
89+ }
90+
6791// userProfileTmpl renders the popover fragment. html/template escapes the
6892// username everywhere it appears (incl. the Twitch URL).
6993var userProfileTmpl = template .Must (template .New ("profile" ).Parse (`<div class="profile-card">
0 commit comments