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