From d0a4afdfb20b41ab03d99358adccd26c690be4d8 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Sat, 11 Jul 2026 16:56:01 +0200 Subject: [PATCH 1/4] feat(engagement): add engaged_users to post_engagement_daily (#172) --- lib/database.types.ts | 6 +- ...engaged_users_to_post_engagement_daily.sql | 165 ++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20260711000003_add_engaged_users_to_post_engagement_daily.sql diff --git a/lib/database.types.ts b/lib/database.types.ts index d6111bb..78bc99c 100644 --- a/lib/database.types.ts +++ b/lib/database.types.ts @@ -424,6 +424,7 @@ export type Database = { avg_seconds: number | null click_rate: number | null day: string | null + engaged_users: number | null post_id: string | null total_comments: number | null total_ratings: number | null @@ -484,10 +485,6 @@ export type Database = { } } Functions: { - refresh_post_engagement_daily: { - Args: never - Returns: undefined - } apply_engagement_events: { Args: { p_events: Json; p_user_id: string } Returns: { @@ -505,6 +502,7 @@ export type Database = { is_admin: { Args: never; Returns: boolean } is_manager: { Args: never; Returns: boolean } is_staff: { Args: never; Returns: boolean } + refresh_post_engagement_daily: { Args: never; Returns: undefined } } Enums: { reaction_type: "like" | "dislike" | "love" diff --git a/supabase/migrations/20260711000003_add_engaged_users_to_post_engagement_daily.sql b/supabase/migrations/20260711000003_add_engaged_users_to_post_engagement_daily.sql new file mode 100644 index 0000000..fb90714 --- /dev/null +++ b/supabase/migrations/20260711000003_add_engaged_users_to_post_engagement_daily.sql @@ -0,0 +1,165 @@ +-- Migration: añade engaged_users a post_engagement_daily +-- Part of Epic N04 (#172) / Feature F-N04-03 (#175) +-- refs: docs/adr/0001-engagement.md (status viewed / engaged / clicked) +-- +-- ADR-001 y las decisiones cerradas del EPIC-N04 definen `engaged` como "el usuario +-- interactuó (reacción / valoración / comentario) pero NO clicó el enlace externo". +-- Hasta ahora ese concepto no llegaba al dashboard: la Edge Function no escribe +-- status = 'engaged' (se decidió derivarlo, no usar triggers cross-tabla) y la MV no +-- lo agregaba. Esta migración lo materializa. +-- +-- ── Por qué se atribuye al día de la PRIMERA interacción ───────────────────── +-- El cliente suma las filas diarias para componer la ventana de 30 días. Eso es +-- seguro para unique_readers porque engagement_sessions tiene 1 fila por +-- (user_id, post_id): cada usuario cae en un único día. Pero un usuario SÍ puede +-- interactuar varios días (reacciona el día 1, comenta el día 3), así que contarlo +-- en cada día lo doblaría al sumar. Atribuyéndolo al día de su primera interacción +-- con el post, cada usuario engaged aparece en un único bucket y la suma sigue +-- siendo un conteo de usuarios distintos. +-- +-- `clicked` es absorbente (ADR-001): si el usuario acaba clicando, deja de contar +-- como engaged en el siguiente refresco. +-- +-- Las MV no admiten ALTER ... ADD COLUMN, así que se recrea (y con ella la vista +-- pública que la expone). Las funciones de refresco resuelven el nombre en tiempo +-- de ejecución, así que el job horario de pg_cron sigue siendo válido. + +drop view if exists public.post_engagement_daily; +drop materialized view if exists private.post_engagement_daily; + +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 +), +-- Toda interacción in-app con el post, sea del tipo que sea. +interactions as ( + select post_id, user_id, created_at from public.post_reactions + union all + select post_id, user_id, created_at from public.post_ratings + union all + select post_id, author_id, created_at from public.post_comments +), +first_interaction as ( + select post_id, user_id, min(created_at) as first_at + from interactions + group by post_id, user_id +), +-- engaged = interactuó y NO clicó el enlace, imputado a su primer día. +engaged as ( + select + fi.post_id, + (fi.first_at at time zone 'utc')::date as day, + count(distinct fi.user_id) as engaged_users + from first_interaction fi + left join public.engagement_sessions es + on es.post_id = fi.post_id + and es.user_id = fi.user_id + and es.link_clicked + where es.user_id is null + group by fi.post_id, (fi.first_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 + union + select post_id, day from engaged +) +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, + coalesce(e.engaged_users, 0) as engaged_users, + -- 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 +left join engaged e on e.post_id = sp.post_id and e.day = sp.day; + +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.'; + +comment on column private.post_engagement_daily.engaged_users is + 'ADR-001: usuarios que interactuaron (reacción/valoración/comentario) sin clicar el enlace. ' + 'Imputados al día de su PRIMERA interacción con el post, para que sumar días no los doble.'; + +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.engaged_users, + 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; From 160e6a98997aca0d62b78f0e6dc1a02fe67770c2 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Sat, 11 Jul 2026 16:56:50 +0200 Subject: [PATCH 2/4] feat(engagement): surface engaged users on the dashboard (#172) --- app/(app)/(tabs)/admin/engagement/index.tsx | 4 +++- lib/supabase/queries/engagement.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/(app)/(tabs)/admin/engagement/index.tsx b/app/(app)/(tabs)/admin/engagement/index.tsx index 158949b..831ab14 100644 --- a/app/(app)/(tabs)/admin/engagement/index.tsx +++ b/app/(app)/(tabs)/admin/engagement/index.tsx @@ -38,8 +38,10 @@ function EngagementRow({ row }: { row: PostEngagement }) { + {/* engaged (ADR-001): interactuaron pero no llegaron al enlace externo. */} - {row.unique_readers} {row.unique_readers === 1 ? 'lector único' : 'lectores únicos'} + {row.unique_readers} {row.unique_readers === 1 ? 'lector único' : 'lectores únicos'} ·{' '} + {row.engaged_users} {row.engaged_users === 1 ? 'interactuó' : 'interactuaron'} sin clicar ); diff --git a/lib/supabase/queries/engagement.ts b/lib/supabase/queries/engagement.ts index 3ad617d..d05ac8f 100644 --- a/lib/supabase/queries/engagement.ts +++ b/lib/supabase/queries/engagement.ts @@ -16,6 +16,8 @@ export type PostEngagement = { click_rate: number | null; // null si el post no tuvo lectores en el periodo avg_rating: number | null; total_reactions: number; + // ADR-001: interactuaron (reacción/valoración/comentario) sin clicar el enlace. + engaged_users: number; // Señales opcionales (ADR-0006): pueden no estar informadas. avg_seconds: number | null; avg_scroll: number | null; @@ -27,6 +29,7 @@ type Totals = { readers: number; clicks: number; reactions: number; + engaged: number; ratingSum: number; ratingCount: number; secondsSum: number; @@ -38,6 +41,7 @@ function emptyTotals(): Totals { readers: 0, clicks: 0, reactions: 0, + engaged: 0, ratingSum: 0, ratingCount: 0, secondsSum: 0, @@ -57,7 +61,7 @@ export async function listPostEngagement({ const { data: daily, error } = await client .from('post_engagement_daily') .select( - 'post_id, unique_readers, unique_clicks, avg_rating, total_ratings, total_reactions, avg_seconds, avg_scroll', + 'post_id, unique_readers, unique_clicks, avg_rating, total_ratings, total_reactions, engaged_users, avg_seconds, avg_scroll', ) .gte('day', cutoff); if (error) throw error; @@ -70,10 +74,12 @@ export async function listPostEngagement({ // Sumar unique_readers entre días es correcto porque engagement_sessions guarda // 1 fila por (user_id, post_id): cada usuario cae en un único día por post, así - // que no hay doble conteo al agregar el periodo. + // que no hay doble conteo al agregar el periodo. engaged_users cumple lo mismo + // porque la vista imputa cada usuario al día de su primera interacción. t.readers += readers; t.clicks += row.unique_clicks ?? 0; t.reactions += row.total_reactions ?? 0; + t.engaged += row.engaged_users ?? 0; // Medias ponderadas: avg_rating pesa por nº de valoraciones de ese día; las // señales de comportamiento pesan por nº de sesiones (= lectores) de ese día. @@ -108,6 +114,7 @@ export async function listPostEngagement({ click_rate: t.readers > 0 ? t.clicks / t.readers : null, avg_rating: t.ratingCount > 0 ? t.ratingSum / t.ratingCount : null, total_reactions: t.reactions, + engaged_users: t.engaged, avg_seconds: t.readers > 0 ? t.secondsSum / t.readers : null, avg_scroll: t.readers > 0 ? t.scrollSum / t.readers : null, }; From f102ddf9bc253ea11e270d81afc70a6b7ed6bc4b Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Sat, 11 Jul 2026 16:57:55 +0200 Subject: [PATCH 3/4] test(engagement): cover engaged users end to end (#172) --- .../components/EngagementScreen.test.tsx | 3 +++ .../integration/engagementDashboard.test.ts | 2 ++ __tests__/lib/engagementQueries.test.ts | 7 ++++++ .../tests/rls/mv_post_engagement_daily.sql | 22 ++++++++++++++++++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/__tests__/components/EngagementScreen.test.tsx b/__tests__/components/EngagementScreen.test.tsx index 8a122cf..1ac7e41 100644 --- a/__tests__/components/EngagementScreen.test.tsx +++ b/__tests__/components/EngagementScreen.test.tsx @@ -41,6 +41,7 @@ const ROWS: PostEngagement[] = [ click_rate: 0.75, avg_rating: 4.5, total_reactions: 6, + engaged_users: 2, avg_seconds: 12, avg_scroll: 0.8, }, @@ -73,6 +74,8 @@ describe('EngagementScreen', () => { expect(screen.getByText('75%')).toBeTruthy(); // click_rate expect(screen.getByText('4.5')).toBeTruthy(); // avg_rating expect(screen.getByText('6')).toBeTruthy(); // total_reactions + // engaged (ADR-001): interactuaron pero no llegaron al enlace externo. + expect(screen.getByText(/2 interactuaron sin clicar/)).toBeTruthy(); }); it('shows the metrics to a manager too', () => { diff --git a/__tests__/integration/engagementDashboard.test.ts b/__tests__/integration/engagementDashboard.test.ts index eccc723..fa79cc2 100644 --- a/__tests__/integration/engagementDashboard.test.ts +++ b/__tests__/integration/engagementDashboard.test.ts @@ -136,6 +136,8 @@ describe('engagement dashboard (integration)', () => { unique_readers: 2, unique_clicks: 1, total_reactions: 1, + // engaged (ADR-001): el manager valoró sin clicar; el staff clicó, así que no cuenta. + engaged_users: 1, }); expect(row!.click_rate).toBeCloseTo(0.5); expect(row!.avg_rating).toBeCloseTo(4); diff --git a/__tests__/lib/engagementQueries.test.ts b/__tests__/lib/engagementQueries.test.ts index 32150e3..aaa61e5 100644 --- a/__tests__/lib/engagementQueries.test.ts +++ b/__tests__/lib/engagementQueries.test.ts @@ -12,6 +12,7 @@ type DailyRow = { avg_rating: number | null; total_ratings: number | null; total_reactions: number | null; + engaged_users: number | null; avg_seconds: number | null; avg_scroll: number | null; }; @@ -44,6 +45,7 @@ describe('listPostEngagement', () => { avg_rating: 4, total_ratings: 2, total_reactions: 1, + engaged_users: 1, avg_seconds: 15, avg_scroll: 0.6, }, @@ -54,6 +56,7 @@ describe('listPostEngagement', () => { avg_rating: 2, total_ratings: 1, total_reactions: 2, + engaged_users: 2, avg_seconds: 5, avg_scroll: 0.2, }, @@ -69,6 +72,8 @@ describe('listPostEngagement', () => { unique_readers: 5, // 2 + 3 unique_clicks: 3, // 1 + 2 total_reactions: 3, // 1 + 2 + // La vista imputa cada usuario engaged a su primer día, así que sumar no dobla. + engaged_users: 3, // 1 + 2 }); expect(row!.click_rate).toBeCloseTo(3 / 5); // avg_rating pondera por nº de valoraciones: (4*2 + 2*1) / 3 @@ -88,6 +93,7 @@ describe('listPostEngagement', () => { avg_rating: null, total_ratings: 0, total_reactions: 2, + engaged_users: 0, avg_seconds: null, avg_scroll: null, }, @@ -112,6 +118,7 @@ describe('listPostEngagement', () => { avg_rating: null, total_ratings: 0, total_reactions: 0, + engaged_users: 0, avg_seconds: null, avg_scroll: null, }; diff --git a/supabase/tests/rls/mv_post_engagement_daily.sql b/supabase/tests/rls/mv_post_engagement_daily.sql index 0209101..022621b 100644 --- a/supabase/tests/rls/mv_post_engagement_daily.sql +++ b/supabase/tests/rls/mv_post_engagement_daily.sql @@ -17,7 +17,7 @@ -- staff: aaaaaaaa-0000-0000-0000-000000000003 begin; -select plan(13); +select plan(15); create or replace function pg_temp.set_session(uid uuid) returns void language plpgsql as $$ @@ -128,6 +128,26 @@ select results_eq( 'día 1: señales opcionales avg_seconds=15.00, avg_scroll=0.600 (ADR-0006)' ); +-- ── engaged (ADR-001): interactuó y NO clicó, imputado a su primer día ────── +-- staff → valoró y reaccionó el día 1, pero CLICÓ → NO engaged +-- manager → valoró el día 1 y no clicó → engaged en el día 1 +-- admin → valoró el día 2 (sin sesión, sin clic) → engaged en el día 2 +select results_eq( + $$ select engaged_users from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid + and day = '2026-07-01'::date $$, + $$ values (1::bigint) $$, + 'día 1: engaged_users=1 (manager interactuó sin clicar; staff clicó)' +); + +select results_eq( + $$ select engaged_users from public.post_engagement_daily + where post_id = 'aaaaaaaa-4444-0000-0000-00000000000a'::uuid + and day = '2026-07-02'::date $$, + $$ values (1::bigint) $$, + 'día 2: engaged_users=1 (admin valoró sin sesión ni clic)' +); + -- ── 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 b72921a5ca4f453007f02c2efc19d5947552a058 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Sat, 11 Jul 2026 17:47:01 +0200 Subject: [PATCH 4/4] test(engagement): retry transient 5xx in the track-engagement e2e (#172) --- __tests__/integration/trackEngagement.test.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/__tests__/integration/trackEngagement.test.ts b/__tests__/integration/trackEngagement.test.ts index 4aa01da..01d4873 100644 --- a/__tests__/integration/trackEngagement.test.ts +++ b/__tests__/integration/trackEngagement.test.ts @@ -36,18 +36,41 @@ const staffClient = createClient(LOCAL_URL, LOCAL_ANON_KEY); type FnResponse = { status: number; body: unknown }; +const MAX_ATTEMPTS = 3; +const RETRY_DELAY_MS = 500; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// El edge runtime recicla workers (`policy = "per_worker"`), así que una petición +// suelta puede fallar con 5xx mientras arranca uno nuevo. El cliente real reintenta +// los 5xx (lib/engagement/queue.ts) y aquí se hace lo mismo: sin esto, un fallo +// transitorio se colaba en silencio y afloraba después como datos incorrectos. +// Los 4xx son definitivos y se devuelven tal cual (los tests de 400/401 dependen de ello). async function callFn(events: unknown, token: string | null): Promise { - const res = await fetch(FN_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - apikey: LOCAL_ANON_KEY, - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify(events), - }); - const body = await res.json().catch(() => null); - return { status: res.status, body }; + let last: FnResponse = { status: 0, body: null }; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(FN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: LOCAL_ANON_KEY, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(events), + }); + last = { status: res.status, body: await res.json().catch(() => null) }; + if (res.status < 500) return last; + } catch (e) { + last = { status: 0, body: String(e) }; // error de red + } + if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS); + } + + return last; } async function sessionRow(postId: string) { @@ -147,14 +170,16 @@ describe('track-engagement Edge Function (integration)', () => { const postId = await createPost(); const sid = crypto.randomUUID(); - await callFn([{ session_id: sid, post_id: postId, link_clicked: true }], staffToken); + const click = await callFn([{ session_id: sid, post_id: postId, link_clicked: true }], staffToken); + expect(click.status).toBe(200); expect(await sessionRow(postId)).toMatchObject({ status: 'clicked', link_clicked: true }); // A later event without a click must not revert clicked → viewed. - await callFn( + const later = await callFn( [{ session_id: sid, post_id: postId, link_clicked: false, focused_seconds_delta: 3 }], staffToken, ); + expect(later.status).toBe(200); expect(await sessionRow(postId)).toMatchObject({ status: 'clicked', link_clicked: true }); }); @@ -162,14 +187,17 @@ describe('track-engagement Edge Function (integration)', () => { const postId = await createPost(); const sid = crypto.randomUUID(); - await callFn( + const first = await callFn( [{ session_id: sid, post_id: postId, focused_seconds_delta: 5, max_scroll_pct: 0.3 }], staffToken, ); - await callFn( + expect(first.status).toBe(200); + + const second = await callFn( [{ session_id: sid, post_id: postId, focused_seconds_delta: 7, max_scroll_pct: 0.1 }], staffToken, ); + expect(second.status).toBe(200); const row = await sessionRow(postId); expect(row).toMatchObject({ focused_seconds: 12, max_scroll_pct: 0.3 });