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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user