diff --git a/supabase/functions/community-pulse/index.ts b/supabase/functions/community-pulse/index.ts index 23e30202..4ba39e21 100644 --- a/supabase/functions/community-pulse/index.ts +++ b/supabase/functions/community-pulse/index.ts @@ -1,9 +1,12 @@ // gstack community-pulse edge function -// Returns weekly active installation count for preamble display. -// Cached for 1 hour via Cache-Control header. +// Returns aggregated community stats for the dashboard: +// weekly active count, top skills, crash clusters, version distribution. +// Uses server-side cache (community_pulse_cache table) to prevent DoS. import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour + Deno.serve(async () => { const supabase = createClient( Deno.env.get("SUPABASE_URL") ?? "", @@ -11,17 +14,37 @@ Deno.serve(async () => { ); try { - // Count unique update checks in the last 7 days (install base proxy) + // Check cache first + const { data: cached } = await supabase + .from("community_pulse_cache") + .select("data, refreshed_at") + .eq("id", 1) + .single(); + + if (cached?.refreshed_at) { + const age = Date.now() - new Date(cached.refreshed_at).getTime(); + if (age < CACHE_MAX_AGE_MS) { + return new Response(JSON.stringify(cached.data), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + }, + }); + } + } + + // Cache is stale or missing — recompute const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(); - // This week's active + // Weekly active (update checks this week) const { count: thisWeek } = await supabase .from("update_checks") .select("*", { count: "exact", head: true }) .gte("checked_at", weekAgo); - // Last week's active (for change %) + // Last week (for change %) const { count: lastWeek } = await supabase .from("update_checks") .select("*", { count: "exact", head: true }) @@ -34,22 +57,80 @@ Deno.serve(async () => { ? Math.round(((current - previous) / previous) * 100) : 0; - return new Response( - JSON.stringify({ - weekly_active: current, - change_pct: changePct, - }), - { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=3600", // 1 hour cache - }, + // Top skills (last 7 days) + const { data: skillRows } = await supabase + .from("telemetry_events") + .select("skill") + .eq("event_type", "skill_run") + .gte("event_timestamp", weekAgo) + .not("skill", "is", null) + .limit(1000); + + const skillCounts: Record = {}; + for (const row of skillRows ?? []) { + if (row.skill) { + skillCounts[row.skill] = (skillCounts[row.skill] ?? 0) + 1; } - ); + } + const topSkills = Object.entries(skillCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10) + .map(([skill, count]) => ({ skill, count })); + + // Crash clusters (top 5) + const { data: crashes } = await supabase + .from("crash_clusters") + .select("error_class, gstack_version, total_occurrences, identified_users") + .limit(5); + + // Version distribution (last 7 days) + const versionCounts: Record = {}; + for (const row of skillRows ?? []) { + // skillRows doesn't have version — query separately + } + const { data: versionRows } = await supabase + .from("telemetry_events") + .select("gstack_version") + .gte("event_timestamp", weekAgo) + .limit(1000); + + for (const row of versionRows ?? []) { + if (row.gstack_version) { + versionCounts[row.gstack_version] = (versionCounts[row.gstack_version] ?? 0) + 1; + } + } + const topVersions = Object.entries(versionCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([version, count]) => ({ version, count })); + + const result = { + weekly_active: current, + change_pct: changePct, + top_skills: topSkills, + crashes: crashes ?? [], + versions: topVersions, + }; + + // Upsert cache + await supabase + .from("community_pulse_cache") + .upsert({ + id: 1, + data: result, + refreshed_at: new Date().toISOString(), + }); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + }, + }); } catch { return new Response( - JSON.stringify({ weekly_active: 0, change_pct: 0 }), + JSON.stringify({ weekly_active: 0, change_pct: 0, top_skills: [], crashes: [], versions: [] }), { status: 200, headers: { "Content-Type": "application/json" },