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() }