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
+202
View File
@@ -0,0 +1,202 @@
package api
import (
"context"
"encoding/csv"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/alchemistkay/guestguard/internal/calendar"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// analyticsHandler powers GET /events/{id}/analytics and the matching
// CSV export. Reads through a Redis 60s cache so a heavy dashboard refresh
// doesn't hammer Postgres with the same six aggregations every second.
type analyticsHandler struct {
logger *slog.Logger
events *storage.EventRepo
collabs *storage.CollaboratorRepo
repo *storage.AnalyticsRepo
redis *redis.Client
ttl time.Duration
}
// analyticsPayload is the wire shape of GET /events/{id}/analytics. Every
// field is non-nullable in the JSON so the frontend can drop "no data"
// fallbacks where the slice would just be empty.
type analyticsPayload struct {
Overview storage.AnalyticsOverview `json:"overview"`
ResponseRate []storage.ResponseRatePoint `json:"response_rate"`
Funnel storage.FunnelCounts `json:"funnel"`
TimeToRespond []storage.TimeToRespondBucket `json:"time_to_respond"`
PlusOnes []storage.PlusOnesBucket `json:"plus_ones"`
Channels []storage.ChannelAttributionRow `json:"channels"`
StaleGuests []storage.StaleGuest `json:"stale_guests"`
GeneratedAt time.Time `json:"generated_at"`
}
// GET /events/{id}/analytics?days=30 — viewer+. Cached for 60s in Redis
// keyed by (event_id, days). The host's tab re-fetches every dashboard
// refresh; without the cache that's six aggregations per visit.
func (h *analyticsHandler) get(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
days, _ := strconv.Atoi(r.URL.Query().Get("days"))
if days <= 0 || days > 365 {
days = 30
}
cacheKey := "gg:analytics:" + eventID.String() + ":" + strconv.Itoa(days)
if h.redis != nil {
if cached, err := h.redis.Get(r.Context(), cacheKey).Bytes(); err == nil && len(cached) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Cache", "hit")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(cached)
return
}
}
payload, err := h.build(r.Context(), eventID, days)
if err != nil {
h.logger.Error("build analytics", "err", err, "event_id", eventID)
writeError(w, http.StatusInternalServerError, "failed to compute analytics")
return
}
body, _ := json.Marshal(payload)
if h.redis != nil {
// Best-effort: don't fail the request if the cache write trips on
// connection issues. Stale reads through the cache are fine since
// the dashboard refreshes within seconds anyway.
_ = h.redis.Set(r.Context(), cacheKey, body, h.cacheTTL()).Err()
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Cache", "miss")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func (h *analyticsHandler) build(ctx context.Context, eventID uuid.UUID, days int) (analyticsPayload, error) {
overview, err := h.repo.Overview(ctx, eventID)
if err != nil {
return analyticsPayload{}, err
}
rate, err := h.repo.ResponseRate(ctx, eventID, days)
if err != nil {
return analyticsPayload{}, err
}
funnel, err := h.repo.Funnel(ctx, eventID)
if err != nil {
return analyticsPayload{}, err
}
ttr, err := h.repo.TimeToRespond(ctx, eventID)
if err != nil {
return analyticsPayload{}, err
}
pones, err := h.repo.PlusOnesDistribution(ctx, eventID)
if err != nil {
return analyticsPayload{}, err
}
channels, err := h.repo.ChannelAttribution(ctx, eventID)
if err != nil {
return analyticsPayload{}, err
}
stale, err := h.repo.StaleGuests(ctx, eventID, 50)
if err != nil {
return analyticsPayload{}, err
}
return analyticsPayload{
Overview: overview,
ResponseRate: rate,
Funnel: funnel,
TimeToRespond: ttr,
PlusOnes: pones,
Channels: channels,
StaleGuests: stale,
GeneratedAt: time.Now().UTC(),
}, nil
}
func (h *analyticsHandler) cacheTTL() time.Duration {
if h.ttl > 0 {
return h.ttl
}
return 60 * time.Second
}
// GET /events/{id}/analytics/export.csv — viewer+. A flat per-guest table:
// one row per guest with their RSVP + access summary. Imports cleanly into
// Excel + Numbers (the plan's must-haves).
func (h *analyticsHandler) exportCSV(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer)
if !ok {
return
}
rows, err := h.repo.ExportAll(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to build export")
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
// Reuse the calendar package's filename slugifier — it does exactly
// what we need and keeps the surface tiny.
filename := calendar.FileName(event.Name + " analytics")
// .ics → .csv
filename = filename[:len(filename)-len(".ics")] + ".csv"
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.WriteHeader(http.StatusOK)
wr := csv.NewWriter(w)
defer wr.Flush()
_ = wr.Write([]string{
"name", "email", "phone",
"plus_ones_allowed", "response", "plus_ones_confirmed",
"submitted_at", "invited_at", "opened_at",
"utm_source",
})
for _, x := range rows {
_ = wr.Write([]string{
x.Name, x.Email, x.Phone,
strconv.Itoa(x.PlusOnesAllowed), x.Response, strconv.Itoa(x.PlusOnesConfirmed),
fmtTimePtr(x.SubmittedAt), fmtTimePtr(x.InvitedAt), fmtTimePtr(x.OpenedAt),
x.UTMSource,
})
}
}
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
+15
View File
@@ -37,6 +37,7 @@ type Server struct {
stripeWH *stripeWebhookHandler
privacy *privacyHandler
collabs *collaboratorHandler
analytics *analyticsHandler
}
type ServerDeps struct {
@@ -87,6 +88,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
userRepo := storage.NewUserRepo(deps.DB)
collabRepo := storage.NewCollaboratorRepo(deps.DB)
inviteRepo := storage.NewInviteRepo(deps.DB)
analyticsRepo := storage.NewAnalyticsRepo(deps.DB)
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
resetRepo := storage.NewPasswordResetRepo(deps.DB)
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
@@ -216,6 +218,13 @@ func NewServer(deps ServerDeps) (*Server, error) {
stripe: deps.StripeClient,
subs: subRepo,
},
analytics: &analyticsHandler{
logger: deps.Logger,
events: eventRepo,
collabs: collabRepo,
repo: analyticsRepo,
redis: deps.Redis,
},
collabs: &collaboratorHandler{
logger: deps.Logger,
events: eventRepo,
@@ -307,6 +316,12 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
// Block E — host analytics. Viewer+ on both endpoints; the Redis
// cache absorbs the dashboard's repeated visits.
mux.Handle("GET /events/{id}/analytics", authed(http.HandlerFunc(s.analytics.get)))
mux.Handle("GET /events/{id}/analytics/export.csv",
authed(http.HandlerFunc(s.analytics.exportCSV)))
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
// requireRole inside each handler enforces the right minimum role.
mux.Handle("GET /events/{id}/collaborators",
+365
View File
@@ -0,0 +1,365 @@
package storage
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// AnalyticsRepo runs the read-only aggregation queries that power the host's
// Analytics tab. All queries are event-scoped — there is no cross-tenant
// surface here. The handler runs requireRole(Viewer) before calling any of
// these. Results land behind a 60-second Redis cache in the API layer.
type AnalyticsRepo struct {
pool *pgxpool.Pool
}
func NewAnalyticsRepo(db *DB) *AnalyticsRepo {
return &AnalyticsRepo{pool: db.Pool}
}
// AnalyticsOverview is the headline counter row at the top of the tab.
// Pending = guests with no RSVP submitted yet.
type AnalyticsOverview struct {
Invited int `json:"invited"`
Attending int `json:"attending"`
Declined int `json:"declined"`
Maybe int `json:"maybe"`
Pending int `json:"pending"`
// PlusOnesTotal sums the plus_ones declared on every "attending" RSVP.
// Hosts care about *headcount* not response count; this surfaces it.
PlusOnesTotal int `json:"plus_ones_total"`
}
func (r *AnalyticsRepo) Overview(ctx context.Context, eventID uuid.UUID) (AnalyticsOverview, error) {
var o AnalyticsOverview
err := r.pool.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE TRUE) AS invited,
count(*) FILTER (WHERE r.response = 'attending') AS attending,
count(*) FILTER (WHERE r.response = 'declined') AS declined,
count(*) FILTER (WHERE r.response = 'maybe') AS maybe,
count(*) FILTER (WHERE r.id IS NULL) AS pending,
COALESCE(sum(r.plus_ones) FILTER (WHERE r.response = 'attending'), 0) AS plus_ones_total
FROM guests g
LEFT JOIN rsvps r ON r.guest_id = g.id
WHERE g.event_id = $1
`, eventID).Scan(&o.Invited, &o.Attending, &o.Declined, &o.Maybe, &o.Pending, &o.PlusOnesTotal)
return o, err
}
// ResponseRatePoint is one daily bucket of "how many responses came in on
// this day". Used for the line chart that hosts watch tick up.
type ResponseRatePoint struct {
Date time.Time `json:"date"`
Count int `json:"count"`
}
// ResponseRate returns a daily count of RSVPs submitted for the event over
// the last `days` days, oldest first. Days with zero responses still appear
// in the series — gaps in the chart imply broken data, not a quiet day.
func (r *AnalyticsRepo) ResponseRate(ctx context.Context, eventID uuid.UUID, days int) ([]ResponseRatePoint, error) {
if days <= 0 || days > 365 {
days = 30
}
rows, err := r.pool.Query(ctx, `
WITH days AS (
SELECT date_trunc('day', now()::timestamptz) - (n || ' days')::interval AS bucket
FROM generate_series(0, $2 - 1) AS n
)
SELECT d.bucket::date AS bucket_date,
COALESCE(count(r.id), 0) AS responses
FROM days d
LEFT JOIN rsvps r ON date_trunc('day', r.submitted_at) = d.bucket
AND r.guest_id IN (SELECT id FROM guests WHERE event_id = $1)
GROUP BY d.bucket
ORDER BY d.bucket ASC
`, eventID, days)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ResponseRatePoint, 0, days)
for rows.Next() {
var p ResponseRatePoint
if err := rows.Scan(&p.Date, &p.Count); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// FunnelCounts captures the invited → opened → responded conversion.
// "Opened" is "at least one row in access_logs", not just "delivered" —
// delivery success is a separate signal handled by the notifications
// dashboard.
type FunnelCounts struct {
Invited int `json:"invited"`
Opened int `json:"opened"`
Responded int `json:"responded"`
}
func (r *AnalyticsRepo) Funnel(ctx context.Context, eventID uuid.UUID) (FunnelCounts, error) {
var f FunnelCounts
err := r.pool.QueryRow(ctx, `
SELECT
count(DISTINCT g.id) FILTER (WHERE TRUE) AS invited,
count(DISTINCT g.id) FILTER (WHERE a.id IS NOT NULL) AS opened,
count(DISTINCT g.id) FILTER (WHERE r.id IS NOT NULL) AS responded
FROM guests g
LEFT JOIN access_logs a ON a.guest_id = g.id
LEFT JOIN rsvps r ON r.guest_id = g.id
WHERE g.event_id = $1
`, eventID).Scan(&f.Invited, &f.Opened, &f.Responded)
return f, err
}
// TimeToRespondBucket lumps RSVP latency into rough buckets so the host
// sees the shape of "how fast do my guests reply" without the noise of
// raw seconds.
type TimeToRespondBucket struct {
Label string `json:"label"` // "0-1h", "1-24h", "1-3d", "3-7d", "7d+"
Count int `json:"count"`
}
// TimeToRespond returns the histogram in the canonical bucket order so
// the frontend doesn't have to sort it. Buckets with zero responses still
// appear so the bar chart maintains its full width.
func (r *AnalyticsRepo) TimeToRespond(ctx context.Context, eventID uuid.UUID) ([]TimeToRespondBucket, error) {
// We approximate "time to respond" as time between token creation and
// RSVP submission. Without a separate "invitation sent" timestamp this
// is the closest signal we have; bulk-send timestamps would be more
// accurate but require Block F's scheduled_messages table.
rows, err := r.pool.Query(ctx, `
SELECT
CASE
WHEN EXTRACT(EPOCH FROM (r.submitted_at - t.created_at)) <= 3600 THEN '0-1h'
WHEN EXTRACT(EPOCH FROM (r.submitted_at - t.created_at)) <= 86400 THEN '1-24h'
WHEN EXTRACT(EPOCH FROM (r.submitted_at - t.created_at)) <= 86400 * 3 THEN '1-3d'
WHEN EXTRACT(EPOCH FROM (r.submitted_at - t.created_at)) <= 86400 * 7 THEN '3-7d'
ELSE '7d+'
END AS bucket,
count(*)::int
FROM rsvps r
JOIN guests g ON g.id = r.guest_id
JOIN tokens t ON t.guest_id = g.id
WHERE g.event_id = $1
AND r.submitted_at >= t.created_at
GROUP BY 1
`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
// Seed the canonical order so missing buckets render as zero.
order := []string{"0-1h", "1-24h", "1-3d", "3-7d", "7d+"}
counts := map[string]int{}
for rows.Next() {
var b string
var c int
if err := rows.Scan(&b, &c); err != nil {
return nil, err
}
counts[b] = c
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]TimeToRespondBucket, 0, len(order))
for _, label := range order {
out = append(out, TimeToRespondBucket{Label: label, Count: counts[label]})
}
return out, nil
}
// PlusOnesBucket is one bar of the plus-ones distribution histogram.
// Buckets: 0, 1, 2, 3+ — combined so the long tail doesn't squash the
// useful detail.
type PlusOnesBucket struct {
Label string `json:"label"`
Count int `json:"count"`
}
func (r *AnalyticsRepo) PlusOnesDistribution(ctx context.Context, eventID uuid.UUID) ([]PlusOnesBucket, error) {
rows, err := r.pool.Query(ctx, `
SELECT
CASE WHEN r.plus_ones >= 3 THEN '3+' ELSE r.plus_ones::text END AS bucket,
count(*)::int
FROM rsvps r
JOIN guests g ON g.id = r.guest_id
WHERE g.event_id = $1
AND r.response = 'attending'
GROUP BY 1
`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
order := []string{"0", "1", "2", "3+"}
counts := map[string]int{}
for rows.Next() {
var b string
var c int
if err := rows.Scan(&b, &c); err != nil {
return nil, err
}
counts[b] = c
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]PlusOnesBucket, 0, len(order))
for _, label := range order {
out = append(out, PlusOnesBucket{Label: label, Count: counts[label]})
}
return out, nil
}
// ChannelAttributionRow is one bar of the source-attribution chart, e.g.
// "whatsapp · 47 attending / 53 invited".
type ChannelAttributionRow struct {
Source string `json:"source"`
Invited int `json:"invited"`
Attending int `json:"attending"`
}
// ChannelAttribution groups responses by `tokens.utm_source`. Tokens
// without a source (legacy / unattributed) are returned under "(none)" so
// the host can see what slice is unlabelled.
func (r *AnalyticsRepo) ChannelAttribution(ctx context.Context, eventID uuid.UUID) ([]ChannelAttributionRow, error) {
rows, err := r.pool.Query(ctx, `
SELECT
COALESCE(NULLIF(t.utm_source, ''), '(none)') AS source,
count(DISTINCT g.id)::int AS invited,
count(DISTINCT g.id) FILTER (WHERE r.response = 'attending')::int AS attending
FROM guests g
JOIN tokens t ON t.guest_id = g.id
LEFT JOIN rsvps r ON r.guest_id = g.id
WHERE g.event_id = $1
GROUP BY 1
ORDER BY invited DESC
`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ChannelAttributionRow{}
for rows.Next() {
var c ChannelAttributionRow
if err := rows.Scan(&c.Source, &c.Invited, &c.Attending); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// StaleGuest is one row of the "hasn't opened their invitation" table. The
// host can chase these up with a manual SMS or the broadcast feature once
// Block F lands.
type StaleGuest struct {
GuestID uuid.UUID `json:"guest_id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
InvitedAt time.Time `json:"invited_at"`
HasOpened bool `json:"has_opened"`
HasResponded bool `json:"has_responded"`
}
// StaleGuests returns guests on this event who haven't responded yet,
// oldest invitation first. Bounded by `limit` (default 50) since this
// powers a UI list, not a full export.
func (r *AnalyticsRepo) StaleGuests(ctx context.Context, eventID uuid.UUID, limit int) ([]StaleGuest, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := r.pool.Query(ctx, `
SELECT
g.id, g.name, g.email, t.created_at,
EXISTS(SELECT 1 FROM access_logs a WHERE a.guest_id = g.id) AS opened,
EXISTS(SELECT 1 FROM rsvps r WHERE r.guest_id = g.id) AS responded
FROM guests g
JOIN tokens t ON t.guest_id = g.id
WHERE g.event_id = $1
AND NOT EXISTS (SELECT 1 FROM rsvps r WHERE r.guest_id = g.id)
ORDER BY t.created_at ASC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []StaleGuest{}
for rows.Next() {
var s StaleGuest
if err := rows.Scan(&s.GuestID, &s.Name, &s.Email, &s.InvitedAt, &s.HasOpened, &s.HasResponded); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ExportRow is one row of the analytics CSV export — flatter than the
// per-table JSON so spreadsheets can pivot on it directly.
type ExportRow struct {
Name string
Email string
Phone string
PlusOnesAllowed int
Response string
PlusOnesConfirmed int
SubmittedAt *time.Time
InvitedAt *time.Time
OpenedAt *time.Time
UTMSource string
}
// ExportAll returns one row per guest with their RSVP + access summary.
// Used to back the /analytics/export.csv endpoint.
func (r *AnalyticsRepo) ExportAll(ctx context.Context, eventID uuid.UUID) ([]ExportRow, error) {
rows, err := r.pool.Query(ctx, `
SELECT
g.name,
COALESCE(g.email, '') AS email,
COALESCE(g.phone, '') AS phone,
g.plus_ones AS plus_ones_allowed,
COALESCE(r.response::text, '') AS response,
COALESCE(r.plus_ones, 0) AS plus_ones_confirmed,
r.submitted_at,
t.created_at AS invited_at,
(SELECT min(a.created_at) FROM access_logs a WHERE a.guest_id = g.id) AS opened_at,
COALESCE(t.utm_source, '') AS utm_source
FROM guests g
LEFT JOIN rsvps r ON r.guest_id = g.id
LEFT JOIN tokens t ON t.guest_id = g.id
WHERE g.event_id = $1
ORDER BY g.created_at ASC
`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ExportRow{}
for rows.Next() {
var x ExportRow
var invitedAt *time.Time
if err := rows.Scan(
&x.Name, &x.Email, &x.Phone, &x.PlusOnesAllowed,
&x.Response, &x.PlusOnesConfirmed, &x.SubmittedAt,
&invitedAt, &x.OpenedAt, &x.UTMSource,
); err != nil {
return nil, err
}
x.InvitedAt = invitedAt
out = append(out, x)
}
return out, rows.Err()
}
@@ -0,0 +1 @@
ALTER TABLE tokens DROP COLUMN IF EXISTS utm_source;
@@ -0,0 +1,9 @@
-- Tier 2 Block E — host analytics.
--
-- The only schema change for analytics is opt-in source attribution on
-- tokens. Counts, funnels, and histograms are all derived from existing
-- tables (events / guests / tokens / rsvps / access_logs) via aggregation
-- queries with a 60-second Redis cache in front.
ALTER TABLE tokens
ADD COLUMN IF NOT EXISTS utm_source TEXT;