diff --git a/frontend/components/AnalyticsCard.vue b/frontend/components/AnalyticsCard.vue
new file mode 100644
index 0000000..44f8c94
--- /dev/null
+++ b/frontend/components/AnalyticsCard.vue
@@ -0,0 +1,261 @@
+
+
+
+
+
+ Analytics
+
+
+
+ Loading analyticsβ¦
+ {{ error }}
+
+
+
+
+
+
Invited
+
{{ data.overview.invited }}
+
+
+
Attending
+
{{ data.overview.attending }}
+
+
+
Declined
+
{{ data.overview.declined }}
+
+
+
Maybe
+
{{ data.overview.maybe }}
+
+
+
Pending
+
{{ data.overview.pending }}
+
+
+
Plus-ones
+
+{{ data.overview.plus_ones_total }}
+
+
+
+
+
+
Responses, last 30 days
+
+
+
+
+
+
Funnel
+
+
+
Invited
+
+
{{ data.funnel.invited }}
+
+
+
Opened
+
+
{{ data.funnel.opened }}
+
+
+
Responded
+
+
{{ data.funnel.responded }}
+
+
+
+
+
+
+
+
Time to respond
+
+
+
{{ b.label }}
+
+
{{ b.count }}
+
+
+
+
+
+
Plus-ones (attending)
+
+
+
{{ b.label }}
+
+
{{ b.count }}
+
+
+
+
+
+
+
+
+ Haven't responded yet ({{ data.stale_guests.length }})
+
+
+ Everyone you've invited has replied. π
+
+
+ -
+
+
{{ g.name }}
+
+ {{ g.email || 'no email' }} Β· invited {{ fmtDate(g.invited_at) }}
+ opened
+
+
+
+
+
+ and {{ data.stale_guests.length - 10 }} more β export the CSV for the full list.
+
+
+
+
+
diff --git a/frontend/pages/dashboard/events/[id].vue b/frontend/pages/dashboard/events/[id].vue
index e145d97..61981e7 100644
--- a/frontend/pages/dashboard/events/[id].vue
+++ b/frontend/pages/dashboard/events/[id].vue
@@ -1120,6 +1120,11 @@ function checkLabel(band?: string): string {
+
+
+
diff --git a/internal/api/analytics.go b/internal/api/analytics.go
new file mode 100644
index 0000000..bac7268
--- /dev/null
+++ b/internal/api/analytics.go
@@ -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)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 611b665..9f0b759 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -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",
diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go
new file mode 100644
index 0000000..449edf8
--- /dev/null
+++ b/internal/storage/analytics.go
@@ -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()
+}
diff --git a/internal/storage/migrations/0009_analytics.down.sql b/internal/storage/migrations/0009_analytics.down.sql
new file mode 100644
index 0000000..3f1d2da
--- /dev/null
+++ b/internal/storage/migrations/0009_analytics.down.sql
@@ -0,0 +1 @@
+ALTER TABLE tokens DROP COLUMN IF EXISTS utm_source;
diff --git a/internal/storage/migrations/0009_analytics.up.sql b/internal/storage/migrations/0009_analytics.up.sql
new file mode 100644
index 0000000..e83645a
--- /dev/null
+++ b/internal/storage/migrations/0009_analytics.up.sql
@@ -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;
diff --git a/test/integration/analytics_test.go b/test/integration/analytics_test.go
new file mode 100644
index 0000000..6a597a4
--- /dev/null
+++ b/test/integration/analytics_test.go
@@ -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
+}