Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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() $$
);
160 changes: 160 additions & 0 deletions supabase/tests/rls/mv_post_engagement_daily.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading