-- 0011_analytics.sql -- Product analytics: engagement events, dwell sessions, and reporting views. -- -- Why: public.runs only logs /api/run (the DE pipeline) and — until the engine -- change that ships with this migration — only ever wrote status='started' -- (finished_at / elapsed_seconds / n_variants stayed NULL, and successful_runs -- in user_summary was always 0). profiles.last_seen_at was also never written. -- This migration adds the missing pieces so activation, retention, engagement -- (across ALL tools), and real dwell time become queryable. -- -- Privacy: mirrors public.runs — no sequence content; anonymous visitors are -- keyed only by a salted /24-IP+UA fingerprint, never a raw IP. Retention is -- bounded by prune_old_analytics() (companion to 0010's prune_old_runs). -- -- Run once in the Supabase SQL editor. Re-runnable: tables/functions/views use -- IF NOT EXISTS / OR REPLACE, and policies are dropped before (re)create. create extension if not exists "pgcrypto"; -- ═══════════════════════════════════════════════════════════════════════ -- EVENTS — one row per meaningful tool action (engagement) -- ─────────────────────────────────────────────────────────────────────── -- Written by the Flask backend via the service role (auth.log_event), fired -- from the after_request hook for a curated set of endpoints (plasmid map, -- CRISPR design, saves, alignment, DE round-2, …). /api/run is NOT logged here -- — public.runs is authoritative for it, so logging it twice would double-count. create table if not exists public.events ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, anon_fingerprint text, kind text not null, meta jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); comment on table public.events is 'Engagement events — one row per meaningful tool action. No sequence content.'; create index if not exists events_user_idx on public.events(user_id, created_at desc); create index if not exists events_kind_idx on public.events(kind, created_at desc); create index if not exists events_created_idx on public.events(created_at desc); -- ═══════════════════════════════════════════════════════════════════════ -- APP_SESSIONS — per-session dwell time -- ─────────────────────────────────────────────────────────────────────── -- One row per browser session, upserted by the /api/ping heartbeat via the -- record_ping() RPC below. visible_ms is CUMULATIVE foreground time; the RPC -- keeps the max so duplicate / out-of-order beats are harmless. create table if not exists public.app_sessions ( session_id text primary key, user_id uuid references auth.users(id) on delete set null, anon_fingerprint text, first_seen timestamptz not null default now(), last_seen timestamptz not null default now(), visible_ms bigint not null default 0, ping_count integer not null default 0 ); comment on table public.app_sessions is 'Per-session dwell tracking. Upserted by record_ping() from /api/ping.'; create index if not exists app_sessions_user_idx on public.app_sessions(user_id, first_seen desc); create index if not exists app_sessions_first_idx on public.app_sessions(first_seen desc); -- ═══════════════════════════════════════════════════════════════════════ -- record_ping() — atomic dwell upsert -- ─────────────────────────────────────────────────────────────────────── -- Insert a new session, or bump last_seen / ping_count and keep visible_ms -- monotonic. Crucially, it PRESERVES an already-known user_id when a later beat -- arrives without one — the on-hide sendBeacon is unauthenticated, so a naive -- upsert would null out the user_id captured by the earlier authenticated beats. create or replace function public.record_ping( p_session_id text, p_user_id uuid, p_anon text, p_visible_ms bigint ) returns void language plpgsql security definer set search_path = public as $$ begin insert into public.app_sessions (session_id, user_id, anon_fingerprint, first_seen, last_seen, visible_ms, ping_count) values (p_session_id, p_user_id, p_anon, now(), now(), greatest(coalesce(p_visible_ms, 0), 0), 1) on conflict (session_id) do update set last_seen = now(), visible_ms = greatest(public.app_sessions.visible_ms, excluded.visible_ms), ping_count = public.app_sessions.ping_count + 1, user_id = coalesce(public.app_sessions.user_id, excluded.user_id), anon_fingerprint = coalesce(public.app_sessions.anon_fingerprint, excluded.anon_fingerprint); end; $$; -- Only the service role (Flask backend) may call it. Revoke the default public -- EXECUTE, then grant it back to service_role explicitly — the engine invokes -- this RPC with the service key at runtime, so without this grant every ping -- would fail "permission denied for function record_ping". revoke all on function public.record_ping(text, uuid, text, bigint) from public; revoke all on function public.record_ping(text, uuid, text, bigint) from anon; revoke all on function public.record_ping(text, uuid, text, bigint) from authenticated; grant execute on function public.record_ping(text, uuid, text, bigint) to service_role; comment on function public.record_ping(text, uuid, text, bigint) is 'Atomic upsert of a dwell session from /api/ping. Service-role only.'; -- ═══════════════════════════════════════════════════════════════════════ -- ROW-LEVEL SECURITY — deny by default; reads of own rows; writes via service -- role (which bypasses RLS, like public.runs). -- ═══════════════════════════════════════════════════════════════════════ alter table public.events enable row level security; alter table public.app_sessions enable row level security; drop policy if exists "events_select_own" on public.events; drop policy if exists "app_sessions_select_own" on public.app_sessions; create policy "events_select_own" on public.events for select using (auth.uid() = user_id); create policy "app_sessions_select_own" on public.app_sessions for select using (auth.uid() = user_id); -- ═══════════════════════════════════════════════════════════════════════ -- RETENTION SWEEP — companion to 0010's prune_old_runs. Free tier has no -- pg_cron, so this is a callable maintenance routine; safe to run repeatedly. -- ═══════════════════════════════════════════════════════════════════════ create or replace function public.prune_old_analytics(retain_days integer default 90) returns integer language plpgsql security definer set search_path = public as $$ declare d_events integer; d_sess integer; begin delete from public.events where created_at < now() - make_interval(days => greatest(retain_days, 1)); get diagnostics d_events = row_count; delete from public.app_sessions where last_seen < now() - make_interval(days => greatest(retain_days, 1)); get diagnostics d_sess = row_count; return d_events + d_sess; end; $$; revoke all on function public.prune_old_analytics(integer) from public; revoke all on function public.prune_old_analytics(integer) from anon; revoke all on function public.prune_old_analytics(integer) from authenticated; comment on function public.prune_old_analytics(integer) is 'Maintenance: delete events + app_sessions older than retain_days (default 90).'; -- ═══════════════════════════════════════════════════════════════════════ -- REPORTING VIEWS (service-role only — RLS on the base tables blocks anon). -- Build dashboards against these instead of re-writing the joins. -- ═══════════════════════════════════════════════════════════════════════ -- Per-user activation: first activity = earliest run OR event. create or replace view public.user_activation as with first_act as ( select user_id, min(ts) as first_activity_at from ( select user_id, started_at as ts from public.runs where user_id is not null union all select user_id, created_at as ts from public.events where user_id is not null ) a group by user_id ) select p.id, p.email, p.created_at as signed_up_at, p.signup_source, fa.first_activity_at, (fa.first_activity_at is not null) as ever_active, (fa.first_activity_at <= p.created_at + interval '24 hours') as activated_24h, (fa.first_activity_at <= p.created_at + interval '7 days') as activated_7d from public.profiles p left join first_act fa on fa.user_id = p.id; comment on view public.user_activation is 'Per-user activation: did they act, and how soon after signup. Service-role only.'; -- Weekly signup cohorts × still-active in later windows. create or replace view public.retention_cohorts as with act as ( select user_id, started_at::date as d from public.runs where user_id is not null union all select user_id, created_at::date as d from public.events where user_id is not null ) select date_trunc('week', p.created_at)::date as cohort_week, count(distinct p.id) as cohort_size, count(distinct a.user_id) filter ( where a.d >= (p.created_at + interval '1 day')::date and a.d < (p.created_at + interval '8 days')::date) as wk1, count(distinct a.user_id) filter ( where a.d >= (p.created_at + interval '8 days')::date and a.d < (p.created_at + interval '29 days')::date) as wk2_4, count(distinct a.user_id) filter ( where a.d >= (p.created_at + interval '29 days')::date) as wk4plus from public.profiles p left join act a on a.user_id = p.id group by 1 order by 1 desc; comment on view public.retention_cohorts is 'Weekly signup cohorts × later activity (runs+events). Service-role only.'; -- Daily action volume + active users (runs + events unioned). create or replace view public.engagement_daily as with act as ( select user_id, anon_fingerprint, started_at as ts, 'run'::text as kind from public.runs union all select user_id, anon_fingerprint, created_at as ts, kind from public.events ) select date_trunc('day', ts)::date as day, count(*) as actions, count(distinct user_id) filter (where user_id is not null) as signed_in_users, count(distinct coalesce(user_id::text, anon_fingerprint)) as total_actors from act group by 1 order by 1 desc; comment on view public.engagement_daily is 'Daily action volume + active users (runs+events). Service-role only.'; -- Per-session dwell. create or replace view public.dwell_sessions as select s.session_id, s.user_id, p.email, s.first_seen, s.last_seen, s.ping_count, round(s.visible_ms / 1000.0) as visible_seconds from public.app_sessions s left join public.profiles p on p.id = s.user_id order by s.first_seen desc; comment on view public.dwell_sessions is 'Per-session foreground dwell time. Service-role only.'; -- Daily dwell summary. create or replace view public.dwell_daily as select date_trunc('day', first_seen)::date as day, count(*) as sessions, count(distinct coalesce(user_id::text, anon_fingerprint)) as visitors, round(avg(visible_ms) / 1000.0) as avg_session_seconds, round((percentile_cont(0.5) within group (order by visible_ms)) / 1000.0) as median_session_seconds from public.app_sessions group by 1 order by 1 desc; comment on view public.dwell_daily is 'Daily dwell: sessions, visitors, avg/median foreground seconds. Service-role only.';