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,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Tier 2 Block E — host analytics dashboard card. Shown on the event
|
||||||
|
// detail page; viewer+ role.
|
||||||
|
//
|
||||||
|
// Charts are inline SVG rather than chart.js — we avoid the ~70kB bundle
|
||||||
|
// hit for charts this small, and keep the page server-renderable. If the
|
||||||
|
// host's expectations grow (zoom, tooltips, animations) chart.js becomes
|
||||||
|
// the right call.
|
||||||
|
|
||||||
|
interface Overview {
|
||||||
|
invited: number
|
||||||
|
attending: number
|
||||||
|
declined: number
|
||||||
|
maybe: number
|
||||||
|
pending: number
|
||||||
|
plus_ones_total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsPayload {
|
||||||
|
overview: Overview
|
||||||
|
response_rate: { date: string; count: number }[]
|
||||||
|
funnel: { invited: number; opened: number; responded: number }
|
||||||
|
time_to_respond: { label: string; count: number }[]
|
||||||
|
plus_ones: { label: string; count: number }[]
|
||||||
|
channels: { source: string; invited: number; attending: number }[]
|
||||||
|
stale_guests: {
|
||||||
|
guest_id: string
|
||||||
|
name: string
|
||||||
|
email?: string | null
|
||||||
|
has_opened: boolean
|
||||||
|
invited_at: string
|
||||||
|
}[]
|
||||||
|
generated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ eventId: string }>()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
const data = ref<AnalyticsPayload | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
data.value = await useApi<AnalyticsPayload>(`/events/${props.eventId}/analytics?days=30`)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = useErrMessage(e, 'Could not load analytics')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
const responseRateMax = computed(() => {
|
||||||
|
const series = data.value?.response_rate ?? []
|
||||||
|
return Math.max(1, ...series.map((p) => p.count))
|
||||||
|
})
|
||||||
|
|
||||||
|
const plusOnesMax = computed(() => {
|
||||||
|
const series = data.value?.plus_ones ?? []
|
||||||
|
return Math.max(1, ...series.map((p) => p.count))
|
||||||
|
})
|
||||||
|
|
||||||
|
const ttrMax = computed(() => {
|
||||||
|
const series = data.value?.time_to_respond ?? []
|
||||||
|
return Math.max(1, ...series.map((p) => p.count))
|
||||||
|
})
|
||||||
|
|
||||||
|
const funnelPct = computed(() => {
|
||||||
|
const f = data.value?.funnel
|
||||||
|
if (!f || f.invited === 0) return { opened: 0, responded: 0 }
|
||||||
|
return {
|
||||||
|
opened: Math.round((f.opened / f.invited) * 100),
|
||||||
|
responded: Math.round((f.responded / f.invited) * 100),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseRateLineD = computed(() => {
|
||||||
|
const series = data.value?.response_rate ?? []
|
||||||
|
if (series.length === 0) return ''
|
||||||
|
const w = 600
|
||||||
|
const h = 80
|
||||||
|
const max = responseRateMax.value
|
||||||
|
const step = w / Math.max(1, series.length - 1)
|
||||||
|
return series
|
||||||
|
.map((p, i) => {
|
||||||
|
const x = i * step
|
||||||
|
const y = h - (p.count / max) * h
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
// Issue a fresh ws-ticket-style download. Browsers preserve the
|
||||||
|
// Authorization header on <a download> if we set it via fetch, so we
|
||||||
|
// use fetch + blob + anchor.
|
||||||
|
const url = `${config.public.apiBase}/events/${props.eventId}/analytics/export.csv`
|
||||||
|
fetch(url, {
|
||||||
|
headers: auth.liveAccessToken() ? { Authorization: `Bearer ${auth.liveAccessToken()}` } : {},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.blob()
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = 'analytics.csv'
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
error.value = 'Could not export: ' + String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso?: string | null) {
|
||||||
|
if (!iso) return ''
|
||||||
|
try { return new Date(iso).toLocaleDateString() } catch { return iso }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<header class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">Analytics</h2>
|
||||||
|
<button class="btn-ghost text-sm" :disabled="!data" @click="exportCSV">Export CSV</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-sm text-zinc-500">Loading analytics…</p>
|
||||||
|
<p v-else-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-else-if="data" class="space-y-6">
|
||||||
|
<!-- Overview tiles -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-zinc-500">Invited</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.invited }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-brand-900/60 bg-brand-950/20 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-brand-400">Attending</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums text-brand-200">{{ data.overview.attending }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-zinc-500">Declined</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.declined }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-zinc-500">Maybe</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.maybe }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-zinc-500">Pending</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.pending }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-zinc-500">Plus-ones</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">+{{ data.overview.plus_ones_total }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response rate over time -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Responses, last 30 days</p>
|
||||||
|
<svg viewBox="0 0 600 80" preserveAspectRatio="none" class="h-20 w-full">
|
||||||
|
<path :d="responseRateLineD" fill="none" stroke="#22c55e" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Funnel -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Funnel</p>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-24 shrink-0 text-xs text-zinc-400">Invited</span>
|
||||||
|
<div class="h-6 flex-1 overflow-hidden rounded bg-zinc-900">
|
||||||
|
<div class="h-full rounded bg-brand-500" :style="{ width: '100%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ data.funnel.invited }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-24 shrink-0 text-xs text-zinc-400">Opened</span>
|
||||||
|
<div class="h-6 flex-1 overflow-hidden rounded bg-zinc-900">
|
||||||
|
<div class="h-full rounded bg-brand-400" :style="{ width: funnelPct.opened + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ data.funnel.opened }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-24 shrink-0 text-xs text-zinc-400">Responded</span>
|
||||||
|
<div class="h-6 flex-1 overflow-hidden rounded bg-zinc-900">
|
||||||
|
<div class="h-full rounded bg-brand-300" :style="{ width: funnelPct.responded + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ data.funnel.responded }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time-to-respond + plus-ones side by side -->
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Time to respond</p>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div v-for="b in data.time_to_respond" :key="b.label" class="flex items-center gap-3">
|
||||||
|
<span class="w-12 shrink-0 text-xs text-zinc-400">{{ b.label }}</span>
|
||||||
|
<div class="h-4 flex-1 overflow-hidden rounded bg-zinc-900">
|
||||||
|
<div class="h-full rounded bg-brand-500" :style="{ width: ((b.count / ttrMax) * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-8 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ b.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Plus-ones (attending)</p>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div v-for="b in data.plus_ones" :key="b.label" class="flex items-center gap-3">
|
||||||
|
<span class="w-12 shrink-0 text-xs text-zinc-400">{{ b.label }}</span>
|
||||||
|
<div class="h-4 flex-1 overflow-hidden rounded bg-zinc-900">
|
||||||
|
<div class="h-full rounded bg-brand-500" :style="{ width: ((b.count / plusOnesMax) * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-8 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ b.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stale guests -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">
|
||||||
|
Haven't responded yet ({{ data.stale_guests.length }})
|
||||||
|
</p>
|
||||||
|
<p v-if="data.stale_guests.length === 0" class="text-sm text-zinc-500">
|
||||||
|
Everyone you've invited has replied. 🎉
|
||||||
|
</p>
|
||||||
|
<ul v-else class="space-y-1.5">
|
||||||
|
<li
|
||||||
|
v-for="g in data.stale_guests.slice(0, 10)"
|
||||||
|
:key="g.guest_id"
|
||||||
|
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate font-medium text-zinc-100">{{ g.name }}</div>
|
||||||
|
<div class="truncate text-xs text-zinc-500">
|
||||||
|
{{ g.email || 'no email' }} · invited {{ fmtDate(g.invited_at) }}
|
||||||
|
<span v-if="g.has_opened" class="ml-1 text-amber-400">opened</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="data.stale_guests.length > 10" class="mt-2 text-xs text-zinc-500">
|
||||||
|
and {{ data.stale_guests.length - 10 }} more — export the CSV for the full list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1120,6 +1120,11 @@ function checkLabel(band?: string): string {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics (Tier 2 Block E). Read-only summary; viewer+ can see it. -->
|
||||||
|
<div v-if="event" class="mt-6">
|
||||||
|
<AnalyticsCard :event-id="eventId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
|
<!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
|
||||||
buttons gated to owners. -->
|
buttons gated to owners. -->
|
||||||
<div v-if="event" class="mt-6">
|
<div v-if="event" class="mt-6">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ type Server struct {
|
|||||||
stripeWH *stripeWebhookHandler
|
stripeWH *stripeWebhookHandler
|
||||||
privacy *privacyHandler
|
privacy *privacyHandler
|
||||||
collabs *collaboratorHandler
|
collabs *collaboratorHandler
|
||||||
|
analytics *analyticsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerDeps struct {
|
type ServerDeps struct {
|
||||||
@@ -87,6 +88,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
userRepo := storage.NewUserRepo(deps.DB)
|
userRepo := storage.NewUserRepo(deps.DB)
|
||||||
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
||||||
inviteRepo := storage.NewInviteRepo(deps.DB)
|
inviteRepo := storage.NewInviteRepo(deps.DB)
|
||||||
|
analyticsRepo := storage.NewAnalyticsRepo(deps.DB)
|
||||||
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
||||||
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
||||||
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
||||||
@@ -216,6 +218,13 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
|||||||
stripe: deps.StripeClient,
|
stripe: deps.StripeClient,
|
||||||
subs: subRepo,
|
subs: subRepo,
|
||||||
},
|
},
|
||||||
|
analytics: &analyticsHandler{
|
||||||
|
logger: deps.Logger,
|
||||||
|
events: eventRepo,
|
||||||
|
collabs: collabRepo,
|
||||||
|
repo: analyticsRepo,
|
||||||
|
redis: deps.Redis,
|
||||||
|
},
|
||||||
collabs: &collaboratorHandler{
|
collabs: &collaboratorHandler{
|
||||||
logger: deps.Logger,
|
logger: deps.Logger,
|
||||||
events: eventRepo,
|
events: eventRepo,
|
||||||
@@ -307,6 +316,12 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
|
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.
|
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
|
||||||
// requireRole inside each handler enforces the right minimum role.
|
// requireRole inside each handler enforces the right minimum role.
|
||||||
mux.Handle("GET /events/{id}/collaborators",
|
mux.Handle("GET /events/{id}/collaborators",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user