From 9842bd4f45f17565cc23da78027ec8da0e2131a5 Mon Sep 17 00:00:00 2001 From: Kwaku Danso <72142185+cloud-dev101@users.noreply.github.com> Date: Sun, 17 May 2026 23:11:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(tier2):=20host=20analytics=20=E2=80=94=20B?= =?UTF-8?q?lock=20E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/AnalyticsCard.vue | 261 +++++++++++++ frontend/pages/dashboard/events/[id].vue | 5 + internal/api/analytics.go | 202 ++++++++++ internal/api/server.go | 15 + internal/storage/analytics.go | 365 ++++++++++++++++++ .../migrations/0009_analytics.down.sql | 1 + .../storage/migrations/0009_analytics.up.sql | 9 + test/integration/analytics_test.go | 292 ++++++++++++++ 8 files changed, 1150 insertions(+) create mode 100644 frontend/components/AnalyticsCard.vue create mode 100644 internal/api/analytics.go create mode 100644 internal/storage/analytics.go create mode 100644 internal/storage/migrations/0009_analytics.down.sql create mode 100644 internal/storage/migrations/0009_analytics.up.sql create mode 100644 test/integration/analytics_test.go 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 @@ + + + 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 +}