From 4d615991397e78c319942f4e45404581859a5236 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Fri, 10 Jul 2026 23:09:46 +0200 Subject: [PATCH 1/2] feat(engagement): align status and add behavioral columns to engagement_sessions (#178) --- lib/database.types.ts | 7 ++- ...000_alter_engagement_sessions_additive.sql | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260710000000_alter_engagement_sessions_additive.sql diff --git a/lib/database.types.ts b/lib/database.types.ts index c660b00..d5c09be 100644 --- a/lib/database.types.ts +++ b/lib/database.types.ts @@ -37,9 +37,11 @@ export type Database = { engagement_sessions: { Row: { device: string | null + focused_seconds: number id: string last_seen_at: string link_clicked: boolean + max_scroll_pct: number post_id: string started_at: string status: string @@ -47,9 +49,11 @@ export type Database = { } Insert: { device?: string | null + focused_seconds?: number id?: string last_seen_at?: string link_clicked?: boolean + max_scroll_pct?: number post_id: string started_at?: string status?: string @@ -57,9 +61,11 @@ export type Database = { } Update: { device?: string | null + focused_seconds?: number id?: string last_seen_at?: string link_clicked?: boolean + max_scroll_pct?: number post_id?: string started_at?: string status?: string @@ -564,4 +570,3 @@ export const Constants = { }, }, } as const - diff --git a/supabase/migrations/20260710000000_alter_engagement_sessions_additive.sql b/supabase/migrations/20260710000000_alter_engagement_sessions_additive.sql new file mode 100644 index 0000000..caf761a --- /dev/null +++ b/supabase/migrations/20260710000000_alter_engagement_sessions_additive.sql @@ -0,0 +1,48 @@ +-- Migration: alter engagement_sessions — alineación de status + columnas de comportamiento +-- Part of Epic N04 / Feature F-N04-02 (#174) / Issue I-F-N04-02-01 (#178) +-- refs: docs/adr/0001-engagement.md (status viewed/engaged/clicked) +-- docs/adr/0006-engagement-behavioral-signals.md (columnas aditivas) +-- +-- Cambio puramente ADITIVO sobre el esquema restaurado (20260618500000): +-- 1. Alinea `status` de active/idle/closed → viewed/engaged/clicked (canónico, ADR-001). +-- 2. Añade columnas opcionales `focused_seconds` y `max_scroll_pct` (ADR-0006). +-- Mantiene `id` PK, `unique (user_id, post_id)` y la FK `user_id → auth.users(id)`. +-- No dropea la tabla: las filas existentes quedan válidas (defaults en las nuevas columnas). +-- +-- Rollback (no versionado; el repo es forward-only vía `supabase db reset`): +-- alter table public.engagement_sessions +-- drop column max_scroll_pct, drop column focused_seconds; +-- alter table public.engagement_sessions drop constraint engagement_sessions_status_check; +-- alter table public.engagement_sessions alter column status set default 'active'; +-- alter table public.engagement_sessions +-- add constraint engagement_sessions_status_check check (status in ('active','idle','closed')); + +-- 1. Alinear el status al modelo canónico ADR-001 (viewed/engaged/clicked) +alter table public.engagement_sessions + drop constraint if exists engagement_sessions_status_check; + +update public.engagement_sessions + set status = 'viewed' + where status not in ('viewed', 'engaged', 'clicked'); + +alter table public.engagement_sessions + alter column status set default 'viewed'; + +alter table public.engagement_sessions + add constraint engagement_sessions_status_check + check (status in ('viewed', 'engaged', 'clicked')); + +-- 2. Señales de comportamiento OPCIONALES y aditivas (ADR-0006). +-- Default 0: no rompen filas previas ni gobiernan el status. +alter table public.engagement_sessions + add column if not exists focused_seconds integer not null default 0 + check (focused_seconds >= 0), + add column if not exists max_scroll_pct numeric(4,3) not null default 0 + check (max_scroll_pct >= 0 and max_scroll_pct <= 1); + +comment on column public.engagement_sessions.link_clicked is + 'Métrica principal: el usuario clicó el enlace externo. Append-only (nunca true→false).'; +comment on column public.engagement_sessions.focused_seconds is + 'OPCIONAL (ADR-0006): segundos de foco in-app acumulados. No gobierna status.'; +comment on column public.engagement_sessions.max_scroll_pct is + 'OPCIONAL (ADR-0006): scroll máximo de la sesión, monotónico ∈ [0,1]. No gobierna status.'; From c4292342b5012e7507e6cd4377af4c2d326e7180 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Fri, 10 Jul 2026 23:11:24 +0200 Subject: [PATCH 2/2] test(engagement): add schema/constraints pgTAP for engagement_sessions (#178) --- .../tests/rls/schema_engagement_sessions.sql | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 supabase/tests/rls/schema_engagement_sessions.sql diff --git a/supabase/tests/rls/schema_engagement_sessions.sql b/supabase/tests/rls/schema_engagement_sessions.sql new file mode 100644 index 0000000..2e06791 --- /dev/null +++ b/supabase/tests/rls/schema_engagement_sessions.sql @@ -0,0 +1,152 @@ +-- Schema/constraints tests: engagement_sessions (modelo aditivo) +-- Issue I-F-N04-02-01 (#178) — migración 20260710000000_alter_engagement_sessions_additive +-- refs: docs/adr/0001-engagement.md · docs/adr/0006-engagement-behavioral-signals.md +-- +-- Cubre la alineación de `status` (viewed/engaged/clicked) y las columnas aditivas +-- `focused_seconds` / `max_scroll_pct`, sus defaults, CHECKs, UPSERT y cascades. +-- +-- Seed UUIDs (supabase/seed.sql): +-- admin: aaaaaaaa-0000-0000-0000-000000000001 +-- manager: aaaaaaaa-0000-0000-0000-000000000002 +-- staff: aaaaaaaa-0000-0000-0000-000000000003 + +begin; +select plan(19); + +-- ── Estructura de columnas ────────────────────────────────────────────────── +select has_column('public', 'engagement_sessions', 'link_clicked', 'existe link_clicked'); +select has_column('public', 'engagement_sessions', 'status', 'existe status'); +select has_column('public', 'engagement_sessions', 'focused_seconds', 'existe focused_seconds (ADR-0006)'); +select has_column('public', 'engagement_sessions', 'max_scroll_pct', 'existe max_scroll_pct (ADR-0006)'); + +select col_type_is('public', 'engagement_sessions', 'focused_seconds', 'integer', 'focused_seconds es integer'); +select col_type_is('public', 'engagement_sessions', 'max_scroll_pct', 'numeric(4,3)', 'max_scroll_pct es numeric(4,3)'); + +-- ── Defaults ──────────────────────────────────────────────────────────────── +select col_default_is('public', 'engagement_sessions', 'link_clicked', 'false', 'link_clicked default false'); +select col_default_is('public', 'engagement_sessions', 'status', 'viewed', 'status default viewed (ADR-001)'); +select col_default_is('public', 'engagement_sessions', 'focused_seconds', '0', 'focused_seconds default 0'); +select col_default_is('public', 'engagement_sessions', 'max_scroll_pct', '0', 'max_scroll_pct default 0'); + +-- ── Legacy fuera: el modelo reading-session revertido (#226) no debe reaparecer ─ +select hasnt_column('public', 'engagement_sessions', 'session_id', 'no existe session_id (modelo revertido)'); +select hasnt_column('public', 'engagement_sessions', 'state', 'no existe state (modelo revertido)'); + +-- ── Fixtures (como postgres = bypass RLS) ─────────────────────────────────── +insert into public.posts (id, author_id, title, external_url) +select + 'aaaaaaaa-1111-0000-0000-000000000001'::uuid, + p.id, + 'Post for engagement schema test', + 'https://example.com/test' +from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid; + +-- ── CHECK de status: solo viewed/engaged/clicked ──────────────────────────── +select throws_ok( + $test$ + insert into public.engagement_sessions (user_id, post_id, status) + values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 'aaaaaaaa-1111-0000-0000-000000000001'::uuid, 'active') + $test$, + '23514', + null, + 'status rechaza el valor legacy active (solo viewed/engaged/clicked)' +); + +-- ── CHECK de columnas aditivas ────────────────────────────────────────────── +select throws_ok( + $test$ + insert into public.engagement_sessions (user_id, post_id, max_scroll_pct) + values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 'aaaaaaaa-1111-0000-0000-000000000001'::uuid, 1.5) + $test$, + '23514', + null, + 'max_scroll_pct = 1.5 viola el CHECK ∈ [0,1]' +); + +select throws_ok( + $test$ + insert into public.engagement_sessions (user_id, post_id, focused_seconds) + values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 'aaaaaaaa-1111-0000-0000-000000000001'::uuid, -1) + $test$, + '23514', + null, + 'focused_seconds = -1 viola el CHECK >= 0' +); + +-- ── UPSERT on conflict (user_id, post_id): acumula sin crear filas duplicadas ─ +insert into public.engagement_sessions (user_id, post_id, focused_seconds, max_scroll_pct) +values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 'aaaaaaaa-1111-0000-0000-000000000001'::uuid, 5, 0.30) +on conflict (user_id, post_id) do update set + focused_seconds = engagement_sessions.focused_seconds + excluded.focused_seconds, + max_scroll_pct = greatest(engagement_sessions.max_scroll_pct, excluded.max_scroll_pct); + +insert into public.engagement_sessions (user_id, post_id, focused_seconds, max_scroll_pct) +values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid, + 'aaaaaaaa-1111-0000-0000-000000000001'::uuid, 7, 0.10) +on conflict (user_id, post_id) do update set + focused_seconds = engagement_sessions.focused_seconds + excluded.focused_seconds, + max_scroll_pct = greatest(engagement_sessions.max_scroll_pct, excluded.max_scroll_pct); + +select results_eq( + $test$ + select focused_seconds, max_scroll_pct + from public.engagement_sessions + where user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid + and post_id = 'aaaaaaaa-1111-0000-0000-000000000001'::uuid + $test$, + $expected$ values (12, 0.300::numeric(4,3)) $expected$, + 'UPSERT acumula focused_seconds (5+7=12) y toma el máximo de max_scroll_pct (0.30)' +); + +select results_eq( + $test$ + select count(*)::int from public.engagement_sessions + where user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid + and post_id = 'aaaaaaaa-1111-0000-0000-000000000001'::uuid + $test$, + $expected$ values (1) $expected$, + 'un par (user_id, post_id) = 1 fila tras varios UPSERT' +); + +-- ── Cascade al borrar el post ─────────────────────────────────────────────── +delete from public.posts where id = 'aaaaaaaa-1111-0000-0000-000000000001'::uuid; + +select results_eq( + $test$ select count(*)::int from public.engagement_sessions + where post_id = 'aaaaaaaa-1111-0000-0000-000000000001'::uuid $test$, + $expected$ values (0) $expected$, + 'borrar el post elimina sus engagement_sessions (cascade)' +); + +-- ── Cascade al borrar el usuario (auth.users) ─────────────────────────────── +insert into public.posts (id, author_id, title, external_url) +select + 'aaaaaaaa-1111-0000-0000-000000000002'::uuid, + p.id, + 'Post for cascade-user test', + 'https://example.com/test2' +from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid; + +-- Usuario efímero solo para probar el cascade sobre auth.users +insert into auth.users (id, email) +values ('aaaaaaaa-0000-0000-0000-0000000000ff'::uuid, 'cascade-test@nun-ibiza.dev'); + +insert into public.engagement_sessions (user_id, post_id) +values ('aaaaaaaa-0000-0000-0000-0000000000ff'::uuid, + 'aaaaaaaa-1111-0000-0000-000000000002'::uuid); + +delete from auth.users where id = 'aaaaaaaa-0000-0000-0000-0000000000ff'::uuid; + +select results_eq( + $test$ select count(*)::int from public.engagement_sessions + where user_id = 'aaaaaaaa-0000-0000-0000-0000000000ff'::uuid $test$, + $expected$ values (0) $expected$, + 'borrar el usuario (auth.users) elimina sus engagement_sessions (cascade)' +); + +select * from finish(); +rollback;