9842bd4f45
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>
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
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)
|
|
}
|