diff --git a/lib/database.types.ts b/lib/database.types.ts index fa6d7cd..d966295 100644 --- a/lib/database.types.ts +++ b/lib/database.types.ts @@ -72,6 +72,13 @@ export type Database = { user_id?: string } Relationships: [ + { + foreignKeyName: "engagement_sessions_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "post_engagement_metrics" + referencedColumns: ["post_id"] + }, { foreignKeyName: "engagement_sessions_post_id_fkey" columns: ["post_id"] @@ -146,6 +153,13 @@ export type Database = { updated_at?: string } Relationships: [ + { + foreignKeyName: "post_comments_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "post_engagement_metrics" + referencedColumns: ["post_id"] + }, { foreignKeyName: "post_comments_post_id_fkey" columns: ["post_id"] @@ -178,6 +192,13 @@ export type Database = { user_id?: string } Relationships: [ + { + foreignKeyName: "post_ratings_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "post_engagement_metrics" + referencedColumns: ["post_id"] + }, { foreignKeyName: "post_ratings_post_id_fkey" columns: ["post_id"] @@ -210,6 +231,13 @@ export type Database = { user_id?: string } Relationships: [ + { + foreignKeyName: "post_reactions_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "post_engagement_metrics" + referencedColumns: ["post_id"] + }, { foreignKeyName: "post_reactions_post_id_fkey" columns: ["post_id"] @@ -389,6 +417,22 @@ export type Database = { } } Views: { + post_engagement_daily: { + Row: { + avg_rating: number | null + avg_scroll: number | null + avg_seconds: number | null + click_rate: number | null + day: string | null + post_id: string | null + total_comments: number | null + total_ratings: number | null + total_reactions: number | null + unique_clicks: number | null + unique_readers: number | null + } + Relationships: [] + } post_engagement_metrics: { Row: { avg_rating: number | null diff --git a/supabase/migrations/20260711000001_create_post_engagement_daily_mv.sql b/supabase/migrations/20260711000001_create_post_engagement_daily_mv.sql new file mode 100644 index 0000000..fe9defd --- /dev/null +++ b/supabase/migrations/20260711000001_create_post_engagement_daily_mv.sql @@ -0,0 +1,157 @@ +-- Migration: vista materializada post_engagement_daily + refresco horario (pg_cron) +-- Part of Epic N04 / Feature F-N04-03 (#175) / Issue I-F-N04-03-02 (#181) +-- refs: docs/adr/0001-engagement.md · docs/adr/0006-engagement-behavioral-signals.md +-- métricas de origen: 20260711000000_create_post_engagement_metrics_view.sql (#180) +-- +-- Materializa las métricas por (post_id, día) para que el dashboard no ataque +-- engagement_sessions cruda. Lag máximo del dashboard: 1 hora (refresco horario). +-- +-- ── Por qué la MV vive en el schema `private` ──────────────────────────────── +-- Las vistas materializadas NO soportan RLS ni `security_invoker`. Si se expusiera +-- con grant a `authenticated`, cualquier staff podría leer el engagement de todos +-- los posts. `private` no está en `[api] schemas` (config.toml), así que PostgREST +-- no lo expone. El acceso pasa por la vista pública `public.post_engagement_daily`, +-- que corre con permisos del owner y filtra con el guard `is_manager()`. +-- +-- ── Fan-out ────────────────────────────────────────────────────────────────── +-- Cada fuente se pre-agrega por (post_id, día) y luego se une sobre un "spine" con +-- todos los pares (post_id, día) presentes en cualquier fuente. Unir las tablas +-- directamente multiplicaría los conteos (producto cartesiano). +-- +-- ── Días en UTC ────────────────────────────────────────────────────────────── +-- `at time zone 'utc'` hace el bucketing determinista e independiente del TimeZone +-- de la sesión que consulte o refresque. + +create schema if not exists private; + +revoke all on schema private from public; +revoke all on schema private from anon, authenticated; + +create materialized view private.post_engagement_daily as +with sessions as ( + select + post_id, + (started_at at time zone 'utc')::date as day, + count(distinct user_id) as unique_readers, + count(distinct user_id) filter (where link_clicked) as unique_clicks, + avg(focused_seconds)::numeric(10,2) as avg_seconds, + avg(max_scroll_pct)::numeric(4,3) as avg_scroll + from public.engagement_sessions + group by post_id, (started_at at time zone 'utc')::date +), +ratings as ( + select + post_id, + (created_at at time zone 'utc')::date as day, + avg(rating)::numeric(3,2) as avg_rating, + count(*) as total_ratings + from public.post_ratings + group by post_id, (created_at at time zone 'utc')::date +), +reactions as ( + select + post_id, + (created_at at time zone 'utc')::date as day, + count(*) as total_reactions + from public.post_reactions + group by post_id, (created_at at time zone 'utc')::date +), +comments as ( + select + post_id, + (created_at at time zone 'utc')::date as day, + count(*) as total_comments + from public.post_comments + group by post_id, (created_at at time zone 'utc')::date +), +spine as ( + select post_id, day from sessions + union + select post_id, day from ratings + union + select post_id, day from reactions + union + select post_id, day from comments +) +select + sp.post_id, + sp.day, + coalesce(s.unique_readers, 0) as unique_readers, + coalesce(s.unique_clicks, 0) as unique_clicks, + -- nullif evita la división por cero: sin lectores ese día, el ratio es NULL. + round( + coalesce(s.unique_clicks, 0)::numeric / nullif(s.unique_readers, 0), + 4 + ) as click_rate, + r.avg_rating, + coalesce(r.total_ratings, 0) as total_ratings, + coalesce(rx.total_reactions, 0) as total_reactions, + coalesce(c.total_comments, 0) as total_comments, + -- Señales opcionales (ADR-0006). + s.avg_seconds, + s.avg_scroll +from spine sp +left join sessions s on s.post_id = sp.post_id and s.day = sp.day +left join ratings r on r.post_id = sp.post_id and r.day = sp.day +left join reactions rx on rx.post_id = sp.post_id and rx.day = sp.day +left join comments c on c.post_id = sp.post_id and c.day = sp.day; + +-- Índice único: requisito de REFRESH MATERIALIZED VIEW CONCURRENTLY (refresca sin +-- bloquear las lecturas del dashboard). +create unique index post_engagement_daily_pk + on private.post_engagement_daily (post_id, day); + +create index post_engagement_daily_day_idx + on private.post_engagement_daily (day desc); + +comment on materialized view private.post_engagement_daily is + 'Métricas de engagement por (post_id, día). Refresco horario vía pg_cron; lag máximo 1h. ' + 'No expuesta por PostgREST (las MV no soportan RLS): el acceso va por public.post_engagement_daily.'; + +-- ── Acceso público (RBAC en Postgres) ──────────────────────────────────────── +-- Vista con permisos del owner (lee la MV en `private`) + guard is_manager() +-- (jerarquía inclusiva: admin > manager). Staff obtiene 0 filas. +create or replace view public.post_engagement_daily as +select + d.post_id, + d.day, + d.unique_readers, + d.unique_clicks, + d.click_rate, + d.avg_rating, + d.total_ratings, + d.total_reactions, + d.total_comments, + d.avg_seconds, + d.avg_scroll +from private.post_engagement_daily d +where public.is_manager(); + +comment on view public.post_engagement_daily is + 'Lectura del dashboard sobre la MV private.post_engagement_daily (F-N04-03). ' + 'Solo admin/manager (guard is_manager()); staff obtiene 0 filas.'; + +grant select on public.post_engagement_daily to authenticated; + +-- ── Refresco horario ───────────────────────────────────────────────────────── +create extension if not exists pg_cron; + +create or replace function private.refresh_post_engagement_daily() +returns void +language sql +security definer +set search_path = '' +as $$ + refresh materialized view concurrently private.post_engagement_daily; +$$; + +comment on function private.refresh_post_engagement_daily() is + 'Refresco CONCURRENTLY (no bloquea lecturas). Lo invoca el job horario de pg_cron.'; + +-- Job idempotente: cron.schedule hace upsert por nombre, así que re-aplicar la +-- migración no duplica el job. Cada hora en punto → lag máximo de 1h. +select cron.schedule( + 'refresh-post-engagement-daily', + '0 * * * *', + $$ select private.refresh_post_engagement_daily() $$ +); diff --git a/supabase/tests/rls/mv_post_engagement_daily.sql b/supabase/tests/rls/mv_post_engagement_daily.sql new file mode 100644 index 0000000..9d3089c --- /dev/null +++ b/supabase/tests/rls/mv_post_engagement_daily.sql @@ -0,0 +1,160 @@ +-- Tests: MV private.post_engagement_daily + vista public.post_engagement_daily +-- Issue I-F-N04-03-02 (#181) — migración 20260711000001_create_post_engagement_daily_mv +-- refs: docs/adr/0001-engagement.md · docs/adr/0006-engagement-behavioral-signals.md +-- +-- Dataset fijo con timestamps UTC explícitos (el bucketing de día es determinista). +-- Cubre: agregación por (post_id, día) desde las 4 fuentes, ausencia de fan-out, +-- día sin sesiones (click_rate NULL, sin división por cero), índice único para el +-- refresh concurrente, job horario de pg_cron y RBAC (staff no ve nada). +-- +-- Nota: los tests corren en transacción, y REFRESH ... CONCURRENTLY no puede correr +-- dentro de un bloque transaccional; aquí se usa el REFRESH plano (equivalente en +-- resultado). El CONCURRENTLY lo ejerce el job de cron fuera de transacción. +-- +-- Seed UUIDs (supabase/seed.sql) — ids de auth.users: +-- admin: aaaaaaaa-0000-0000-0000-000000000001 +-- manager: aaaaaaaa-0000-0000-0000-000000000002 +-- staff: aaaaaaaa-0000-0000-0000-000000000003 + +begin; +select plan(10); + +create or replace function pg_temp.set_session(uid uuid) +returns void language plpgsql as $$ +begin + perform set_config( + 'request.jwt.claims', + json_build_object('sub', uid::text, 'role', 'authenticated')::text, + true + ); + set local role authenticated; +end; +$$; + +-- ── Estructura ────────────────────────────────────────────────────────────── +select ok( + (select count(*) from pg_matviews + where schemaname = 'private' and matviewname = 'post_engagement_daily') = 1, + 'existe la MV private.post_engagement_daily (fuera del schema expuesto por PostgREST)' +); + +select ok( + exists ( + select 1 + from pg_index i + join pg_class ic on ic.oid = i.indexrelid + join pg_class tc on tc.oid = i.indrelid + join pg_namespace n on n.oid = tc.relnamespace + where n.nspname = 'private' + and tc.relname = 'post_engagement_daily' + and ic.relname = 'post_engagement_daily_pk' + and i.indisunique + ), + 'índice único (post_id, day): requisito de REFRESH MATERIALIZED VIEW CONCURRENTLY' +); + +select is( + (select schedule from cron.job where jobname = 'refresh-post-engagement-daily'), + '0 * * * *', + 'refresco programado cada hora vía pg_cron (lag máximo del dashboard: 1h)' +); + +select has_view('public', 'post_engagement_daily', 'existe la vista pública que consume el dashboard'); + +-- ── Dataset fijo (como postgres = bypass RLS) ─────────────────────────────── +insert into public.posts (id, author_id, title, external_url, status, published_at) +select 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid, p.id, 'Daily metrics post', + 'https://example.com/daily', 'published', '2026-07-01T08:00:00Z' +from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid; + +-- Día 1 (2026-07-01): 2 lectores, 1 clic. engagement_sessions es 1 fila por +-- (user_id, post_id), así que cada usuario aporta a un único día por post. +insert into public.engagement_sessions + (user_id, post_id, started_at, link_clicked, status, focused_seconds, max_scroll_pct) +values + ('aaaaaaaa-0000-0000-0000-000000000003'::uuid, 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid, + '2026-07-01T10:00:00Z', true, 'clicked', 10, 0.800), + ('aaaaaaaa-0000-0000-0000-000000000002'::uuid, 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid, + '2026-07-01T11:00:00Z', false, 'viewed', 20, 0.400); + +-- Día 1: 2 valoraciones (staff 5, manager 3) y 1 reacción (staff). +insert into public.post_ratings (post_id, user_id, rating, created_at) values + ('aaaaaaaa-4444-0000-0000-00000000000a'::uuid, 'aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 5, '2026-07-01T12:00:00Z'), + ('aaaaaaaa-4444-0000-0000-00000000000a'::uuid, 'aaaaaaaa-0000-0000-0000-000000000002'::uuid, + 3, '2026-07-01T13:00:00Z'); + +insert into public.post_reactions (post_id, user_id, type, created_at) values + ('aaaaaaaa-4444-0000-0000-00000000000a'::uuid, 'aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 'like', '2026-07-01T12:30:00Z'); + +-- Día 2 (2026-07-02): SIN sesiones; solo una valoración del admin. +-- Ejercita el "spine" (el día existe aunque no haya sesiones) y la división por cero. +insert into public.post_ratings (post_id, user_id, rating, created_at) values + ('aaaaaaaa-4444-0000-0000-00000000000a'::uuid, 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, + 4, '2026-07-02T09:00:00Z'); + +refresh materialized view private.post_engagement_daily; + +-- ── Métricas del día 1 (como manager) ─────────────────────────────────────── +select pg_temp.set_session('aaaaaaaa-0000-0000-0000-000000000002'::uuid); + +select results_eq( + $$ select unique_readers, unique_clicks, click_rate + from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid + and day = '2026-07-01'::date $$, + $$ values (2::bigint, 1::bigint, 0.5000::numeric) $$, + 'día 1: unique_readers=2, unique_clicks=1, click_rate=0.5' +); + +-- Con 2 sesiones × 2 ratings × 1 reacción, un JOIN sin pre-agregar daría +-- total_reactions=2 y total_ratings=4. Aquí se comprueba que no hay fan-out. +select results_eq( + $$ select avg_rating, total_ratings, total_reactions, total_comments + from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid + and day = '2026-07-01'::date $$, + $$ values (4.00::numeric(3,2), 2::bigint, 1::bigint, 0::bigint) $$, + 'día 1: avg_rating=4.00, ratings=2, reactions=1, comments=0 (sin fan-out)' +); + +select results_eq( + $$ select avg_seconds, avg_scroll + from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid + and day = '2026-07-01'::date $$, + $$ values (15.00::numeric(10,2), 0.600::numeric(4,3)) $$, + 'día 1: señales opcionales avg_seconds=15.00, avg_scroll=0.600 (ADR-0006)' +); + +-- ── Día 2: sin sesiones → click_rate NULL, sin división por cero ──────────── +select results_eq( + $$ select unique_readers, unique_clicks, click_rate, avg_rating, total_ratings + from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid + and day = '2026-07-02'::date $$, + $$ values (0::bigint, 0::bigint, null::numeric, 4.00::numeric(3,2), 1::bigint) $$, + 'día 2 sin sesiones: unique_readers=0 y click_rate NULL (sin división por cero)' +); + +select results_eq( + $$ select count(*)::int from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid $$, + $$ values (2) $$, + 'el post agrega en 2 filas: una por día con actividad' +); + +-- ── RBAC ──────────────────────────────────────────────────────────────────── +select pg_temp.set_session('aaaaaaaa-0000-0000-0000-000000000003'::uuid); + +select results_eq( + $$ select count(*)::int from public.post_engagement_daily $$, + $$ values (0) $$, + 'staff no ve datos del dashboard (vista vacía)' +); + +reset role; + +select * from finish(); +rollback;