diff --git a/features/admin/handlers.go b/features/admin/handlers.go index dc7e9e2..dede624 100644 --- a/features/admin/handlers.go +++ b/features/admin/handlers.go @@ -915,6 +915,140 @@ func writeJSON(w http.ResponseWriter, v any) { var _ = writeJSON // keep available for upcoming admin JSON endpoints +// FollowUpRow is a single row returned by AdminFollowUpsCSV. +type FollowUpRow struct { + DisplayName string + Email string + Events string // comma-separated event names + ConnectionNums int // number of connections +} + +// AdminFollowUps renders the follow-ups page — local users who opted in. +func (h *Handlers) AdminFollowUps(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT did, display_name, email + FROM users + WHERE user_type = 'local' AND follow_up = 1 AND email != '' + ORDER BY updated_at DESC + `) + if err != nil { + slog.Error("admin follow-ups: list", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var entries []pages.AdminFollowUpEntry + for rows.Next() { + var e pages.AdminFollowUpEntry + if err := rows.Scan(&e.DID, &e.DisplayName, &e.Email); err != nil { + slog.Error("admin follow-ups: scan", "err", err) + continue + } + entries = append(entries, e) + } + if err := rows.Err(); err != nil { + slog.Error("admin follow-ups: rows", "err", err) + } + + view := pages.AdminFollowUpsView{ + Entries: entries, + Count: len(entries), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminFollowUps(view).Render(r.Context(), w); err != nil { + slog.Error("render admin follow-ups", "err", err) + } +} + +// AdminFollowUpsCSV returns a CSV download of follow-up entries with their +// checked-in events and connection counts. +func (h *Handlers) AdminFollowUpsCSV(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT did, display_name, email + FROM users + WHERE user_type = 'local' AND follow_up = 1 AND email != '' + ORDER BY updated_at DESC + `) + if err != nil { + slog.Error("admin follow-ups csv: list", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var results []FollowUpRow + for rows.Next() { + var did, displayName, email string + if err := rows.Scan(&did, &displayName, &email); err != nil { + slog.Error("admin follow-ups csv: scan", "err", err) + continue + } + + // Get events this user checked into + eventRows, err := h.DB.QueryContext(r.Context(), ` + SELECT e.name FROM checkins c + JOIN events e ON e.uri = c.event_uri + WHERE c.did = ? + ORDER BY c.checked_in_at ASC + `, did) + var eventNames []string + if err == nil { + for eventRows.Next() { + var name string + if err := eventRows.Scan(&name); err == nil { + eventNames = append(eventNames, name) + } + } + eventRows.Close() + } + + // Get connection count — count rows in local_connections where + // this user is the viewer (using viewer_local_id). + var connCount int + _ = h.DB.QueryRowContext(r.Context(), ` + SELECT COUNT(*) FROM local_connections + WHERE viewer_local_id = ? + `, did).Scan(&connCount) + + events := "" + for _, n := range eventNames { + if events != "" { + events += " " + } + events += n + } + + results = append(results, FollowUpRow{ + DisplayName: displayName, + Email: email, + Events: events, + ConnectionNums: connCount, + }) + } + rows.Close() + + // Write CSV + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=atmoquest-follow-ups.csv") + // Write BOM for Excel compatibility + _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) + _, _ = w.Write([]byte("display_name,email,events,connections\n")) + for _, r := range results { + line := csvEscape(r.DisplayName) + "," + csvEscape(r.Email) + "," + csvEscape(r.Events) + "," + strconv.Itoa(r.ConnectionNums) + "\n" + _, _ = w.Write([]byte(line)) + } +} + +// csvEscape wraps a value in double quotes if it contains special chars. +func csvEscape(v string) string { + if strings.ContainsAny(v, ",\"\n\r") { + v = strings.ReplaceAll(v, "\"", "\"\"") + return "\"" + v + "\"" + } + return v +} + // parseDatetimeLocal parses the value of an HTML . // Browsers can send several formats: // - "2006-01-02T15:04" (Chrome, Safari — most common) diff --git a/features/admin/pages/dashboard.templ b/features/admin/pages/dashboard.templ index 9c6d635..bdf27d1 100644 --- a/features/admin/pages/dashboard.templ +++ b/features/admin/pages/dashboard.templ @@ -72,6 +72,7 @@ templ adminShell(active, currentHandle string) {