feat(tier2): host analytics — Block E
GET /events/{id}/analytics renders a viewer+ dashboard summary:
overview tiles, 30-day response sparkline, invited→opened→responded
funnel, time-to-respond histogram, plus-ones distribution, channel
attribution (utm_source), and stale-guest follow-up list. A
matching /analytics/export.csv hand-offs a flat per-guest table for
Excel + Numbers.
Backend
- Migration 0009 adds tokens.utm_source for source attribution
- AnalyticsRepo with six aggregation queries — all event-scoped, all
returning canonical-ordered series so empty buckets still render
- Redis 60s cache in the handler, keyed by (event_id, days). Cache
miss path returns X-Cache: miss; hit returns the JSON straight
from Redis without re-querying Postgres
- Time-to-respond uses (rsvp.submitted_at - token.created_at) as
the latency signal (no separate "invitation sent" timestamp yet —
Block F will add one)
Frontend
- AnalyticsCard.vue: inline SVG sparkline + Tailwind bar charts.
No chart.js dependency; the bundle stays lean
- Stale-guests list with opened-but-no-response highlighted
- Export CSV button issues an authed fetch + blob download
Tests
- TestAnalyticsAggregations seeds 5 guests with a known mix and
asserts every count (overview/funnel/plus-ones/time-to-respond/
stale) matches expected
- TestAnalyticsCSVExport: header row + per-guest rows parse cleanly
- TestAnalyticsAuthzMatrix: viewer 200, outsider 404 on both endpoints
- Full integration suite passes (109.9s)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user