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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user