Skip to content

Commit 7003854

Browse files
dmerrickclaude
andauthored
feat: read-only JSON user-profile endpoint for tripbot-console (#837)
Add GET /api/user/{username} returning the same chatter stats as the in-tripbot panel's /admin/user popover (miles, monthly miles, sessions, first/last seen) as JSON. The standalone tripbot-console has no DB access by design and proxies here over the in-namespace Service to render its own chat-console popover. Factors the DB-backed gather out of userProfileHandler into gatherUserProfile, shared by the HTML and JSON handlers. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 061dbfa commit 7003854

3 files changed

Lines changed: 99 additions & 24 deletions

File tree

pkg/server/profile.go

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
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).
3134
type 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).
4670
func 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).
6993
var userProfileTmpl = template.Must(template.New("profile").Parse(`<div class="profile-card">

pkg/server/profile_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/http"
67
"net/http/httptest"
78
"strings"
@@ -72,6 +73,53 @@ func TestUserProfileHandler_NotFound(t *testing.T) {
7273
}
7374
}
7475

76+
func TestUserProfileAPIHandler_JSON(t *testing.T) {
77+
withProfileSeams(t, users.User{
78+
ID: 42,
79+
Username: "danalol",
80+
Miles: 123.0,
81+
DateCreated: time.Date(2019, 5, 1, 0, 0, 0, 0, time.UTC),
82+
LastSeen: time.Date(2026, 5, 29, 13, 5, 0, 0, time.UTC),
83+
}, 87, 42.0)
84+
85+
r := mux.NewRouter()
86+
r.Handle("/api/user/{username}", http.HandlerFunc(userProfileAPIHandler)).Methods("GET")
87+
rec := httptest.NewRecorder()
88+
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/user/danalol", nil))
89+
90+
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
91+
t.Errorf("content-type = %q, want application/json", ct)
92+
}
93+
var got userProfile
94+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
95+
t.Fatalf("decode: %v\n%s", err, rec.Body.String())
96+
}
97+
if !got.Found || got.Username != "danalol" || got.Miles != 123.0 ||
98+
got.MonthlyMiles != 42.0 || got.Sessions != 87 {
99+
t.Errorf("unexpected profile: %+v", got)
100+
}
101+
// snake_case wire format the console reads.
102+
if !strings.Contains(rec.Body.String(), `"monthly_miles"`) {
103+
t.Errorf("expected snake_case keys: %s", rec.Body.String())
104+
}
105+
}
106+
107+
func TestUserProfileAPIHandler_NotFound(t *testing.T) {
108+
withProfileSeams(t, users.User{ID: 0}, 0, 0)
109+
r := mux.NewRouter()
110+
r.Handle("/api/user/{username}", http.HandlerFunc(userProfileAPIHandler)).Methods("GET")
111+
rec := httptest.NewRecorder()
112+
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/user/ghost", nil))
113+
114+
var got userProfile
115+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
116+
t.Fatalf("decode: %v", err)
117+
}
118+
if got.Found || got.Username != "ghost" {
119+
t.Errorf("expected not-found ghost, got %+v", got)
120+
}
121+
}
122+
75123
func TestUserProfileHandler_BotBadge(t *testing.T) {
76124
withProfileSeams(t, users.User{ID: 7, Username: "tripbot4000", IsBot: true}, 3, 0)
77125
if body := renderProfile(t, "tripbot4000"); !strings.Contains(body, "profile-bot") {

pkg/server/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ func (s *Server) Start(ctx context.Context) {
114114
// + stream toggle so they stay current without a full reload.
115115
r.Handle("/admin/refresh", tagged("/admin/refresh", s.refreshHandler)).Methods("GET")
116116
r.Handle("/admin/user/{username}", tagged("/admin/user/{username}", userProfileHandler)).Methods("GET")
117+
// read-only JSON profile for the standalone tripbot-console's popover (it
118+
// has no DB access and proxies here over the in-namespace Service).
119+
r.Handle("/api/user/{username}", tagged("/api/user/{username}", userProfileAPIHandler)).Methods("GET")
117120
r.Handle("/admin/map/corpus", tagged("/admin/map/corpus", mapCorpusHandler)).Methods("GET")
118121
r.PathPrefix("/static/").Handler(staticHandler())
119122

0 commit comments

Comments
 (0)