feat(tier2): host analytics — Block E

GET /events/{id}/analytics renders a viewer+ dashboard summary:
overview tiles, 30-day response sparkline, invited→opened→responded
funnel, time-to-respond histogram, plus-ones distribution, channel
attribution (utm_source), and stale-guest follow-up list. A
matching /analytics/export.csv hand-offs a flat per-guest table for
Excel + Numbers.

Backend
- Migration 0009 adds tokens.utm_source for source attribution
- AnalyticsRepo with six aggregation queries — all event-scoped, all
  returning canonical-ordered series so empty buckets still render
- Redis 60s cache in the handler, keyed by (event_id, days). Cache
  miss path returns X-Cache: miss; hit returns the JSON straight
  from Redis without re-querying Postgres
- Time-to-respond uses (rsvp.submitted_at - token.created_at) as
  the latency signal (no separate "invitation sent" timestamp yet —
  Block F will add one)

Frontend
- AnalyticsCard.vue: inline SVG sparkline + Tailwind bar charts.
  No chart.js dependency; the bundle stays lean
- Stale-guests list with opened-but-no-response highlighted
- Export CSV button issues an authed fetch + blob download

Tests
- TestAnalyticsAggregations seeds 5 guests with a known mix and
  asserts every count (overview/funnel/plus-ones/time-to-respond/
  stale) matches expected
- TestAnalyticsCSVExport: header row + per-guest rows parse cleanly
- TestAnalyticsAuthzMatrix: viewer 200, outsider 404 on both endpoints
- Full integration suite passes (109.9s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 23:11:13 +01:00
parent 3973e4058d
commit 9842bd4f45
8 changed files with 1150 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
//go:build integration
package integration_test
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Tier 2 Block E — host analytics.
type analyticsBody struct {
Overview struct {
Invited int `json:"invited"`
Attending int `json:"attending"`
Declined int `json:"declined"`
Maybe int `json:"maybe"`
Pending int `json:"pending"`
PlusOnesTotal int `json:"plus_ones_total"`
} `json:"overview"`
ResponseRate []struct {
Date time.Time `json:"date"`
Count int `json:"count"`
} `json:"response_rate"`
Funnel struct {
Invited int `json:"invited"`
Opened int `json:"opened"`
Responded int `json:"responded"`
} `json:"funnel"`
TimeToRespond []struct {
Label string `json:"label"`
Count int `json:"count"`
} `json:"time_to_respond"`
PlusOnes []struct {
Label string `json:"label"`
Count int `json:"count"`
} `json:"plus_ones"`
Channels []struct {
Source string `json:"source"`
Invited int `json:"invited"`
Attending int `json:"attending"`
} `json:"channels"`
StaleGuests []struct {
GuestID uuid.UUID `json:"guest_id"`
Name string `json:"name"`
HasOpened bool `json:"has_opened"`
HasResponded bool `json:"has_responded"`
} `json:"stale_guests"`
}
// TestAnalyticsAggregations seeds a synthetic event with a known mix of
// guests + RSVPs + access logs, then asserts every aggregation matches the
// expected counts. This is the regression net for the SQL — if a JOIN
// pivots wrong the numbers will move.
func TestAnalyticsAggregations(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Analytics Test", "analytics-test")
type seedRow struct {
name string
plusOnes int
response string
confirmed int
opened bool
}
seeds := []seedRow{
{name: "Alice (attending+2)", plusOnes: 3, response: "attending", confirmed: 2, opened: true},
{name: "Bob (attending+0)", plusOnes: 1, response: "attending", confirmed: 0, opened: true},
{name: "Carol (declined)", plusOnes: 0, response: "declined", confirmed: 0, opened: true},
{name: "Dave (maybe)", plusOnes: 1, response: "maybe", confirmed: 0, opened: true},
{name: "Eve (pending)", plusOnes: 0, response: "", confirmed: 0, opened: false},
}
for _, s := range seeds {
seedAnalyticsGuest(t, ctx, db.Pool, eventID, s.name, s.plusOnes, s.response, s.confirmed, s.opened)
}
var body analyticsBody
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/analytics", srv.URL, eventID),
token, http.StatusOK, &body)
if body.Overview.Invited != 5 {
t.Errorf("invited: got %d want 5", body.Overview.Invited)
}
if body.Overview.Attending != 2 {
t.Errorf("attending: got %d want 2", body.Overview.Attending)
}
if body.Overview.Declined != 1 {
t.Errorf("declined: got %d want 1", body.Overview.Declined)
}
if body.Overview.Maybe != 1 {
t.Errorf("maybe: got %d want 1", body.Overview.Maybe)
}
if body.Overview.Pending != 1 {
t.Errorf("pending: got %d want 1", body.Overview.Pending)
}
if body.Overview.PlusOnesTotal != 2 {
t.Errorf("plus_ones_total: got %d want 2", body.Overview.PlusOnesTotal)
}
if body.Funnel.Invited != 5 || body.Funnel.Opened != 4 || body.Funnel.Responded != 4 {
t.Errorf("funnel: got %+v want {5,4,4}", body.Funnel)
}
if len(body.StaleGuests) != 1 || body.StaleGuests[0].Name != "Eve (pending)" {
t.Errorf("stale guests: got %+v want [Eve]", body.StaleGuests)
}
if body.StaleGuests[0].HasOpened {
t.Error("Eve should not be marked opened")
}
pones := map[string]int{}
for _, p := range body.PlusOnes {
pones[p.Label] = p.Count
}
if pones["0"] != 1 || pones["2"] != 1 {
t.Errorf("plus_ones: got %+v want {0:1, 2:1, ...}", pones)
}
ttr := map[string]int{}
for _, b := range body.TimeToRespond {
ttr[b.Label] = b.Count
}
if ttr["0-1h"] != 4 {
t.Errorf("time_to_respond[0-1h]: got %d want 4 (%+v)", ttr["0-1h"], ttr)
}
for _, want := range []string{"0-1h", "1-24h", "1-3d", "3-7d", "7d+"} {
if _, ok := ttr[want]; !ok {
t.Errorf("time_to_respond missing canonical bucket %q", want)
}
}
if len(body.ResponseRate) != 30 {
t.Errorf("response_rate length: got %d want 30", len(body.ResponseRate))
}
var total int
for _, p := range body.ResponseRate {
total += p.Count
}
if total != 4 {
t.Errorf("response_rate total: got %d want 4", total)
}
}
// TestAnalyticsCSVExport asserts the endpoint serves a parseable CSV with
// the right columns and one row per guest.
func TestAnalyticsCSVExport(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "CSV Export", "csv-export")
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Alice", 2, "attending", 1, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Bob", 0, "", 0, false)
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/events/%s/analytics/export.csv", srv.URL, eventID), nil)
must(t, err, "build req")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "do req")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("status: %d body=%s", resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
t.Errorf("content-type: %q", ct)
}
if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "filename=") {
t.Errorf("content-disposition: %q", cd)
}
r := csv.NewReader(resp.Body)
rows, err := r.ReadAll()
must(t, err, "parse csv")
if len(rows) != 3 { // header + 2 guests
t.Fatalf("expected 3 rows (header + 2 guests), got %d", len(rows))
}
header := rows[0]
for _, want := range []string{"name", "email", "response", "plus_ones_confirmed", "submitted_at", "utm_source"} {
if !containsString(header, want) {
t.Errorf("header missing column %q in %v", want, header)
}
}
}
// TestAnalyticsAuthzMatrix confirms viewers can read analytics (it's a
// read-only feature) and non-members get 404.
func TestAnalyticsAuthzMatrix(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Authz Analytics", "authz-analytics")
viewer, viewerToken := makeAuthedUser(t, ctx, db.Pool)
directlyInsertCollaborator(t, ctx, db.Pool, eventID, viewer, "viewer", uuid.UUID(ownerID))
// Viewer can read analytics.
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/analytics", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/analytics/export.csv", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
// Outsider: 404 on both.
_, outsiderToken := makeAuthedUser(t, ctx, db.Pool)
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/analytics", srv.URL, eventID),
outsiderToken, nil, http.StatusNotFound)
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/analytics/export.csv", srv.URL, eventID),
outsiderToken, nil, http.StatusNotFound)
}
// --- helpers ---
// seedAnalyticsGuest inserts a guest + token (with utm_source=test) and
// optionally an access_log entry + an rsvp row. Bypasses the HTTP API
// because the API would require token-based fraud scoring which is out of
// scope for these aggregation tests.
func seedAnalyticsGuest(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
eventID uuid.UUID,
name string,
plusOnesAllowed int,
response string,
plusOnesConfirmed int,
opened bool,
) {
t.Helper()
var guestID uuid.UUID
must(t, pool.QueryRow(ctx, `
INSERT INTO guests (event_id, name, plus_ones)
VALUES ($1, $2, $3) RETURNING id
`, eventID, name, plusOnesAllowed).Scan(&guestID), "insert guest")
tokenHash := fmt.Sprintf("hash-%s-%d", guestID.String(), time.Now().UnixNano())
_, err := pool.Exec(ctx, `
INSERT INTO tokens (guest_id, token_hash, expires_at, utm_source)
VALUES ($1, $2, now() + interval '30 days', 'test')
`, guestID, tokenHash)
must(t, err, "insert token")
if opened {
_, err = pool.Exec(ctx, `
INSERT INTO access_logs (guest_id) VALUES ($1)
`, guestID)
must(t, err, "insert access_log")
}
if response != "" {
_, err = pool.Exec(ctx, `
INSERT INTO rsvps (guest_id, response, plus_ones)
VALUES ($1, $2::rsvp_response, $3)
`, guestID, response, plusOnesConfirmed)
must(t, err, "insert rsvp")
}
}
func containsString(slice []string, want string) bool {
for _, s := range slice {
if s == want {
return true
}
}
return false
}