diff --git a/__tests__/components/CommentComposer.test.tsx b/__tests__/components/CommentComposer.test.tsx
new file mode 100644
index 0000000..4459084
--- /dev/null
+++ b/__tests__/components/CommentComposer.test.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+
+import { CommentComposer } from '@/components/comments';
+
+// CommentComposer importa MAX_COMMENT_LENGTH de @/lib/comments, que a su vez carga
+// @/lib/supabase (createClient con env de Expo, no disponible en jest).
+jest.mock('@/lib/supabase', () => ({ supabase: {} }));
+
+describe('CommentComposer', () => {
+ it('el botón está deshabilitado cuando el campo está vacío', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render();
+
+ fireEvent.press(getByLabelText('Comentar'));
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('envía el comentario recortado y limpia el campo', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render();
+
+ const input = getByLabelText('Nuevo comentario');
+ fireEvent.changeText(input, ' hola mundo ');
+ fireEvent.press(getByLabelText('Comentar'));
+
+ expect(onSubmit).toHaveBeenCalledWith('hola mundo');
+ });
+
+ it('no envía cuando solo hay espacios', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render();
+
+ fireEvent.changeText(getByLabelText('Nuevo comentario'), ' ');
+ fireEvent.press(getByLabelText('Comentar'));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('muestra el contador de caracteres', () => {
+ const { getByText, getByLabelText } = render();
+
+ fireEvent.changeText(getByLabelText('Nuevo comentario'), 'abc');
+ expect(getByText('3/2000')).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/PostCard.test.tsx b/__tests__/components/PostCard.test.tsx
index 501327d..fab6876 100644
--- a/__tests__/components/PostCard.test.tsx
+++ b/__tests__/components/PostCard.test.tsx
@@ -18,6 +18,8 @@ jest.mock('expo-image', () => {
jest.mock('lucide-react-native', () => ({
ExternalLink: () => null,
+ MessageCircle: () => null,
+ Star: () => null,
}));
jest.mock('@/hooks/usePostReactions', () => ({
@@ -49,6 +51,9 @@ const POST: PostWithAuthor = {
updated_at: '2026-06-30T09:00:00Z',
deleted_at: null,
author: { name: 'Juan', surname: 'García' },
+ comments_count: 4,
+ rating_average: 4.25,
+ rating_count: 8,
};
// ─── Tests ───────────────────────────────────────────────────────────────────
@@ -91,6 +96,18 @@ describe('PostCard', () => {
expect(getByText('—')).toBeTruthy();
});
+ it('renders the rounded rating average when the post has ratings', () => {
+ const { getByText } = render();
+ expect(getByText('4.3')).toBeTruthy(); // 4.25 -> toFixed(1)
+ });
+
+ it('does not render a rating when the post has no ratings', () => {
+ const { queryByText } = render(
+ ,
+ );
+ expect(queryByText('0.0')).toBeNull();
+ });
+
it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByRole } = render();
diff --git a/__tests__/components/PostDetailScreen.test.tsx b/__tests__/components/PostDetailScreen.test.tsx
index ea8d53a..e44fee4 100644
--- a/__tests__/components/PostDetailScreen.test.tsx
+++ b/__tests__/components/PostDetailScreen.test.tsx
@@ -23,10 +23,54 @@ jest.mock('@/hooks/usePostReactions', () => ({
}),
}));
+jest.mock('@/hooks/usePostRating', () => ({
+ usePostRating: () => ({
+ myRating: null,
+ average: 0,
+ count: 0,
+ loading: false,
+ rate: jest.fn(),
+ }),
+}));
+
jest.mock('@/components/reactions', () => ({
ReactionPicker: () => null,
}));
+jest.mock('@/hooks/useComments', () => ({
+ useComments: () => ({
+ comments: [],
+ total: 0,
+ loading: false,
+ loadingMore: false,
+ hasMore: false,
+ error: null,
+ loadMore: jest.fn(),
+ refresh: jest.fn(),
+ add: jest.fn(),
+ remove: jest.fn(),
+ }),
+}));
+
+jest.mock('@/hooks/useSession', () => ({
+ useSession: () => ({ session: null, profile: null }),
+}));
+
+jest.mock('@/components/comments', () => ({
+ CommentComposer: () => null,
+ CommentsList: () => null,
+}));
+
+const mockTrackLinkClick = jest.fn().mockResolvedValue(undefined);
+jest.mock('@/lib/engagement', () => ({
+ trackLinkClick: (...args: unknown[]) => mockTrackLinkClick(...args),
+ createEngagementSink: () => jest.fn(),
+}));
+
+jest.mock('@/hooks/usePostEngagement', () => ({
+ usePostEngagement: () => ({ onScroll: jest.fn(), sessionId: 'sess-test' }),
+}));
+
jest.mock('expo-router', () => ({
useLocalSearchParams: () => ({ id: 'post-1' }),
useRouter: () => ({ back: mockBack }),
@@ -111,7 +155,19 @@ describe('PostDetailScreen', () => {
expect(screen.queryByTestId('markdown-body')).toBeNull();
});
- it('opens external_url when "Leer noticia" is tapped', () => {
+ it('tracks the click and opens external_url when "Leer noticia" is tapped', () => {
+ const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as never);
+ mockUsePostDetail.mockReturnValue({ post: POST, loading: false, error: null });
+
+ render();
+ fireEvent.press(screen.getByLabelText('Leer noticia →'));
+
+ expect(mockTrackLinkClick).toHaveBeenCalledWith('post-1');
+ expect(openURLSpy).toHaveBeenCalledWith('https://example.com/noticia');
+ });
+
+ it('still opens external_url even if tracking rejects (fire-and-no-wait)', () => {
+ mockTrackLinkClick.mockRejectedValueOnce(new Error('network'));
const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as never);
mockUsePostDetail.mockReturnValue({ post: POST, loading: false, error: null });
diff --git a/__tests__/components/StarRating.test.tsx b/__tests__/components/StarRating.test.tsx
new file mode 100644
index 0000000..938efba
--- /dev/null
+++ b/__tests__/components/StarRating.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+
+import { StarRating } from '@/components/ui';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('lucide-react-native', () => ({
+ Star: () => null,
+}));
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('StarRating', () => {
+ it('renders 5 star buttons', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ expect(getAllByRole('button')).toHaveLength(5);
+ });
+
+ it('marks the chosen star as selected', () => {
+ const { getByLabelText } = render(
+ ,
+ );
+ expect(getByLabelText('Valorar con 3 estrellas').props.accessibilityState.selected).toBe(true);
+ expect(getByLabelText('Valorar con 2 estrellas').props.accessibilityState.selected).toBe(false);
+ expect(getByLabelText('Valorar con 4 estrellas').props.accessibilityState.selected).toBe(false);
+ });
+
+ it('calls onRate with the pressed star number', () => {
+ const onRate = jest.fn();
+ const { getByLabelText } = render(
+ ,
+ );
+ fireEvent.press(getByLabelText('Valorar con 4 estrellas'));
+ expect(onRate).toHaveBeenCalledWith(4);
+ });
+
+ it('shows the average and a pluralised count', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('3.8 · 8 valoraciones')).toBeTruthy();
+ });
+
+ it('shows the singular label for a single rating', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('5.0 · 1 valoración')).toBeTruthy();
+ });
+
+ it('shows a placeholder when there are no ratings', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Sé el primero en valorar')).toBeTruthy();
+ });
+
+ it('disables all buttons when disabled', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ getAllByRole('button').forEach((btn) =>
+ expect(btn.props.accessibilityState.disabled).toBe(true),
+ );
+ });
+});
diff --git a/__tests__/components/__snapshots__/PostCard.test.tsx.snap b/__tests__/components/__snapshots__/PostCard.test.tsx.snap
index 6a89630..963c975 100644
--- a/__tests__/components/__snapshots__/PostCard.test.tsx.snap
+++ b/__tests__/components/__snapshots__/PostCard.test.tsx.snap
@@ -84,6 +84,24 @@ exports[`PostCard matches snapshot 1`] = `
30 jun
+
+
+
+ 4.3
+
+
+
+ 4
+
+
diff --git a/__tests__/hooks/useComments.test.ts b/__tests__/hooks/useComments.test.ts
new file mode 100644
index 0000000..d643930
--- /dev/null
+++ b/__tests__/hooks/useComments.test.ts
@@ -0,0 +1,179 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { useComments } from '@/hooks/useComments';
+import { createComment, deleteComment, getCommentsCount, listComments } from '@/lib/comments';
+import type { CommentAuthor, CommentWithAuthor } from '@/lib/comments';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/supabase', () => ({
+ supabase: { from: jest.fn(), auth: { getUser: jest.fn() } },
+}));
+
+jest.mock('@/lib/comments', () => ({
+ listComments: jest.fn(),
+ createComment: jest.fn(),
+ deleteComment: jest.fn(),
+ getCommentsCount: jest.fn(),
+ MAX_COMMENT_LENGTH: 2000,
+}));
+
+const mockList = listComments as jest.MockedFunction;
+const mockCreate = createComment as jest.MockedFunction;
+const mockDelete = deleteComment as jest.MockedFunction;
+const mockCount = getCommentsCount as jest.MockedFunction;
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+const AUTHOR: CommentAuthor = { user_id: 'u1', full_name: 'Ana', avatar_url: null };
+
+function comment(id: string, authorId = 'u1'): CommentWithAuthor {
+ return {
+ id,
+ post_id: 'p1',
+ author_id: authorId,
+ body: `body-${id}`,
+ created_at: `2026-07-08T10:0${id}:00Z`,
+ updated_at: `2026-07-08T10:0${id}:00Z`,
+ author: { user_id: authorId, full_name: 'Ana', avatar_url: null },
+ };
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockCount.mockResolvedValue(0);
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('useComments', () => {
+ it('carga los comentarios iniciales al montar', async () => {
+ mockList.mockResolvedValueOnce({ rows: [comment('1')], nextCursor: null });
+ mockCount.mockResolvedValueOnce(1);
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.comments).toHaveLength(1);
+ expect(result.current.total).toBe(1);
+ expect(result.current.hasMore).toBe(false);
+ expect(mockList).toHaveBeenCalledWith({ postId: 'p1', pageSize: 20 });
+ expect(mockCount).toHaveBeenCalledWith('p1');
+ });
+
+ it('expone error cuando falla la carga', async () => {
+ mockList.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBe('No se pudieron cargar los comentarios.');
+ });
+
+ it('add inserta de forma optimista y sustituye por la fila del servidor', async () => {
+ mockList.mockResolvedValueOnce({ rows: [], nextCursor: null });
+ mockCount.mockResolvedValueOnce(0);
+ mockCreate.mockResolvedValueOnce({
+ id: 'server-1',
+ post_id: 'p1',
+ author_id: 'u1',
+ body: 'hola',
+ created_at: '2026-07-08T11:00:00Z',
+ updated_at: '2026-07-08T11:00:00Z',
+ });
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.add('hola');
+ });
+
+ expect(result.current.comments).toHaveLength(1);
+ expect(result.current.comments[0]!.id).toBe('server-1');
+ expect(result.current.comments[0]!.author).toEqual(AUTHOR);
+ expect(result.current.total).toBe(1);
+ expect(mockCreate).toHaveBeenCalledWith({ postId: 'p1', body: 'hola' });
+ });
+
+ it('add hace rollback si el servidor falla', async () => {
+ mockList.mockResolvedValueOnce({ rows: [], nextCursor: null });
+ mockCreate.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.add('hola');
+ });
+
+ expect(result.current.comments).toHaveLength(0);
+ });
+
+ it('no envía comentarios vacíos', async () => {
+ mockList.mockResolvedValueOnce({ rows: [], nextCursor: null });
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.add(' ');
+ });
+
+ expect(mockCreate).not.toHaveBeenCalled();
+ });
+
+ it('remove borra de forma optimista', async () => {
+ mockList.mockResolvedValueOnce({ rows: [comment('1'), comment('2')], nextCursor: null });
+ mockCount.mockResolvedValueOnce(2);
+ mockDelete.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.remove('1');
+ });
+
+ expect(result.current.comments.map((c) => c.id)).toEqual(['2']);
+ expect(result.current.total).toBe(1);
+ expect(mockDelete).toHaveBeenCalledWith('1');
+ });
+
+ it('remove restaura el comentario si el borrado falla', async () => {
+ mockList.mockResolvedValueOnce({ rows: [comment('1'), comment('2')], nextCursor: null });
+ mockDelete.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.remove('1');
+ });
+
+ expect(result.current.comments.map((c) => c.id)).toEqual(['1', '2']);
+ });
+
+ it('loadMore pagina sin duplicados', async () => {
+ mockList.mockResolvedValueOnce({
+ rows: [comment('1')],
+ nextCursor: { created_at: '2026-07-08T10:01:00Z', id: '1' },
+ });
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.hasMore).toBe(true);
+
+ // La segunda página devuelve '1' (duplicado) y '2' (nuevo)
+ mockList.mockResolvedValueOnce({ rows: [comment('1'), comment('2')], nextCursor: null });
+
+ await act(async () => {
+ await result.current.loadMore();
+ });
+
+ expect(result.current.comments.map((c) => c.id)).toEqual(['1', '2']);
+ expect(result.current.hasMore).toBe(false);
+ });
+});
diff --git a/__tests__/hooks/useFeed.test.ts b/__tests__/hooks/useFeed.test.ts
index 67fba13..26e6077 100644
--- a/__tests__/hooks/useFeed.test.ts
+++ b/__tests__/hooks/useFeed.test.ts
@@ -31,6 +31,9 @@ const BASE_POST = {
updated_at: '2026-06-30T09:00:00Z',
deleted_at: null,
author: { name: 'Juan', surname: 'García' },
+ comments_count: 0,
+ rating_average: 0,
+ rating_count: 0,
};
function makePosts(count: number) {
diff --git a/__tests__/hooks/usePostEngagement.test.ts b/__tests__/hooks/usePostEngagement.test.ts
new file mode 100644
index 0000000..8cfabf6
--- /dev/null
+++ b/__tests__/hooks/usePostEngagement.test.ts
@@ -0,0 +1,187 @@
+import { act, renderHook } from '@testing-library/react-native';
+import {
+ AppState,
+ type AppStateStatus,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
+} from 'react-native';
+import { useIsFocused } from '@react-navigation/native';
+
+import { usePostEngagement, type EngagementEvent } from '@/hooks/usePostEngagement';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@react-navigation/native', () => ({
+ useIsFocused: jest.fn(() => true),
+}));
+
+let mockUuidCounter = 0;
+jest.mock('expo-crypto', () => ({
+ randomUUID: jest.fn(() => `sess-${++mockUuidCounter}`),
+}));
+
+const mockUseIsFocused = useIsFocused as jest.MockedFunction;
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+// Captura el handler de AppState para simular transiciones active/background.
+function spyAppState() {
+ let handler: ((state: AppStateStatus) => void) | undefined;
+ const remove = jest.fn();
+ const addSpy = jest
+ .spyOn(AppState, 'addEventListener')
+ .mockImplementation((_event, cb) => {
+ handler = cb as (state: AppStateStatus) => void;
+ return { remove } as never;
+ });
+ return {
+ addSpy,
+ remove,
+ change: (state: AppStateStatus) => act(() => handler?.(state)),
+ };
+}
+
+function scrollEvent(pct: number): NativeSyntheticEvent {
+ const layoutHeight = 100;
+ const contentHeight = 200; // scrollable = 100
+ return {
+ nativeEvent: {
+ contentOffset: { x: 0, y: pct * (contentHeight - layoutHeight) },
+ contentSize: { width: 0, height: contentHeight },
+ layoutMeasurement: { width: 0, height: layoutHeight },
+ zoomScale: 1,
+ contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
+ },
+ } as NativeSyntheticEvent;
+}
+
+const HEARTBEAT = 5000;
+
+function ticks(events: EngagementEvent[]) {
+ return events.filter((e): e is Extract => e.type === 'tick');
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockUuidCounter = 0;
+ mockUseIsFocused.mockReturnValue(true);
+ (AppState as unknown as { currentState: AppStateStatus }).currentState = 'active';
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+});
+
+describe('usePostEngagement', () => {
+ it('genera un sessionId estable durante toda la vida del hook', () => {
+ spyAppState();
+ const { result, rerender } = renderHook(() => usePostEngagement('post-1'));
+
+ const first = result.current.sessionId;
+ expect(first).toBe('sess-1');
+
+ // Un re-render (p. ej. por cambio de foco) no debe cambiar el id.
+ mockUseIsFocused.mockReturnValue(false);
+ rerender({});
+ expect(result.current.sessionId).toBe(first);
+ });
+
+ it('emite un init al montar y un tick por heartbeat', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { wordCount: 120, onEvent, heartbeatMs: HEARTBEAT }));
+
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ expect(onEvent).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: 'init', postId: 'post-1', wordCount: 120, sessionId: 'sess-1' }),
+ );
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 2));
+ const events = onEvent.mock.calls.map((c) => c[0] as EngagementEvent);
+ expect(ticks(events)).toHaveLength(2);
+ });
+
+ it('acumula focusedSeconds mientras hay foco real', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }));
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 2));
+ const events = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent));
+ expect(events[events.length - 1]!.focusedSeconds).toBe(10);
+ });
+
+ it('no incrementa focusedSeconds en background y lo retoma al volver a active', () => {
+ const app = spyAppState();
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }));
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT)); // +5s activo
+ app.change('background');
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 2)); // congelado
+ app.change('active');
+ act(() => jest.advanceTimersByTime(HEARTBEAT)); // +5s activo
+
+ const events = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent));
+ // Sigue emitiendo ticks en background pero el contador no avanza allí.
+ expect(events.map((e) => e.focusedSeconds)).toEqual([5, 5, 5, 10]);
+ });
+
+ it('detiene la cuenta cuando useIsFocused es false aunque AppState siga active', () => {
+ spyAppState();
+ mockUseIsFocused.mockReturnValue(false);
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }));
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 3));
+ const events = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent));
+ expect(events.every((e) => e.focusedSeconds === 0)).toBe(true);
+ });
+
+ it('maxScrollPct solo aumenta y nunca decrece', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ const { result } = renderHook(() =>
+ usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }),
+ );
+
+ act(() => result.current.onScroll(scrollEvent(0.5)));
+ act(() => result.current.onScroll(scrollEvent(0.8)));
+ act(() => result.current.onScroll(scrollEvent(0.3))); // scroll hacia arriba
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT));
+ const last = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent)).at(-1)!;
+ expect(last.maxScrollPct).toBeCloseTo(0.8);
+ });
+
+ it('clampa maxScrollPct al rango [0, 1]', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ const { result } = renderHook(() =>
+ usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }),
+ );
+
+ act(() => result.current.onScroll(scrollEvent(3))); // overscroll
+ act(() => jest.advanceTimersByTime(HEARTBEAT));
+ const last = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent)).at(-1)!;
+ expect(last.maxScrollPct).toBe(1);
+ });
+
+ it('limpia timers y listener tras unmount sin emitir más eventos', () => {
+ const app = spyAppState();
+ const onEvent = jest.fn();
+ const { unmount } = renderHook(() =>
+ usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }),
+ );
+
+ unmount();
+ expect(app.remove).toHaveBeenCalledTimes(1);
+
+ onEvent.mockClear();
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 3));
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/hooks/usePostRating.test.ts b/__tests__/hooks/usePostRating.test.ts
new file mode 100644
index 0000000..d69008d
--- /dev/null
+++ b/__tests__/hooks/usePostRating.test.ts
@@ -0,0 +1,118 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { usePostRating } from '@/hooks/usePostRating';
+import { getRatingState, upsertRating } from '@/lib/ratings';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/supabase', () => ({
+ supabase: { from: jest.fn(), auth: { getUser: jest.fn() } },
+}));
+
+jest.mock('@/lib/ratings', () => ({
+ getRatingState: jest.fn(),
+ upsertRating: jest.fn(),
+ EMPTY_RATING: { myRating: null, average: 0, count: 0 },
+}));
+
+const mockGetRatingState = getRatingState as jest.MockedFunction;
+const mockUpsertRating = upsertRating as jest.MockedFunction;
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('usePostRating', () => {
+ it('loads the initial rating state on mount', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: 4, average: 4.2, count: 5 });
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.myRating).toBe(4);
+ expect(result.current.average).toBe(4.2);
+ expect(result.current.count).toBe(5);
+ expect(mockGetRatingState).toHaveBeenCalledWith('post-1');
+ });
+
+ it('falls back to empty state on fetch error', async () => {
+ mockGetRatingState.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.myRating).toBeNull();
+ expect(result.current.average).toBe(0);
+ expect(result.current.count).toBe(0);
+ });
+
+ it('adds a first rating optimistically and increments the count', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: null, average: 4, count: 2 });
+ mockUpsertRating.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(5);
+ });
+
+ // previous sum 4*2=8, +5 → 13 over 3 ratings
+ expect(result.current.myRating).toBe(5);
+ expect(result.current.count).toBe(3);
+ expect(result.current.average).toBeCloseTo(13 / 3);
+ expect(mockUpsertRating).toHaveBeenCalledWith('post-1', 5);
+ });
+
+ it('updates an existing rating in place without changing the count', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: 2, average: 3, count: 4 });
+ mockUpsertRating.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(4);
+ });
+
+ // previous sum 3*4=12, -2 +4 → 14 over 4 ratings
+ expect(result.current.myRating).toBe(4);
+ expect(result.current.count).toBe(4);
+ expect(result.current.average).toBeCloseTo(3.5);
+ expect(mockUpsertRating).toHaveBeenCalledWith('post-1', 4);
+ });
+
+ it('does not upsert when reselecting the same rating', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: 3, average: 3, count: 1 });
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(3);
+ });
+
+ expect(mockUpsertRating).not.toHaveBeenCalled();
+ expect(result.current.myRating).toBe(3);
+ expect(result.current.count).toBe(1);
+ });
+
+ it('rolls back the optimistic update on error', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: null, average: 2, count: 1 });
+ mockUpsertRating.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(5);
+ });
+
+ expect(result.current.myRating).toBeNull();
+ expect(result.current.average).toBe(2);
+ expect(result.current.count).toBe(1);
+ expect(mockUpsertRating).toHaveBeenCalledWith('post-1', 5);
+ });
+});
diff --git a/__tests__/lib/engagement.test.ts b/__tests__/lib/engagement.test.ts
new file mode 100644
index 0000000..7ce1022
--- /dev/null
+++ b/__tests__/lib/engagement.test.ts
@@ -0,0 +1,34 @@
+import { trackLinkClick } from '@/lib/engagement';
+import { supabase } from '@/lib/supabase';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/supabase', () => ({
+ supabase: { functions: { invoke: jest.fn() } },
+}));
+
+const mockInvoke = supabase.functions.invoke as jest.MockedFunction<
+ typeof supabase.functions.invoke
+>;
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('trackLinkClick', () => {
+ it('invokes track-engagement with post_id and link_clicked', async () => {
+ mockInvoke.mockResolvedValueOnce({ data: { data: {} }, error: null });
+
+ await trackLinkClick('post-1');
+
+ expect(mockInvoke).toHaveBeenCalledWith('track-engagement', {
+ body: { post_id: 'post-1', link_clicked: true },
+ });
+ });
+
+ it('throws when the edge function returns an error', async () => {
+ mockInvoke.mockResolvedValueOnce({ data: null, error: new Error('boom') });
+
+ await expect(trackLinkClick('post-1')).rejects.toThrow('boom');
+ });
+});
diff --git a/__tests__/lib/engagement/queue.test.ts b/__tests__/lib/engagement/queue.test.ts
new file mode 100644
index 0000000..70f8be3
--- /dev/null
+++ b/__tests__/lib/engagement/queue.test.ts
@@ -0,0 +1,205 @@
+import type { EngagementPayload } from '@/lib/engagement/queue';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+const mockStore = new Map();
+
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ __esModule: true,
+ default: {
+ getItem: jest.fn((k: string) => Promise.resolve(mockStore.get(k) ?? null)),
+ setItem: jest.fn((k: string, v: string) => {
+ mockStore.set(k, v);
+ return Promise.resolve();
+ }),
+ removeItem: jest.fn((k: string) => {
+ mockStore.delete(k);
+ return Promise.resolve();
+ }),
+ },
+}));
+
+const mockGetSession = jest.fn();
+const mockRefreshSession = jest.fn();
+jest.mock('@/lib/supabase', () => ({
+ supabase: {
+ auth: {
+ getSession: (...a: unknown[]) => mockGetSession(...a),
+ refreshSession: (...a: unknown[]) => mockRefreshSession(...a),
+ },
+ },
+}));
+
+jest.mock('expo-constants', () => ({
+ __esModule: true,
+ default: {
+ expoConfig: {
+ extra: { supabaseUrl: 'https://test.supabase.co', supabaseAnonKey: 'anon-key' },
+ },
+ },
+}));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+const QUEUE_KEY = '@engagement/queue';
+
+function payload(n: number): EngagementPayload {
+ return {
+ session_id: `sess-${n}`,
+ post_id: 'post-1',
+ focused_seconds_delta: 5,
+ max_scroll_pct: 0.5,
+ client_ts: new Date(n).toISOString(),
+ };
+}
+
+function seed(items: EngagementPayload[]): void {
+ mockStore.set(QUEUE_KEY, JSON.stringify(items));
+}
+
+function stored(): EngagementPayload[] {
+ const raw = mockStore.get(QUEUE_KEY);
+ return raw ? (JSON.parse(raw) as EngagementPayload[]) : [];
+}
+
+function resp(status: number): Response {
+ return { ok: status >= 200 && status < 300, status } as unknown as Response;
+}
+
+// Módulo re-importado en cada test para resetear su estado interno (backoff, flags).
+let queue: typeof import('@/lib/engagement/queue');
+
+beforeEach(() => {
+ jest.resetModules();
+ jest.useFakeTimers();
+ mockStore.clear();
+ jest.clearAllMocks();
+ mockGetSession.mockResolvedValue({ data: { session: { access_token: 'jwt' } } });
+ mockRefreshSession.mockResolvedValue({ data: { session: null }, error: null });
+ global.fetch = jest.fn();
+ // require (no import) para reimportar el módulo con estado interno fresco tras resetModules.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ queue = require('@/lib/engagement/queue');
+});
+
+afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('engagement queue', () => {
+ it('persiste eventos en AsyncStorage bajo @engagement/queue en orden FIFO', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('offline'));
+
+ await queue.enqueue(payload(1));
+ await queue.enqueue(payload(2));
+
+ const persisted = stored();
+ expect(persisted).toHaveLength(2);
+ expect(persisted.map((p) => p.session_id)).toEqual(['sess-1', 'sess-2']);
+ expect(await queue.size()).toBe(2);
+ });
+
+ it('descarta los más antiguos y loguea dropped_count al superar 500', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('offline'));
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ seed(Array.from({ length: 500 }, (_, i) => payload(i)));
+
+ await queue.enqueue(payload(500));
+
+ const persisted = stored();
+ expect(persisted).toHaveLength(500);
+ expect(persisted[0]!.session_id).toBe('sess-1'); // sess-0 descartado
+ expect(persisted[499]!.session_id).toBe('sess-500');
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('descartados 1'),
+ expect.objectContaining({ dropped_count: 1 }),
+ );
+ warn.mockRestore();
+ });
+
+ it('drena la cola en lotes de ≤50 con red activa', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue(resp(200));
+ seed(Array.from({ length: 120 }, (_, i) => payload(i)));
+
+ await queue.flush();
+
+ expect(global.fetch).toHaveBeenCalledTimes(3); // 50 + 50 + 20
+ const firstBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0]![1].body);
+ expect(firstBody).toHaveLength(50);
+ expect(await queue.size()).toBe(0);
+ });
+
+ it('envía Authorization Bearer con el token de la sesión', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue(resp(200));
+ seed([payload(1)]);
+
+ await queue.flush();
+
+ const [url, init] = (global.fetch as jest.Mock).mock.calls[0]!;
+ expect(url).toBe('https://test.supabase.co/functions/v1/track-engagement');
+ expect(init.headers.Authorization).toBe('Bearer jwt');
+ expect(init.headers.apikey).toBe('anon-key');
+ });
+
+ it('mantiene el lote y programa reintento con backoff 1s→2s ante error de red', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('offline'));
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
+ seed([payload(1)]);
+
+ await queue.flush();
+ expect(await queue.size()).toBe(1); // no se pierde
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 1000);
+
+ // El timer de reintento vuelve a fallar → siguiente backoff 2s.
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 2000);
+ setTimeoutSpy.mockRestore();
+ });
+
+ it('ante 401 refresca la sesión y reintenta una vez', async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce(resp(401))
+ .mockResolvedValueOnce(resp(200));
+ seed([payload(1)]);
+
+ await queue.flush();
+
+ expect(mockRefreshSession).toHaveBeenCalledTimes(1);
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(await queue.size()).toBe(0);
+ });
+
+ it('descarta el lote ante un 4xx permanente (no-401)', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue(resp(400));
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ seed([payload(1)]);
+
+ await queue.flush();
+
+ expect(mockRefreshSession).not.toHaveBeenCalled();
+ expect(await queue.size()).toBe(0); // descartado, no reintentado
+ warn.mockRestore();
+ });
+
+ it('no lanza flushes concurrentes', async () => {
+ let resolveFetch: (r: Response) => void = () => {};
+ (global.fetch as jest.Mock).mockReturnValue(
+ new Promise((r) => {
+ resolveFetch = r;
+ }),
+ );
+ seed([payload(1), payload(2)]);
+
+ const first = queue.flush();
+ const second = queue.flush(); // debe ser no-op mientras el primero está en curso
+
+ resolveFetch(resp(200));
+ await Promise.all([first, second]);
+
+ // Un único ciclo de drenaje: 2 eventos → 1 request (batch).
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/lib/engagement/sink.test.ts b/__tests__/lib/engagement/sink.test.ts
new file mode 100644
index 0000000..8205343
--- /dev/null
+++ b/__tests__/lib/engagement/sink.test.ts
@@ -0,0 +1,85 @@
+import type { EngagementEvent } from '@/hooks/usePostEngagement';
+import { createEngagementSink } from '@/lib/engagement/sink';
+import type { EngagementPayload } from '@/lib/engagement/queue';
+
+// Cortamos la cadena de import a la cola real (y a supabase): estos tests
+// inyectan su propio sink, así que enqueue nunca se usa.
+jest.mock('@/lib/engagement/queue', () => ({ enqueue: jest.fn() }));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function init(overrides?: Partial>): EngagementEvent {
+ return {
+ type: 'init',
+ sessionId: 'sess-1',
+ postId: 'post-1',
+ wordCount: 100,
+ startedAt: '2026-07-09T10:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function tick(focusedSeconds: number, maxScrollPct = 0, at = '2026-07-09T10:00:05.000Z'): EngagementEvent {
+ return { type: 'tick', sessionId: 'sess-1', postId: 'post-1', focusedSeconds, maxScrollPct, at };
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('createEngagementSink', () => {
+ it('encola un payload con delta 0 al recibir init', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+
+ expect(out).toHaveBeenCalledWith({
+ session_id: 'sess-1',
+ post_id: 'post-1',
+ focused_seconds_delta: 0,
+ max_scroll_pct: 0,
+ client_ts: '2026-07-09T10:00:00.000Z',
+ });
+ });
+
+ it('calcula focused_seconds_delta a partir del acumulado del hook', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+ sink(tick(5)); // 5 - 0
+ sink(tick(10)); // 10 - 5
+ sink(tick(10)); // 10 - 10 = 0 (background)
+
+ const deltas = out.mock.calls.map((c) => c[0].focused_seconds_delta);
+ expect(deltas).toEqual([0, 5, 5, 0]);
+ });
+
+ it('propaga max_scroll_pct y client_ts del tick', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+ sink(tick(5, 0.72, '2026-07-09T10:00:05.500Z'));
+
+ expect(out).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ max_scroll_pct: 0.72,
+ client_ts: '2026-07-09T10:00:05.500Z',
+ focused_seconds_delta: 5,
+ }),
+ );
+ });
+
+ it('reinicia el acumulado en cada init (nueva sesión)', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+ sink(tick(15));
+ sink(init({ sessionId: 'sess-2' })); // reset
+ sink(tick(5));
+
+ const last = out.mock.calls.at(-1)![0];
+ expect(last.focused_seconds_delta).toBe(5); // no 5 - 15
+ });
+});
diff --git a/__tests__/lib/engagement/sync.test.ts b/__tests__/lib/engagement/sync.test.ts
new file mode 100644
index 0000000..a23d939
--- /dev/null
+++ b/__tests__/lib/engagement/sync.test.ts
@@ -0,0 +1,57 @@
+import type { NetInfoState } from '@react-native-community/netinfo';
+
+import { startEngagementSync } from '@/lib/engagement/sync';
+import { flush } from '@/lib/engagement/queue';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+let netInfoHandler: ((state: NetInfoState) => void) | undefined;
+const mockUnsubscribe = jest.fn();
+const mockAddEventListener = jest.fn((cb: (state: NetInfoState) => void) => {
+ netInfoHandler = cb;
+ return mockUnsubscribe;
+});
+
+jest.mock('@react-native-community/netinfo', () => ({
+ __esModule: true,
+ default: { addEventListener: (cb: (state: NetInfoState) => void) => mockAddEventListener(cb) },
+}));
+
+jest.mock('@/lib/engagement/queue', () => ({ flush: jest.fn() }));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function state(isConnected: boolean, isInternetReachable: boolean | null): NetInfoState {
+ return { isConnected, isInternetReachable } as NetInfoState;
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ netInfoHandler = undefined;
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('startEngagementSync', () => {
+ it('vacía la cola al recuperar conectividad real', () => {
+ startEngagementSync();
+ netInfoHandler!(state(true, true));
+
+ expect(flush).toHaveBeenCalledTimes(1);
+ });
+
+ it('no vacía si no hay conexión o internet no es alcanzable', () => {
+ startEngagementSync();
+
+ netInfoHandler!(state(false, false));
+ netInfoHandler!(state(true, false));
+ netInfoHandler!(state(true, null));
+
+ expect(flush).not.toHaveBeenCalled();
+ });
+
+ it('devuelve la función de desuscripción de NetInfo', () => {
+ const unsubscribe = startEngagementSync();
+ expect(unsubscribe).toBe(mockUnsubscribe);
+ });
+});
diff --git a/app/(app)/(tabs)/tablon/[id].tsx b/app/(app)/(tabs)/tablon/[id].tsx
index fc17932..4300d7e 100644
--- a/app/(app)/(tabs)/tablon/[id].tsx
+++ b/app/(app)/(tabs)/tablon/[id].tsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
import { Linking, ScrollView, View } from 'react-native';
import { Image } from 'expo-image';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
@@ -5,8 +6,15 @@ import Markdown from 'react-native-markdown-display';
import { usePostDetail } from '@/hooks/usePostDetail';
import { usePostReactions } from '@/hooks/usePostReactions';
-import { Button, Text } from '@/components/ui';
+import { usePostRating } from '@/hooks/usePostRating';
+import { useComments } from '@/hooks/useComments';
+import { useSession } from '@/hooks/useSession';
+import { usePostEngagement } from '@/hooks/usePostEngagement';
+import { createEngagementSink, trackLinkClick } from '@/lib/engagement';
+import { Button, StarRating, Text } from '@/components/ui';
import { ReactionPicker } from '@/components/reactions';
+import { CommentComposer, CommentsList } from '@/components/comments';
+import type { CommentAuthor } from '@/lib/comments';
function getCoverUrl(url: string): string {
return (
@@ -15,6 +23,12 @@ function getCoverUrl(url: string): string {
);
}
+// Host only, without www. — regex-based so it doesn't rely on RN's partial URL impl.
+function getDomain(url: string): string {
+ const match = url.match(/^https?:\/\/([^/]+)/i);
+ return match ? match[1]!.replace(/^www\./, '') : url;
+}
+
function formatRelativeTime(isoDate: string): string {
const diff = Date.now() - new Date(isoDate).getTime();
const minutes = Math.floor(diff / 60_000);
@@ -42,9 +56,41 @@ export default function PostDetailScreen() {
const router = useRouter();
const { post, loading, error } = usePostDetail(id);
const { myReaction, counts, loading: reactionsLoading, toggle } = usePostReactions(id ?? '');
-
- // Punto de inserción para el futuro tracker de engagement (EPIC-N04, ADR-001):
- // aquí se registrará el evento `view` una vez cargado `post`. No implementado en este issue.
+ const {
+ myRating,
+ average: ratingAverage,
+ count: ratingCount,
+ loading: ratingLoading,
+ rate,
+ } = usePostRating(id ?? '');
+ const { session, profile } = useSession();
+
+ const currentUser = useMemo(() => {
+ if (!session) return null;
+ return {
+ user_id: session.userId,
+ full_name: [profile?.name, profile?.surname].filter(Boolean).join(' ') || null,
+ avatar_url: profile?.avatar_url ?? null,
+ };
+ }, [session, profile?.name, profile?.surname, profile?.avatar_url]);
+
+ const {
+ comments,
+ total: commentsTotal,
+ loading: commentsLoading,
+ loadingMore: commentsLoadingMore,
+ hasMore: commentsHasMore,
+ loadMore: loadMoreComments,
+ add: addComment,
+ remove: removeComment,
+ } = useComments(id ?? '', currentUser);
+
+ // Engagement (EPIC-N04, ADR-001): un sink por apertura de pantalla que encola los
+ // heartbeats hacia la Edge Function track-engagement. El evento `init` (fila `viewed`)
+ // se emite al montar; `onScroll` alimenta el scroll monótono al ScrollView del post.
+ // El sink se auto-reinicia en cada `init`, así que no depende de `id`.
+ const onEngagementEvent = useMemo(() => createEngagementSink(), []);
+ const { onScroll } = usePostEngagement(id ?? '', { onEvent: onEngagementEvent });
return (
@@ -60,7 +106,12 @@ export default function PostDetailScreen() {
) : null}
{!loading && post ? (
-
+
{post.cover_image_url ? (
-
) : null}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index d0abf8c..0750338 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,8 +1,14 @@
+import { useEffect } from 'react';
import { Stack } from 'expo-router';
import '../global.css';
import '../lib/nativewind-setup';
+import { startEngagementSync } from '@/lib/engagement';
export default function RootLayout() {
+ // Vacía la cola offline de engagement al recuperar conectividad, en cualquier
+ // pantalla. Devuelve la desuscripción de NetInfo como cleanup del efecto.
+ useEffect(() => startEngagementSync(), []);
+
return ;
}
diff --git a/components/PostCard.tsx b/components/PostCard.tsx
index c6666d1..8117ae8 100644
--- a/components/PostCard.tsx
+++ b/components/PostCard.tsx
@@ -1,6 +1,6 @@
import { Pressable, View } from 'react-native';
import { Image } from 'expo-image';
-import { ExternalLink } from 'lucide-react-native';
+import { ExternalLink, MessageCircle, Star } from 'lucide-react-native';
import { Text } from '@/components/ui';
import { ReactionPicker } from '@/components/reactions';
@@ -74,7 +74,19 @@ export function PostCard({ post, onPress }: Props) {
>
) : null}
-
+
+ {post.rating_count > 0 ? (
+
+
+
+ {post.rating_average.toFixed(1)}
+
+
+ ) : null}
+
+ {post.comments_count}
+
+
void;
+ submitting?: boolean;
+};
+
+export function CommentComposer({ onSubmit, submitting }: Props) {
+ const [body, setBody] = useState('');
+ const trimmed = body.trim();
+ const canSubmit = trimmed.length >= 1 && trimmed.length <= MAX_COMMENT_LENGTH && !submitting;
+
+ function handleSubmit() {
+ if (!canSubmit) return;
+ onSubmit(trimmed);
+ setBody('');
+ Keyboard.dismiss();
+ }
+
+ return (
+
+
+
+
+ {body.length}/{MAX_COMMENT_LENGTH}
+
+
+
+
+ );
+}
diff --git a/components/comments/CommentsList.tsx b/components/comments/CommentsList.tsx
new file mode 100644
index 0000000..cffd2a5
--- /dev/null
+++ b/components/comments/CommentsList.tsx
@@ -0,0 +1,143 @@
+import { Alert, Pressable, View } from 'react-native';
+import { Image } from 'expo-image';
+import { Trash2 } from 'lucide-react-native';
+
+import { Button, Text } from '@/components/ui';
+import type { CommentWithAuthor } from '@/lib/comments';
+
+function formatRelativeTime(isoDate: string): string {
+ const diff = Date.now() - new Date(isoDate).getTime();
+ const minutes = Math.floor(diff / 60_000);
+ if (minutes < 1) return 'Ahora';
+ if (minutes < 60) return `Hace ${minutes} min`;
+ const hours = Math.floor(diff / 3_600_000);
+ if (hours < 24) return `Hace ${hours} h`;
+ return new Date(isoDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' });
+}
+
+function initials(fullName: string | null): string {
+ if (!fullName) return '—';
+ return (
+ fullName
+ .split(' ')
+ .filter(Boolean)
+ .map((w) => w[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase() || '—'
+ );
+}
+
+function CommentItem({
+ comment,
+ canDelete,
+ onDelete,
+}: {
+ comment: CommentWithAuthor;
+ canDelete: boolean;
+ onDelete: () => void;
+}) {
+ const name = comment.author?.full_name ?? 'Usuario';
+
+ function handleDelete() {
+ Alert.alert('Borrar comentario', '¿Seguro que quieres borrar este comentario?', [
+ { text: 'Cancelar', style: 'cancel' },
+ { text: 'Borrar', style: 'destructive', onPress: onDelete },
+ ]);
+ }
+
+ return (
+
+ {comment.author?.avatar_url ? (
+
+ ) : (
+
+
+ {initials(comment.author?.full_name ?? null)}
+
+
+ )}
+
+
+
+
+ {name}
+
+ {formatRelativeTime(comment.created_at)}
+ {canDelete ? (
+
+
+
+ ) : null}
+
+ {comment.body}
+
+
+ );
+}
+
+type Props = {
+ comments: CommentWithAuthor[];
+ currentUserId: string | null;
+ isAdmin: boolean;
+ loading: boolean;
+ hasMore: boolean;
+ loadingMore: boolean;
+ onLoadMore: () => void;
+ onDelete: (commentId: string) => void;
+};
+
+export function CommentsList({
+ comments,
+ currentUserId,
+ isAdmin,
+ loading,
+ hasMore,
+ loadingMore,
+ onLoadMore,
+ onDelete,
+}: Props) {
+ if (loading) {
+ return Cargando comentarios…;
+ }
+
+ if (comments.length === 0) {
+ return (
+
+ Sé el primero en comentar
+
+ );
+ }
+
+ return (
+
+ {comments.map((comment) => (
+ onDelete(comment.id)}
+ />
+ ))}
+
+ {hasMore ? (
+
+ ) : null}
+
+ );
+}
diff --git a/components/comments/index.ts b/components/comments/index.ts
new file mode 100644
index 0000000..ba22ba0
--- /dev/null
+++ b/components/comments/index.ts
@@ -0,0 +1,2 @@
+export { CommentsList } from './CommentsList';
+export { CommentComposer } from './CommentComposer';
diff --git a/components/ui/StarRating.tsx b/components/ui/StarRating.tsx
new file mode 100644
index 0000000..f0b6a6f
--- /dev/null
+++ b/components/ui/StarRating.tsx
@@ -0,0 +1,53 @@
+import { Pressable, View } from 'react-native';
+import { Star } from 'lucide-react-native';
+
+import { Text } from '@/components/ui/Text';
+
+export type StarRatingProps = {
+ value: number | null; // the caller's own rating, null if not rated yet
+ average: number;
+ count: number;
+ onRate: (value: number) => void;
+ disabled?: boolean;
+};
+
+const STARS = [1, 2, 3, 4, 5];
+const ACTIVE_COLOR = '#FACC15'; // text-yellow-400
+const EMPTY_COLOR = '#D1D5DB'; // text-gray-300
+
+export function StarRating({ value, average, count, onRate, disabled }: StarRatingProps) {
+ const current = value ?? 0;
+
+ return (
+
+
+ {STARS.map((star) => {
+ const filled = star <= current;
+ return (
+ onRate(star)}
+ disabled={disabled}
+ accessibilityRole="button"
+ accessibilityLabel={`Valorar con ${star} ${star === 1 ? 'estrella' : 'estrellas'}`}
+ accessibilityState={{ selected: value === star }}
+ hitSlop={6}
+ className="py-1 pr-1.5 active:opacity-70"
+ >
+
+
+ );
+ })}
+
+
+ {count > 0
+ ? `${average.toFixed(1)} · ${count} ${count === 1 ? 'valoración' : 'valoraciones'}`
+ : 'Sé el primero en valorar'}
+
+
+ );
+}
diff --git a/components/ui/index.ts b/components/ui/index.ts
index 3e8fc5b..8c63558 100644
--- a/components/ui/index.ts
+++ b/components/ui/index.ts
@@ -2,3 +2,4 @@ export { Text, type TextProps } from './Text';
export { View, type ViewProps } from './View';
export { Button, type ButtonProps, type ButtonVariant } from './Button';
export { Card, type CardProps } from './Card';
+export { StarRating, type StarRatingProps } from './StarRating';
diff --git a/docs/adr/0001-engagement.md b/docs/adr/0001-engagement.md
index e6a0464..e6cdeee 100644
--- a/docs/adr/0001-engagement.md
+++ b/docs/adr/0001-engagement.md
@@ -1,10 +1,12 @@
# ADR-001 — Modelo de engagement: viewed / engaged / clicked
-**Estado:** Aceptado
+**Estado:** Superseded por [ADR-0003](0003-engagement-reading-sessions.md) (2026-07-09)
**Fecha:** 2026-06-26
**Autores:** Alex Zapata
**Issues:** [EPIC-A00 #45](https://github.com/CodeCrafters-ES/pinboard-app/issues/45) · [I-F-A00-01-01 #47](https://github.com/CodeCrafters-ES/pinboard-app/issues/47) · [I-F-A00-01-02 #48](https://github.com/CodeCrafters-ES/pinboard-app/issues/48)
+> **Nota (2026-07-09):** Este ADR queda **superado por [ADR-0003](0003-engagement-reading-sessions.md)**, que sustituye el modelo `(user_id, post_id)` / `viewed·engaged·clicked` por uno **por sesión de lectura** (`session_id`, `focused_seconds`, `max_scroll_pct`, estados `viewed → skimmed → read`). Se conserva por contexto histórico.
+
---
## Contexto
diff --git a/docs/adr/0002-rbac.md b/docs/adr/0002-rbac.md
index 8a1f7fd..3b3b190 100644
--- a/docs/adr/0002-rbac.md
+++ b/docs/adr/0002-rbac.md
@@ -131,9 +131,13 @@ La autorización **vive en las RLS policies de Postgres**, nunca en el cliente.
| Acción | Admin | Manager | Staff | Policy tentativa |
|---|---|---|---|---|
| SELECT | Todas | Todas | Todas | `post_comments_select_authenticated` |
-| INSERT | Propia | Propia | Propia | `post_comments_insert_own` |
+| INSERT | Propia | Propia | Propia | `post_comments_insert_self` |
| UPDATE | Todas (moderar) | Propia | Propia | `post_comments_update_own_or_admin` |
-| DELETE | Todas (moderar) | Propia | Propia | `post_comments_delete_own_or_admin` |
+| DELETE | Todas (moderar) | Propia | Propia | `post_comments_delete_self_or_admin` |
+
+> Moderación verificada en I-F-N03-02-03 (#166): las 4 policies satisfacen el contrato
+> (admin modera todo; autor gestiona lo propio). Tests pos/neg en
+> `supabase/tests/rls/rls_post_comments.sql`.
### `events`
diff --git a/docs/adr/0003-engagement-reading-sessions.md b/docs/adr/0003-engagement-reading-sessions.md
new file mode 100644
index 0000000..b981470
--- /dev/null
+++ b/docs/adr/0003-engagement-reading-sessions.md
@@ -0,0 +1,82 @@
+# ADR-0003 — Modelo de engagement por sesión de lectura: viewed / skimmed / read
+
+**Estado:** Aceptado
+**Fecha:** 2026-07-09
+**Supersedes:** [ADR-001](0001-engagement.md)
+**Issues:** [EPIC-N04 #172](https://github.com/CodeCrafters-ES/pinboard-app/issues/172) · [F-N04-02 #174](https://github.com/CodeCrafters-ES/pinboard-app/issues/174) · [I-F-N04-02-01 #178](https://github.com/CodeCrafters-ES/pinboard-app/issues/178)
+
+---
+
+## Contexto
+
+[ADR-001](0001-engagement.md) modeló el engagement como **una fila por `(user_id, post_id)`** con estados de negocio `viewed → engaged → clicked`, siendo `link_clicked` la métrica de éxito, y **rechazó** medir el tiempo de lectura in-app por considerarlo semánticamente irrelevante (el contenido vive fuera de la app).
+
+La EPIC-N04 revisa esa decisión: aunque el artículo sea externo, el **comportamiento in-app antes del clic** (cuánto foco real y cuánto scroll dedica el usuario a la card/preview) sí es una señal útil de interés editorial, y es lo que el cliente ya captura (hook `usePostEngagement`, #176/#177: `focused_seconds`, `max_scroll_pct`, `session_id`). ADR-001 y ese cliente son incompatibles; este ADR resuelve el conflicto adoptando el modelo por sesión.
+
+## Decisión
+
+### Modelo de datos
+
+**Una fila por sesión de lectura**, identificada por un `session_id` (UUID v4 generado en el cliente al abrir la card). A diferencia de ADR-001, **no** hay unicidad por `(user_id, post_id)`: cada apertura de pantalla es una sesión nueva.
+
+```sql
+create type public.engagement_state as enum ('viewed', 'skimmed', 'read');
+
+create table public.engagement_sessions (
+ session_id uuid primary key,
+ post_id uuid not null references public.posts(id) on delete cascade,
+ user_id uuid not null references public.profiles(id) on delete cascade,
+ focused_seconds integer not null default 0 check (focused_seconds >= 0),
+ max_scroll_pct numeric(4,3) not null default 0 check (max_scroll_pct between 0 and 1),
+ state public.engagement_state not null default 'viewed',
+ started_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+```
+
+- `focused_seconds`: segundos de foco in-app acumulados. El cliente envía **deltas** por heartbeat (5s); la Edge Function los suma.
+- `max_scroll_pct`: máximo scroll alcanzado en la sesión, monotónico ∈ [0, 1].
+- `state`: estado **derivado por el servidor** (nunca aceptado del cliente).
+
+### Estados y umbrales
+
+| Estado | Condición |
+|---|---|
+| `viewed` | Sesión creada al abrir la card (estado inicial). |
+| `skimmed` | `max_scroll_pct ≥ 0.70`. |
+| `read` | `focused_seconds ≥ N` **y** `max_scroll_pct ≥ 0.70`, donde `N = max(15, palabras / 4)`. |
+
+`palabras` proviene de `posts.word_count` (EPIC-N02). El estado solo avanza; nunca retrocede.
+
+### FK a `profiles(id)`
+
+`user_id` referencia `public.profiles(id)` (no `auth.users`). Consecuencia: **almacena `profiles.id`, no `auth.uid()`**. Implicaciones para las issues consumidoras:
+
+- **Edge Function `track-engagement` (#179):** debe resolver `profiles.id` a partir de `auth.uid()` antes de escribir.
+- **Policies RLS (EPIC-S00 / I-F-S00-04-05):** la comprobación de propiedad es `user_id in (select id from public.profiles where user_id = auth.uid())`, no el patrón directo `user_id = auth.uid()` usado en otras tablas.
+
+### Arquitectura de escritura
+
+Igual que ADR-001: **solo** la Edge Function `track-engagement` (con `service_role`) escribe en `engagement_sessions`. Los clientes autenticados no tienen policies de escritura; la lectura (own / manager-admin, para el dashboard) se define en EPIC-S00.
+
+## Consecuencias
+
+**Positivas:**
+
+- Captura granular del comportamiento de lectura (tiempo + scroll) que ADR-001 descartaba.
+- El `session_id` como PK hace el UPSERT idempotente por sesión (reintentos de la cola offline no duplican).
+- Alinea el servidor con el cliente ya mergeado (#173).
+
+**Negativas / limitaciones:**
+
+- Varias filas por (usuario, post) → el dashboard debe agregar (`count(distinct user_id)`, `avg`, etc.).
+- La FK a `profiles(id)` rompe el patrón `user_id = auth.uid()` y obliga a mapear en Edge Function y policies.
+- Reemplazar la tabla de ADR-001 deja temporalmente incoherente la Edge Function de `link_clicked` (N03-04) hasta #179.
+- El tiempo in-app mide interés sobre la card/preview, no lectura del artículo externo (la crítica de ADR-001 sigue siendo parcialmente válida; se asume como proxy).
+
+## Referencias
+
+- `supabase/migrations/20260710000000_replace_engagement_sessions_n04_schema.sql` — tabla del nuevo modelo.
+- `supabase/tests/rls/schema_engagement_sessions.sql` — tests de estructura y constraints.
+- [ADR-001](0001-engagement.md) — modelo anterior (superseded).
+- Consumidores: Edge Function `track-engagement` (#179) · hook `usePostEngagement` (#176/#177) · policies RLS (EPIC-S00).
diff --git a/hooks/useComments.ts b/hooks/useComments.ts
new file mode 100644
index 0000000..0e2bf0f
--- /dev/null
+++ b/hooks/useComments.ts
@@ -0,0 +1,194 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Alert } from 'react-native';
+
+import {
+ createComment,
+ deleteComment,
+ getCommentsCount,
+ listComments,
+ MAX_COMMENT_LENGTH,
+ type CommentAuthor,
+ type CommentCursor,
+ type CommentWithAuthor,
+} from '@/lib/comments';
+
+const PAGE_SIZE = 20;
+
+type State = {
+ comments: CommentWithAuthor[];
+ cursor: CommentCursor | null;
+ hasMore: boolean;
+ total: number;
+ loading: boolean;
+ loadingMore: boolean;
+ error: string | null;
+};
+
+const INITIAL: State = {
+ comments: [],
+ cursor: null,
+ hasMore: false,
+ total: 0,
+ loading: true,
+ loadingMore: false,
+ error: null,
+};
+
+export function useComments(postId: string, currentUser: CommentAuthor | null) {
+ const [state, setState] = useState(INITIAL);
+
+ const load = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const [{ rows, nextCursor }, total] = await Promise.all([
+ listComments({ postId, pageSize: PAGE_SIZE }),
+ getCommentsCount(postId),
+ ]);
+ setState({
+ comments: rows,
+ cursor: nextCursor,
+ hasMore: nextCursor !== null,
+ total,
+ loading: false,
+ loadingMore: false,
+ error: null,
+ });
+ } catch {
+ setState((s) => ({
+ ...s,
+ loading: false,
+ error: 'No se pudieron cargar los comentarios.',
+ }));
+ }
+ }, [postId]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setState({ ...INITIAL });
+ Promise.all([listComments({ postId, pageSize: PAGE_SIZE }), getCommentsCount(postId)])
+ .then(([{ rows, nextCursor }, total]) => {
+ if (cancelled) return;
+ setState({
+ comments: rows,
+ cursor: nextCursor,
+ hasMore: nextCursor !== null,
+ total,
+ loading: false,
+ loadingMore: false,
+ error: null,
+ });
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setState((s) => ({
+ ...s,
+ loading: false,
+ error: 'No se pudieron cargar los comentarios.',
+ }));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [postId]);
+
+ const loadMore = useCallback(async () => {
+ if (state.loading || state.loadingMore || !state.hasMore || !state.cursor) return;
+ const cursor = state.cursor;
+ setState((s) => ({ ...s, loadingMore: true }));
+ try {
+ const { rows, nextCursor } = await listComments({ postId, cursor, pageSize: PAGE_SIZE });
+ setState((s) => {
+ const seen = new Set(s.comments.map((c) => c.id));
+ const merged = [...s.comments, ...rows.filter((r) => !seen.has(r.id))];
+ return {
+ ...s,
+ comments: merged,
+ cursor: nextCursor,
+ hasMore: nextCursor !== null,
+ loadingMore: false,
+ };
+ });
+ } catch {
+ setState((s) => ({ ...s, loadingMore: false }));
+ Alert.alert('Error', 'No se pudieron cargar más comentarios.');
+ }
+ }, [postId, state.loading, state.loadingMore, state.hasMore, state.cursor]);
+
+ const add = useCallback(
+ async (body: string) => {
+ const trimmed = body.trim();
+ if (trimmed.length < 1 || trimmed.length > MAX_COMMENT_LENGTH) return;
+
+ const tempId = `temp-${Date.now()}`;
+ const now = new Date().toISOString();
+ const optimistic: CommentWithAuthor = {
+ id: tempId,
+ post_id: postId,
+ author_id: currentUser?.user_id ?? '',
+ body: trimmed,
+ created_at: now,
+ updated_at: now,
+ author: currentUser,
+ };
+ setState((s) => ({ ...s, comments: [optimistic, ...s.comments], total: s.total + 1 }));
+
+ try {
+ const created = await createComment({ postId, body: trimmed });
+ setState((s) => ({
+ ...s,
+ comments: s.comments.map((c) =>
+ c.id === tempId ? { ...created, author: currentUser } : c,
+ ),
+ }));
+ } catch {
+ setState((s) => ({
+ ...s,
+ comments: s.comments.filter((c) => c.id !== tempId),
+ total: Math.max(0, s.total - 1),
+ }));
+ Alert.alert('Error', 'No se pudo publicar el comentario. Inténtalo de nuevo.');
+ }
+ },
+ [postId, currentUser],
+ );
+
+ const remove = useCallback(async (commentId: string) => {
+ let removed: CommentWithAuthor | undefined;
+ let index = -1;
+ setState((s) => {
+ index = s.comments.findIndex((c) => c.id === commentId);
+ if (index < 0) return s;
+ removed = s.comments[index];
+ return {
+ ...s,
+ comments: s.comments.filter((c) => c.id !== commentId),
+ total: Math.max(0, s.total - 1),
+ };
+ });
+
+ try {
+ await deleteComment(commentId);
+ } catch {
+ setState((s) => {
+ if (!removed) return s;
+ const next = [...s.comments];
+ next.splice(index < 0 ? 0 : index, 0, removed);
+ return { ...s, comments: next, total: s.total + 1 };
+ });
+ Alert.alert('Error', 'No se pudo borrar el comentario.');
+ }
+ }, []);
+
+ return {
+ comments: state.comments,
+ total: state.total,
+ loading: state.loading,
+ loadingMore: state.loadingMore,
+ hasMore: state.hasMore,
+ error: state.error,
+ loadMore,
+ refresh: load,
+ add,
+ remove,
+ };
+}
diff --git a/hooks/usePostEngagement.ts b/hooks/usePostEngagement.ts
new file mode 100644
index 0000000..ae8233b
--- /dev/null
+++ b/hooks/usePostEngagement.ts
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { AppState, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
+import { useIsFocused } from '@react-navigation/native';
+import * as Crypto from 'expo-crypto';
+
+export type EngagementEvent =
+ | { type: 'init'; sessionId: string; postId: string; wordCount: number; startedAt: string }
+ | {
+ type: 'tick';
+ sessionId: string;
+ postId: string;
+ focusedSeconds: number;
+ maxScrollPct: number;
+ at: string;
+ };
+
+export type UsePostEngagementOpts = {
+ wordCount?: number;
+ // La cola offline real la inyecta I-F-N04-01-02; por defecto no-op.
+ onEvent?: (event: EngagementEvent) => void;
+ // Configurable solo para tests; en producción se mantiene el heartbeat de 5s (ADR-001).
+ heartbeatMs?: number;
+};
+
+type UsePostEngagementResult = {
+ onScroll: (event: NativeSyntheticEvent) => void;
+ sessionId: string;
+};
+
+const DEFAULT_HEARTBEAT_MS = 5000;
+
+function clamp01(value: number): number {
+ if (!Number.isFinite(value) || value <= 0) return 0;
+ return value >= 1 ? 1 : value;
+}
+
+export function usePostEngagement(
+ postId: string,
+ opts?: UsePostEngagementOpts,
+): UsePostEngagementResult {
+ const wordCount = opts?.wordCount ?? 0;
+ const heartbeatMs = opts?.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
+
+ // sessionId estable durante toda la vida del hook: un post nuevo desmonta la
+ // pantalla, por lo que se genera un id por sesión de lectura.
+ const sessionIdRef = useRef('');
+ if (sessionIdRef.current === '') sessionIdRef.current = Crypto.randomUUID();
+ const sessionId = sessionIdRef.current;
+
+ // Acumuladores en refs para no re-renderizar en cada heartbeat/scroll.
+ const focusedSecondsRef = useRef(0);
+ const maxScrollPctRef = useRef(0);
+
+ // Foco real = app en primer plano Y pantalla enfocada en la navegación.
+ const appStateActiveRef = useRef(AppState.currentState === 'active');
+ const navigationFocused = useIsFocused();
+ const navigationFocusedRef = useRef(navigationFocused);
+ navigationFocusedRef.current = navigationFocused;
+
+ // Mantener referencias frescas de los callbacks/valores para el intervalo,
+ // que se crea una sola vez y no debe recrearse en cada render.
+ const onEventRef = useRef(opts?.onEvent);
+ onEventRef.current = opts?.onEvent;
+
+ const emit = useCallback((event: EngagementEvent) => {
+ onEventRef.current?.(event);
+ }, []);
+
+ const onScroll = useCallback((event: NativeSyntheticEvent) => {
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
+ const scrollable = contentSize.height - layoutMeasurement.height;
+ const pct = scrollable > 0 ? clamp01(contentOffset.y / scrollable) : 0;
+ // Monótono: nunca decrece aunque el usuario haga scroll hacia arriba.
+ if (pct > maxScrollPctRef.current) maxScrollPctRef.current = pct;
+ }, []);
+
+ useEffect(() => {
+ focusedSecondsRef.current = 0;
+ maxScrollPctRef.current = 0;
+ appStateActiveRef.current = AppState.currentState === 'active';
+
+ emit({
+ type: 'init',
+ sessionId,
+ postId,
+ wordCount,
+ startedAt: new Date().toISOString(),
+ });
+
+ const subscription = AppState.addEventListener('change', (nextState) => {
+ appStateActiveRef.current = nextState === 'active';
+ });
+
+ const secondsPerTick = heartbeatMs / 1000;
+ const interval = setInterval(() => {
+ const isActive = appStateActiveRef.current && navigationFocusedRef.current;
+ // focusedSeconds solo avanza mientras hay foco real.
+ if (isActive) focusedSecondsRef.current += secondsPerTick;
+
+ emit({
+ type: 'tick',
+ sessionId,
+ postId,
+ focusedSeconds: focusedSecondsRef.current,
+ maxScrollPct: maxScrollPctRef.current,
+ at: new Date().toISOString(),
+ });
+ }, heartbeatMs);
+
+ return () => {
+ clearInterval(interval);
+ subscription.remove();
+ };
+ }, [sessionId, postId, wordCount, heartbeatMs, emit]);
+
+ return { onScroll, sessionId };
+}
diff --git a/hooks/usePostRating.ts b/hooks/usePostRating.ts
new file mode 100644
index 0000000..59568a5
--- /dev/null
+++ b/hooks/usePostRating.ts
@@ -0,0 +1,53 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Alert } from 'react-native';
+
+import { EMPTY_RATING, getRatingState, upsertRating, type RatingState } from '@/lib/ratings';
+
+type State = RatingState & { loading: boolean };
+
+export function usePostRating(postId: string) {
+ const [state, setState] = useState({ ...EMPTY_RATING, loading: true });
+
+ useEffect(() => {
+ let cancelled = false;
+ setState((s) => ({ ...s, loading: true }));
+
+ getRatingState(postId)
+ .then((r) => {
+ if (!cancelled) setState({ ...r, loading: false });
+ })
+ .catch(() => {
+ if (!cancelled) setState({ ...EMPTY_RATING, loading: false });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [postId]);
+
+ const rate = useCallback(
+ async (value: number) => {
+ // Reselecting the same star is a no-op: no optimistic change, no upsert.
+ if (value === state.myRating) return;
+
+ const prevState = state;
+
+ // Optimistic average: swap out my previous contribution for the new one.
+ setState((s) => {
+ const count = s.myRating === null ? s.count + 1 : s.count;
+ const sum = s.average * s.count - (s.myRating ?? 0) + value;
+ return { ...s, myRating: value, count, average: count > 0 ? sum / count : 0 };
+ });
+
+ try {
+ await upsertRating(postId, value);
+ } catch {
+ setState(prevState);
+ Alert.alert('Error', 'No se pudo guardar la valoración. Inténtalo de nuevo.');
+ }
+ },
+ [postId, state],
+ );
+
+ return { ...state, rate };
+}
diff --git a/jest.config.js b/jest.config.js
index 0b42ab0..d745796 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,7 +1,8 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'jest-expo',
-transformIgnorePatterns: [
+ setupFiles: ['/jest.setup.ts'],
+ transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|lucide-react-native)',
],
moduleNameMapper: {
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000..db7049c
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,17 @@
+// Mocks globales de módulos nativos. Las suites que necesiten controlar su
+// comportamiento pueden redefinirlos con jest.mock local (tiene prioridad).
+
+jest.mock('@react-native-async-storage/async-storage', () =>
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
+);
+
+jest.mock('@react-native-community/netinfo', () => ({
+ __esModule: true,
+ default: {
+ addEventListener: jest.fn(() => jest.fn()),
+ fetch: jest.fn(() =>
+ Promise.resolve({ isConnected: true, isInternetReachable: true }),
+ ),
+ },
+}));
diff --git a/lib/comments.ts b/lib/comments.ts
new file mode 100644
index 0000000..fa1b833
--- /dev/null
+++ b/lib/comments.ts
@@ -0,0 +1,130 @@
+import type { SupabaseClient } from '@supabase/supabase-js';
+
+import { supabase } from '@/lib/supabase';
+import type { Database, Tables } from '@/lib/database.types';
+
+export type PostComment = Tables<'post_comments'>;
+export type CommentAuthor = {
+ user_id: string;
+ full_name: string | null;
+ avatar_url: string | null;
+};
+export type CommentWithAuthor = PostComment & { author: CommentAuthor | null };
+export type CommentCursor = { created_at: string; id: string };
+
+export const MAX_COMMENT_LENGTH = 2000;
+
+// post_comments.author_id → auth.users(id), so there is no PostgREST FK to embed
+// profiles_public. Authors are fetched in a second query and merged client-side.
+async function attachAuthors(
+ comments: PostComment[],
+ client: SupabaseClient,
+): Promise {
+ if (comments.length === 0) return [];
+
+ const authorIds = [...new Set(comments.map((c) => c.author_id))];
+ const { data, error } = await client
+ .from('profiles_public')
+ .select('user_id, full_name, avatar_url')
+ .in('user_id', authorIds);
+
+ if (error) throw error;
+
+ const byId = new Map();
+ for (const row of data ?? []) {
+ if (row.user_id) {
+ byId.set(row.user_id, {
+ user_id: row.user_id,
+ full_name: row.full_name,
+ avatar_url: row.avatar_url,
+ });
+ }
+ }
+
+ return comments.map((c) => ({ ...c, author: byId.get(c.author_id) ?? null }));
+}
+
+export async function listComments({
+ postId,
+ cursor,
+ pageSize = 20,
+ client = supabase,
+}: {
+ postId: string;
+ cursor?: CommentCursor;
+ pageSize?: number;
+ client?: SupabaseClient;
+}): Promise<{ rows: CommentWithAuthor[]; nextCursor: CommentCursor | null }> {
+ let query = client
+ .from('post_comments')
+ .select('*')
+ .eq('post_id', postId)
+ .order('created_at', { ascending: false })
+ .order('id', { ascending: false })
+ .limit(pageSize);
+
+ if (cursor) {
+ query = query.or(
+ `created_at.lt.${cursor.created_at},and(created_at.eq.${cursor.created_at},id.lt.${cursor.id})`,
+ );
+ }
+
+ const { data, error } = await query;
+ if (error) throw error;
+
+ const comments = data ?? [];
+ const rows = await attachAuthors(comments, client);
+
+ const lastRow = comments[comments.length - 1];
+ const nextCursor =
+ comments.length === pageSize && lastRow
+ ? { created_at: lastRow.created_at, id: lastRow.id }
+ : null;
+
+ return { rows, nextCursor };
+}
+
+export async function getCommentsCount(
+ postId: string,
+ client: SupabaseClient = supabase,
+): Promise {
+ const { count, error } = await client
+ .from('post_comments')
+ .select('*', { count: 'exact', head: true })
+ .eq('post_id', postId);
+
+ if (error) throw error;
+ return count ?? 0;
+}
+
+export async function createComment({
+ postId,
+ body,
+ client = supabase,
+}: {
+ postId: string;
+ body: string;
+ client?: SupabaseClient;
+}): Promise {
+ const {
+ data: { user },
+ } = await client.auth.getUser();
+ if (!user) throw new Error('No autenticado');
+
+ const { data, error } = await client
+ .from('post_comments')
+ .insert({ post_id: postId, author_id: user.id, body })
+ .select('*')
+ .single();
+
+ if (error) throw error;
+ return data;
+}
+
+export async function deleteComment(
+ commentId: string,
+ client: SupabaseClient = supabase,
+): Promise {
+ const { error } = await client.from('post_comments').delete().eq('id', commentId);
+ if (error) throw error;
+}
diff --git a/lib/database.types.ts b/lib/database.types.ts
index c660b00..3d8cb77 100644
--- a/lib/database.types.ts
+++ b/lib/database.types.ts
@@ -36,33 +36,33 @@ export type Database = {
Tables: {
engagement_sessions: {
Row: {
- device: string | null
- id: string
- last_seen_at: string
- link_clicked: boolean
+ focused_seconds: number
+ max_scroll_pct: number
post_id: string
+ session_id: string
started_at: string
- status: string
+ state: Database["public"]["Enums"]["engagement_state"]
+ updated_at: string
user_id: string
}
Insert: {
- device?: string | null
- id?: string
- last_seen_at?: string
- link_clicked?: boolean
+ focused_seconds?: number
+ max_scroll_pct?: number
post_id: string
+ session_id: string
started_at?: string
- status?: string
+ state?: Database["public"]["Enums"]["engagement_state"]
+ updated_at?: string
user_id: string
}
Update: {
- device?: string | null
- id?: string
- last_seen_at?: string
- link_clicked?: boolean
+ focused_seconds?: number
+ max_scroll_pct?: number
post_id?: string
+ session_id?: string
started_at?: string
- status?: string
+ state?: Database["public"]["Enums"]["engagement_state"]
+ updated_at?: string
user_id?: string
}
Relationships: [
@@ -73,6 +73,13 @@ export type Database = {
referencedRelation: "posts"
referencedColumns: ["id"]
},
+ {
+ foreignKeyName: "engagement_sessions_user_id_fkey"
+ columns: ["user_id"]
+ isOneToOne: false
+ referencedRelation: "profiles"
+ referencedColumns: ["id"]
+ },
]
}
events: {
@@ -427,6 +434,7 @@ export type Database = {
is_staff: { Args: never; Returns: boolean }
}
Enums: {
+ engagement_state: "viewed" | "skimmed" | "read"
reaction_type: "like" | "dislike" | "love"
user_role: "staff" | "manager" | "admin"
}
@@ -559,6 +567,7 @@ export const Constants = {
},
public: {
Enums: {
+ engagement_state: ["viewed", "skimmed", "read"],
reaction_type: ["like", "dislike", "love"],
user_role: ["staff", "manager", "admin"],
},
diff --git a/lib/engagement/README.md b/lib/engagement/README.md
new file mode 100644
index 0000000..8042755
--- /dev/null
+++ b/lib/engagement/README.md
@@ -0,0 +1,66 @@
+# Engagement (cliente)
+
+Captura las señales reales de lectura de un post (tiempo de foco, scroll, `AppState`) y las entrega
+_offline-first_ a la Edge Function `track-engagement`. Base de la EPIC-N04 (participación real).
+
+Ver `docs/adr/0001-engagement.md` para los estados (`viewed` / `skimmed` / `read`) y umbrales. La
+transición de estado y el UPSERT viven en el **servidor** (F-N04-02); el cliente solo emite deltas.
+
+## Piezas
+
+| Módulo | Rol |
+|---|---|
+| `hooks/usePostEngagement.ts` | Hook de pantalla: `sessionId` estable (`expo-crypto`), foco real (`AppState` + `useIsFocused`), heartbeat 5s, `maxScrollPct` monótono. Emite eventos `init`/`tick` por `onEvent`. |
+| `lib/engagement/sink.ts` | `createEngagementSink()`: adapta el evento del hook (segundos acumulados) al payload del servidor (`focused_seconds_delta`). Uno por apertura de pantalla. |
+| `lib/engagement/queue.ts` | Cola FIFO persistente en `AsyncStorage` (`@engagement/queue`). Máx 500 eventos (descarta los más antiguos), batch de 50, backoff exponencial (1→30s), 401 → refresca sesión Supabase y reintenta. |
+| `lib/engagement/sync.ts` | `startEngagementSync()`: vacía la cola al recuperar conectividad (NetInfo). Se monta una vez en el root. |
+
+## Uso
+
+### 1. En la pantalla de detalle del post
+
+```tsx
+import { useMemo } from 'react';
+import { ScrollView } from 'react-native';
+import { usePostEngagement } from '@/hooks/usePostEngagement';
+import { createEngagementSink } from '@/lib/engagement';
+
+function PostDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+
+ // Un sink por apertura de pantalla (el closure recuerda el acumulado previo).
+ const onEvent = useMemo(() => createEngagementSink(), [id]);
+ const { onScroll } = usePostEngagement(id ?? '', { onEvent });
+
+ return (
+
+ {/* ...contenido del post... */}
+
+ );
+}
+```
+
+El evento `init` (crea la fila `viewed`) se emite al montar; cada `tick` (5s, solo con foco real y
+app en foreground) encola un heartbeat. Al desmontar, timers y listeners se cancelan.
+
+### 2. Flush global (una sola vez, en el root)
+
+`app/_layout.tsx` monta el drenaje de la cola al recuperar red:
+
+```tsx
+useEffect(() => startEngagementSync(), []);
+```
+
+## Contrato de payload
+
+```ts
+{ session_id, post_id, focused_seconds_delta, max_scroll_pct, client_ts }
+```
+
+Enviado a `POST /functions/v1/track-engagement` con `Authorization: Bearer `. El servidor UPSERTea
+por `session_id`, así que reenviar (reintentos, flush offline) es idempotente.
+
+## Pendiente
+
+- `wordCount`: hoy se envía 0. Se rellenará cuando exista `posts.word_count` (EPIC-N02); el servidor
+ usa N = max(15s, palabras/4) para decidir `read`.
diff --git a/lib/engagement/index.ts b/lib/engagement/index.ts
new file mode 100644
index 0000000..5c15111
--- /dev/null
+++ b/lib/engagement/index.ts
@@ -0,0 +1,16 @@
+import { supabase } from '@/lib/supabase';
+
+export { enqueue, flush, size, type EngagementPayload } from './queue';
+export { createEngagementSink } from './sink';
+export { startEngagementSync } from './sync';
+
+// Records that the user opened a post's external link. Writes go through the
+// track-engagement Edge Function because RLS blocks direct client writes to
+// engagement_sessions; the function derives user_id from the JWT and dedups by
+// (user_id, post_id). link_clicked is append-only server-side (never true → false).
+export async function trackLinkClick(postId: string): Promise {
+ const { error } = await supabase.functions.invoke('track-engagement', {
+ body: { post_id: postId, link_clicked: true },
+ });
+ if (error) throw error;
+}
diff --git a/lib/engagement/queue.ts b/lib/engagement/queue.ts
new file mode 100644
index 0000000..5b880a5
--- /dev/null
+++ b/lib/engagement/queue.ts
@@ -0,0 +1,169 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import Constants from 'expo-constants';
+
+import { supabase } from '@/lib/supabase';
+
+// Cola FIFO persistente de eventos de engagement. Los heartbeats generados por
+// usePostEngagement se encolan aquí y se entregan a la Edge Function track-engagement
+// con reintentos, sobreviviendo a caídas de red y cierres de la app.
+
+const QUEUE_KEY = '@engagement/queue';
+const MAX_QUEUE_SIZE = 500;
+const BATCH_SIZE = 50;
+// Backoff exponencial con techo de 30s.
+const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
+const FUNCTION_NAME = 'track-engagement';
+
+export type EngagementPayload = {
+ session_id: string;
+ post_id: string;
+ focused_seconds_delta: number;
+ max_scroll_pct: number;
+ client_ts: string;
+};
+
+function extra(key: 'supabaseUrl' | 'supabaseAnonKey'): T | undefined {
+ return Constants.expoConfig?.extra?.[key] as T | undefined;
+}
+
+function functionsUrl(): string {
+ const base = extra('supabaseUrl');
+ if (!base) throw new Error('supabaseUrl no está configurado en expoConfig.extra');
+ return `${base}/functions/v1/${FUNCTION_NAME}`;
+}
+
+async function readQueue(): Promise {
+ const raw = await AsyncStorage.getItem(QUEUE_KEY);
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? (parsed as EngagementPayload[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+async function writeQueue(queue: EngagementPayload[]): Promise {
+ await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
+}
+
+export async function size(): Promise {
+ return (await readQueue()).length;
+}
+
+export async function enqueue(payload: EngagementPayload): Promise {
+ const queue = await readQueue();
+ queue.push(payload);
+
+ if (queue.length > MAX_QUEUE_SIZE) {
+ const dropped_count = queue.length - MAX_QUEUE_SIZE;
+ queue.splice(0, dropped_count); // descarta los más antiguos (FIFO)
+ console.warn(`[engagement] cola llena: descartados ${dropped_count} eventos antiguos`, {
+ dropped_count,
+ });
+ }
+
+ await writeQueue(queue);
+ // Best-effort: no bloquear al productor esperando la red.
+ void flush();
+}
+
+// ─── Envío HTTP ───────────────────────────────────────────────────────────────
+
+type SendResult = 'ok' | 'retry' | 'drop';
+
+async function accessToken(): Promise {
+ const { data } = await supabase.auth.getSession();
+ return data.session?.access_token ?? null;
+}
+
+async function postBatch(batch: EngagementPayload[], token: string | null): Promise {
+ return fetch(functionsUrl(), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ apikey: extra('supabaseAnonKey') ?? '',
+ Authorization: token ? `Bearer ${token}` : '',
+ },
+ body: JSON.stringify(batch),
+ });
+}
+
+function classify(res: Response): SendResult {
+ if (res.ok) return 'ok';
+ // 5xx y demás: transitorio, reintenta con backoff.
+ if (res.status >= 500) return 'retry';
+ // 4xx (no-401): el servidor rechaza el payload de forma permanente → descartar
+ // para no bloquear la cola en un bucle infinito.
+ return 'drop';
+}
+
+async function sendBatch(batch: EngagementPayload[]): Promise {
+ let res: Response;
+ try {
+ res = await postBatch(batch, await accessToken());
+ } catch {
+ return 'retry'; // error de red
+ }
+
+ // 401: refresca la sesión Supabase y reintenta una sola vez.
+ if (res.status === 401) {
+ await supabase.auth.refreshSession();
+ try {
+ res = await postBatch(batch, await accessToken());
+ } catch {
+ return 'retry';
+ }
+ if (res.status === 401) return 'retry'; // auth aún no disponible: transitorio
+ }
+
+ return classify(res);
+}
+
+// ─── Flush + backoff ────────────────────────────────────────────────────────
+
+let isFlushing = false;
+let backoffIndex = 0;
+let retryTimer: ReturnType | null = null;
+
+function scheduleRetry(): void {
+ if (retryTimer) return;
+ const delay = BACKOFF_MS[Math.min(backoffIndex, BACKOFF_MS.length - 1)]!;
+ backoffIndex += 1;
+ retryTimer = setTimeout(() => {
+ retryTimer = null;
+ void flush();
+ }, delay);
+}
+
+function resetBackoff(): void {
+ backoffIndex = 0;
+ if (retryTimer) {
+ clearTimeout(retryTimer);
+ retryTimer = null;
+ }
+}
+
+export async function flush(): Promise {
+ if (isFlushing) return; // evita drenajes concurrentes
+ isFlushing = true;
+ try {
+ let queue = await readQueue();
+ while (queue.length > 0) {
+ const batch = queue.slice(0, BATCH_SIZE);
+ const result = await sendBatch(batch);
+
+ if (result === 'retry') {
+ scheduleRetry();
+ return; // mantiene el lote en cola para el próximo intento
+ }
+
+ if (result === 'ok') backoffIndex = 0; // se resetea al primer éxito
+ queue = queue.slice(batch.length); // quita el lote del frente (ok o drop)
+ await writeQueue(queue);
+ }
+ resetBackoff();
+ } finally {
+ isFlushing = false;
+ }
+}
diff --git a/lib/engagement/sink.ts b/lib/engagement/sink.ts
new file mode 100644
index 0000000..b3c6eed
--- /dev/null
+++ b/lib/engagement/sink.ts
@@ -0,0 +1,38 @@
+import type { EngagementEvent } from '@/hooks/usePostEngagement';
+
+import { enqueue, type EngagementPayload } from './queue';
+
+// Adaptador entre los eventos del hook usePostEngagement (que emite focusedSeconds
+// acumulado) y el payload que espera la cola/servidor (focused_seconds_delta).
+// Se crea uno por montaje de pantalla: el closure recuerda el acumulado previo
+// para calcular el delta de cada heartbeat.
+export function createEngagementSink(
+ sink: (payload: EngagementPayload) => void = (p) => void enqueue(p),
+): (event: EngagementEvent) => void {
+ let prevFocusedSeconds = 0;
+
+ return (event) => {
+ if (event.type === 'init') {
+ prevFocusedSeconds = 0;
+ // Crea la fila de sesión cuanto antes (estado inicial "viewed").
+ sink({
+ session_id: event.sessionId,
+ post_id: event.postId,
+ focused_seconds_delta: 0,
+ max_scroll_pct: 0,
+ client_ts: event.startedAt,
+ });
+ return;
+ }
+
+ const delta = event.focusedSeconds - prevFocusedSeconds;
+ prevFocusedSeconds = event.focusedSeconds;
+ sink({
+ session_id: event.sessionId,
+ post_id: event.postId,
+ focused_seconds_delta: delta,
+ max_scroll_pct: event.maxScrollPct,
+ client_ts: event.at,
+ });
+ };
+}
diff --git a/lib/engagement/sync.ts b/lib/engagement/sync.ts
new file mode 100644
index 0000000..64998d6
--- /dev/null
+++ b/lib/engagement/sync.ts
@@ -0,0 +1,13 @@
+import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
+
+import { flush } from './queue';
+
+// Vacía la cola de engagement al recuperar conectividad real. Devuelve la función
+// de desuscripción; se montará en el root de la app en la issue de integración.
+export function startEngagementSync(): () => void {
+ return NetInfo.addEventListener((state: NetInfoState) => {
+ if (state.isConnected && state.isInternetReachable) {
+ void flush();
+ }
+ });
+}
diff --git a/lib/ratings.ts b/lib/ratings.ts
new file mode 100644
index 0000000..c5392b6
--- /dev/null
+++ b/lib/ratings.ts
@@ -0,0 +1,47 @@
+import { supabase } from '@/lib/supabase';
+
+export type RatingState = {
+ myRating: number | null;
+ average: number; // 0 when the post has no ratings yet
+ count: number;
+};
+
+export const EMPTY_RATING: RatingState = { myRating: null, average: 0, count: 0 };
+
+// Reads every rating for a post (RLS allows select to any authenticated user) and
+// derives the caller's own rating plus the aggregate in a single round trip.
+export async function getRatingState(postId: string): Promise {
+ const [ratingsResult, userResult] = await Promise.all([
+ supabase.from('post_ratings').select('user_id, rating').eq('post_id', postId),
+ supabase.auth.getUser(),
+ ]);
+
+ if (ratingsResult.error) throw ratingsResult.error;
+
+ const ratings = ratingsResult.data ?? [];
+ const count = ratings.length;
+ const sum = ratings.reduce((acc, r) => acc + r.rating, 0);
+ const average = count > 0 ? sum / count : 0;
+
+ const userId = userResult.data.user?.id;
+ const myRating = userId
+ ? (ratings.find((r) => r.user_id === userId)?.rating ?? null)
+ : null;
+
+ return { myRating, average, count };
+}
+
+// Idempotent upsert keyed on the composite PK (post_id, user_id): rating a post
+// again overwrites the previous value instead of inserting a new row.
+export async function upsertRating(postId: string, rating: number): Promise {
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) throw new Error('No autenticado');
+
+ const { error } = await supabase.from('post_ratings').upsert(
+ { post_id: postId, user_id: user.id, rating, updated_at: new Date().toISOString() },
+ { onConflict: 'post_id,user_id' },
+ );
+ if (error) throw error;
+}
diff --git a/lib/supabase/queries/posts.ts b/lib/supabase/queries/posts.ts
index b87b98d..cb46bad 100644
--- a/lib/supabase/queries/posts.ts
+++ b/lib/supabase/queries/posts.ts
@@ -6,10 +6,24 @@ import type { Database, Tables } from '@/lib/database.types';
type ProfileSnippet = { name: string | null; surname: string | null };
type PostAuthor = { id: string; name: string | null; surname: string | null; avatar_url: string | null };
-export type PostWithAuthor = Tables<'posts'> & { author: ProfileSnippet };
+export type PostWithAuthor = Tables<'posts'> & {
+ author: ProfileSnippet;
+ comments_count: number;
+ rating_average: number; // 0 when the post has no ratings yet
+ rating_count: number;
+};
export type PostDetail = Tables<'posts'> & { author: PostAuthor };
export type PostCursor = { published_at: string; id: string };
+// PostgREST reverse-embed: post_comments(count) yields [{ count }] per post and
+// post_ratings(rating) yields the rating rows, so the feed's comment and rating
+// aggregates ride on the same query (no N+1).
+type PostRow = Tables<'posts'> & {
+ author: ProfileSnippet;
+ post_comments: { count: number }[];
+ post_ratings: { rating: number }[];
+};
+
export async function listPublishedPosts({
cursor,
pageSize = 20,
@@ -21,7 +35,7 @@ export async function listPublishedPosts({
}): Promise<{ rows: PostWithAuthor[]; nextCursor: PostCursor | null }> {
let query = client
.from('posts')
- .select('*, author:profiles!author_id(name, surname)')
+ .select('*, author:profiles!author_id(name, surname), post_comments(count), post_ratings(rating)')
.eq('status', 'published')
.is('deleted_at', null)
.order('published_at', { ascending: false })
@@ -38,7 +52,21 @@ export async function listPublishedPosts({
if (error) throw error;
- const rows = (data ?? []) as PostWithAuthor[];
+ const rows: PostWithAuthor[] = ((data ?? []) as unknown as PostRow[]).map(
+ ({ post_comments, post_ratings, ...post }) => {
+ const rating_count = post_ratings.length;
+ const rating_average =
+ rating_count > 0
+ ? post_ratings.reduce((acc, r) => acc + r.rating, 0) / rating_count
+ : 0;
+ return {
+ ...post,
+ comments_count: post_comments[0]?.count ?? 0,
+ rating_average,
+ rating_count,
+ };
+ },
+ );
const lastRow = rows[rows.length - 1];
const nextCursor =
rows.length === pageSize && lastRow?.published_at
diff --git a/package.json b/package.json
index b2b3b8d..b646a7e 100644
--- a/package.json
+++ b/package.json
@@ -24,9 +24,12 @@
"lint:md": "markdownlint-cli2 docs/adr/**/*.md"
},
"dependencies": {
+ "@react-native-async-storage/async-storage": "1.23.1",
+ "@react-native-community/netinfo": "11.4.1",
"@supabase/supabase-js": "^2.108.1",
"expo": "~52.0.0",
"expo-constants": "~17.0.8",
+ "expo-crypto": "~14.0.2",
"expo-image": "~2.0.0",
"expo-image-manipulator": "~13.0.0",
"expo-image-picker": "~16.0.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4525a2f..a33f1c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
+ '@react-native-async-storage/async-storage':
+ specifier: 1.23.1
+ version: 1.23.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))
+ '@react-native-community/netinfo':
+ specifier: 11.4.1
+ version: 11.4.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))
'@supabase/supabase-js':
specifier: ^2.108.1
version: 2.108.1
@@ -17,6 +23,9 @@ importers:
expo-constants:
specifier: ~17.0.8
version: 17.0.8(expo@52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))
+ expo-crypto:
+ specifier: ~14.0.2
+ version: 14.0.2(expo@52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))
expo-image:
specifier: ~2.0.0
version: 2.0.7(expo@52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)
@@ -1328,6 +1337,16 @@ packages:
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
+ '@react-native-async-storage/async-storage@1.23.1':
+ resolution: {integrity: sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==}
+ peerDependencies:
+ react-native: ^0.0.0-0 || >=0.60 <1.0
+
+ '@react-native-community/netinfo@11.4.1':
+ resolution: {integrity: sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==}
+ peerDependencies:
+ react-native: '>=0.59'
+
'@react-native/assets-registry@0.76.9':
resolution: {integrity: sha512-pN0Ws5xsjWOZ8P37efh0jqHHQmq+oNGKT4AyAoKRpxBDDDmlAmpaYjer9Qz7PpDKF+IUyRjF/+rBsM50a8JcUg==}
engines: {node: '>=18'}
@@ -3092,6 +3111,11 @@ packages:
expo: '*'
react-native: '*'
+ expo-crypto@14.0.2:
+ resolution: {integrity: sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==}
+ peerDependencies:
+ expo: '*'
+
expo-file-system@18.0.12:
resolution: {integrity: sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==}
peerDependencies:
@@ -3786,6 +3810,10 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
+ is-plain-obj@2.1.0:
+ resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
+ engines: {node: '>=8'}
+
is-plain-object@2.0.4:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
@@ -4427,6 +4455,10 @@ packages:
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
+ merge-options@3.0.4:
+ resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
+ engines: {node: '>=10'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -8320,6 +8352,15 @@ snapshots:
'@radix-ui/react-compose-refs': 1.0.0(react@18.3.1)
react: 18.3.1
+ '@react-native-async-storage/async-storage@1.23.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))':
+ dependencies:
+ merge-options: 3.0.4
+ react-native: 0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)
+
+ '@react-native-community/netinfo@11.4.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))':
+ dependencies:
+ react-native: 0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)
+
'@react-native/assets-registry@0.76.9': {}
'@react-native/babel-plugin-codegen@0.76.9(@babel/preset-env@7.29.7(@babel/core@7.29.7))':
@@ -10487,6 +10528,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ expo-crypto@14.0.2(expo@52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)):
+ dependencies:
+ base64-js: 1.5.1
+ expo: 52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)
+
expo-file-system@18.0.12(expo@52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)):
dependencies:
expo: 52.0.49(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1)))(graphql@16.8.1)(react-native@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)
@@ -11252,6 +11298,8 @@ snapshots:
is-path-inside@3.0.3: {}
+ is-plain-obj@2.1.0: {}
+
is-plain-object@2.0.4:
dependencies:
isobject: 3.0.1
@@ -12177,6 +12225,10 @@ snapshots:
memoize-one@5.2.1: {}
+ merge-options@3.0.4:
+ dependencies:
+ is-plain-obj: 2.1.0
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
diff --git a/supabase/migrations/20260708200000_alter_post_comments_n03_schema.sql b/supabase/migrations/20260708200000_alter_post_comments_n03_schema.sql
new file mode 100644
index 0000000..8a1b46b
--- /dev/null
+++ b/supabase/migrations/20260708200000_alter_post_comments_n03_schema.sql
@@ -0,0 +1,17 @@
+-- Migration: N03-02-01 — Align post_comments scaffold with N03 spec (issue #164)
+-- Precondition: 20260618100000 (tabla post_comments + trigger updated_at existen)
+-- Note: FK author_id se mantiene en auth.users(id) — las RLS policies (author_id =
+-- auth.uid()) siguen siendo válidas; el cambio a profiles(id) queda para N03-02-03.
+
+-- 1. CHECK de longitud de body (1..2000)
+alter table public.post_comments
+ add constraint post_comments_body_length
+ check (char_length(body) between 1 and 2000);
+
+-- 2. Índice de paginación cursor-based (post_id, created_at desc)
+-- reemplaza al índice simple (post_id) del scaffold.
+drop index if exists public.post_comments_post_id_idx;
+create index post_comments_post_id_created_at_idx
+ on public.post_comments (post_id, created_at desc);
+
+-- Índice (author_id), trigger updated_at, RLS y grants ya existen (20260618100000).
diff --git a/supabase/migrations/20260709000000_rls_post_ratings_n03_03_03.sql b/supabase/migrations/20260709000000_rls_post_ratings_n03_03_03.sql
new file mode 100644
index 0000000..b1b5fc8
--- /dev/null
+++ b/supabase/migrations/20260709000000_rls_post_ratings_n03_03_03.sql
@@ -0,0 +1,19 @@
+-- Migration: N03-03-03 — Formalize RLS policy names for post_ratings
+-- Issue: I-F-N03-03-03 (#168)
+-- Precondition: 20260618200000 (RLS enabled + initial policies created with _self suffix),
+-- 20260708100000 (schema evolved to composite PK + rating column)
+--
+-- The initial scaffold (20260618200000) enabled RLS and created the write policies
+-- with the _self suffix. This migration renames them to the N03 standard (_own),
+-- mirroring post_reactions (20260707100000). No policy logic changes: select stays
+-- open to any authenticated user, insert/update stay restricted to user_id = auth.uid(),
+-- and there is intentionally no DELETE policy (ratings are updated in place, never
+-- deleted; DELETE is also not granted to `authenticated`).
+
+alter policy post_ratings_insert_self
+ on public.post_ratings rename to post_ratings_insert_own;
+
+alter policy post_ratings_update_self
+ on public.post_ratings rename to post_ratings_update_own;
+
+-- post_ratings_select_authenticated already matches the N03 naming convention.
diff --git a/supabase/migrations/20260710000000_replace_engagement_sessions_n04_schema.sql b/supabase/migrations/20260710000000_replace_engagement_sessions_n04_schema.sql
new file mode 100644
index 0000000..f1c871f
--- /dev/null
+++ b/supabase/migrations/20260710000000_replace_engagement_sessions_n04_schema.sql
@@ -0,0 +1,64 @@
+-- Migration: replace engagement_sessions with the N04 reading-session model
+-- Part of EPIC-N04 / F-N04-02 / Issue I-F-N04-02-01 (#178)
+-- Precondition: 20260618500000 (old engagement_sessions), public.set_updated_at() (20260617000001)
+--
+-- Supersedes ADR-001: the prior (user_id, post_id)-unique link_clicked model is
+-- dropped and rebuilt as one row per reading session (session_id PK), tracking
+-- focused_seconds + max_scroll_pct + derived state (viewed → skimmed → read).
+-- See docs/adr/0003-engagement-reading-sessions.md.
+--
+-- Writes stay exclusive to the track-engagement Edge Function (service_role);
+-- RLS SELECT policies land in EPIC-S00 (default-deny meanwhile).
+
+-- 1. Drop the old model. cascade removes its RLS policy, grant and indexes.
+drop table if exists public.engagement_sessions cascade;
+
+-- 2. Business state of a reading session (ADR-0003). Guarded so the migration is
+-- idempotent if the enum already exists from a partial run.
+do $$
+begin
+ create type public.engagement_state as enum ('viewed', 'skimmed', 'read');
+exception
+ when duplicate_object then null;
+end $$;
+
+-- 3. One row per reading session. session_id is generated client-side on screen open.
+create table public.engagement_sessions (
+ session_id uuid primary key,
+ post_id uuid not null references public.posts(id) on delete cascade,
+ user_id uuid not null references public.profiles(id) on delete cascade,
+ focused_seconds integer not null default 0 check (focused_seconds >= 0),
+ max_scroll_pct numeric(4,3) not null default 0
+ check (max_scroll_pct >= 0 and max_scroll_pct <= 1),
+ state public.engagement_state not null default 'viewed',
+ started_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+comment on column public.engagement_sessions.focused_seconds is
+ 'Segundos de foco in-app acumulados; la Edge Function los suma desde los deltas del cliente.';
+comment on column public.engagement_sessions.max_scroll_pct is
+ 'Máximo scroll alcanzado en la sesión, monotónico ∈ [0, 1].';
+comment on column public.engagement_sessions.state is
+ 'Estado derivado por el servidor (viewed → skimmed → read) según ADR-0003.';
+
+create index engagement_sessions_post_id_idx on public.engagement_sessions (post_id);
+create index engagement_sessions_user_id_idx on public.engagement_sessions (user_id);
+create index engagement_sessions_post_state_idx on public.engagement_sessions (post_id, state);
+create index engagement_sessions_updated_at_idx on public.engagement_sessions (updated_at);
+
+-- 4. Keep updated_at fresh on every UPDATE (shared trigger fn from 20260617000001).
+create trigger engagement_sessions_updated_at
+ before update on public.engagement_sessions
+ for each row execute function public.set_updated_at();
+
+-- 5. Enable RLS. No policies here — SELECT policies (own / manager-admin) land in
+-- EPIC-S00 / I-F-S00-04-05. Until then RLS denies reads by default. All writes
+-- go through the track-engagement Edge Function with service_role (bypasses RLS).
+alter table public.engagement_sessions enable row level security;
+
+grant select on public.engagement_sessions to authenticated;
+
+comment on table public.engagement_sessions is
+ 'Una fila por sesión de lectura (session_id). Escritura exclusiva vía Edge Function '
+ 'track-engagement (service_role). Lectura: policies en EPIC-S00. refs: docs/adr/0003-engagement-reading-sessions.md';
diff --git a/supabase/tests/rls/rls_engagement_sessions.sql b/supabase/tests/rls/rls_engagement_sessions.sql
index 14b9b19..b82b9e4 100644
--- a/supabase/tests/rls/rls_engagement_sessions.sql
+++ b/supabase/tests/rls/rls_engagement_sessions.sql
@@ -1,15 +1,17 @@
--- RLS tests: engagement_sessions table
--- Policy: engagement_sessions_select_own_or_manager
--- No write policies exist by design — all writes go through service_role.
--- refs: docs/adr/0002-rbac.md
+-- RLS tests: engagement_sessions (N04 reading-session model, #178)
+-- After I-F-N04-02-01 the table has RLS enabled but NO policies yet — the SELECT
+-- policies (own / manager-admin) are delivered in EPIC-S00 / I-F-S00-04-05. Until
+-- then the table is default-deny for authenticated, and all writes go through the
+-- track-engagement Edge Function with service_role. This test locks in that
+-- intentional gap: authenticated reads see nothing and cannot write directly.
+-- refs: 20260710000000_replace_engagement_sessions_n04_schema.sql · docs/adr/0002-rbac.md
--
-- 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(5);
+select plan(3);
create or replace function pg_temp.set_session(uid uuid)
returns void language plpgsql as $$
@@ -23,97 +25,50 @@ begin
end;
$$;
-create or replace function pg_temp.reset_session()
-returns void language plpgsql as $$
-begin
- perform set_config('request.jwt.claims', '{}', true);
- reset role;
-end;
-$$;
-
--- Fixtures (inserted as postgres = service_role equivalent, bypasses RLS)
+-- Fixtures (inserted as postgres = service_role equivalent, bypasses RLS).
insert into public.posts (id, author_id, title, external_url)
select
- 'aaaaaaaa-1111-0000-0000-000000000001'::uuid,
+ 'dddddddd-0000-0000-0000-0000000000aa'::uuid,
p.id,
- 'Post for engagement',
- 'https://example.com/test'
+ 'Post for engagement RLS',
+ 'https://example.com/rls'
from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid;
--- Staff owns this session
-insert into public.engagement_sessions (id, user_id, post_id)
-values ('aaaaaaaa-1111-0000-0000-000000000010'::uuid,
- 'aaaaaaaa-0000-0000-0000-000000000003'::uuid,
- 'aaaaaaaa-1111-0000-0000-000000000001'::uuid);
-
--- Manager owns this session
-insert into public.engagement_sessions (id, user_id, post_id)
-values ('aaaaaaaa-1111-0000-0000-000000000011'::uuid,
- 'aaaaaaaa-0000-0000-0000-000000000002'::uuid,
- 'aaaaaaaa-1111-0000-0000-000000000001'::uuid);
-
--- ── SELECT ────────────────────────────────────────────────────────────────────
+insert into public.engagement_sessions (session_id, post_id, user_id)
+select
+ 'dddddddd-1111-0000-0000-0000000000aa'::uuid,
+ 'dddddddd-0000-0000-0000-0000000000aa'::uuid,
+ p.id
+from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid;
--- Positive: staff sees only their own sessions
+-- ── SELECT default-deny (no policy yet → 0 rows for any authenticated user) ──
select pg_temp.set_session('aaaaaaaa-0000-0000-0000-000000000003'::uuid);
-
-select results_eq(
- $test$ select count(*)::int from public.engagement_sessions $test$,
- $expected$ values (1) $expected$,
- 'staff solo ve sus propias sesiones de engagement'
-);
-
--- Negative: staff cannot see sessions belonging to other users
-select results_eq(
- $test$
- select count(*)::int from public.engagement_sessions
- where user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid
- $test$,
- $expected$ values (0) $expected$,
- 'staff no puede ver sesiones de otro usuario'
+select is(
+ (select count(*)::int from public.engagement_sessions),
+ 0,
+ 'staff no ve ninguna sesión (RLS default-deny, policy pendiente en EPIC-S00)'
);
--- Positive: manager sees all sessions (dashboard)
select pg_temp.set_session('aaaaaaaa-0000-0000-0000-000000000002'::uuid);
-
-select results_eq(
- $test$ select count(*)::int from public.engagement_sessions $test$,
- $expected$ values (2) $expected$,
- 'manager ve todas las sesiones de engagement (dashboard)'
+select is(
+ (select count(*)::int from public.engagement_sessions),
+ 0,
+ 'manager tampoco ve sesiones aún (policy de dashboard pendiente en EPIC-S00)'
);
--- ── INSERT (write denied for authenticated) ───────────────────────────────────
-
--- Negative: authenticated staff cannot insert directly (no write policy)
+-- ── INSERT denied for authenticated (writes only via service_role) ───────────
select pg_temp.set_session('aaaaaaaa-0000-0000-0000-000000000003'::uuid);
-
-select throws_ok(
- $test$
- insert into public.engagement_sessions (user_id, post_id)
- values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid,
- 'aaaaaaaa-1111-0000-0000-000000000001'::uuid)
- $test$,
- '42501',
- null,
- 'authenticated no puede insertar en engagement_sessions (solo Edge Function con service_role)'
-);
-
--- Negative: anon cannot insert
-select pg_temp.reset_session();
-set local role anon;
-
select throws_ok(
$test$
- insert into public.engagement_sessions (user_id, post_id)
- values ('aaaaaaaa-0000-0000-0000-000000000003'::uuid,
- 'aaaaaaaa-1111-0000-0000-000000000001'::uuid)
+ insert into public.engagement_sessions (session_id, post_id, user_id)
+ values ('dddddddd-1111-0000-0000-0000000000bb'::uuid,
+ 'dddddddd-0000-0000-0000-0000000000aa'::uuid,
+ 'dddddddd-0000-0000-0000-0000000000cc'::uuid)
$test$,
'42501',
null,
- 'anon no puede insertar en engagement_sessions'
+ 'authenticated no puede insertar en engagement_sessions (solo Edge Function service_role)'
);
-reset role;
-
select * from finish();
rollback;
diff --git a/supabase/tests/rls/rls_post_comments.sql b/supabase/tests/rls/rls_post_comments.sql
index 0febaa7..bad483a 100644
--- a/supabase/tests/rls/rls_post_comments.sql
+++ b/supabase/tests/rls/rls_post_comments.sql
@@ -9,7 +9,7 @@
-- staff: aaaaaaaa-0000-0000-0000-000000000003
begin;
-select plan(7);
+select plan(8);
create or replace function pg_temp.set_session(uid uuid)
returns void language plpgsql as $$
@@ -69,6 +69,19 @@ select lives_ok(
'staff puede insertar su propio comentario'
);
+-- Negative: staff cannot insert a comment on behalf of another user
+select throws_ok(
+ $test$
+ insert into public.post_comments (post_id, author_id, body)
+ values ('eeeeeeee-0000-0000-0000-000000000001'::uuid,
+ 'aaaaaaaa-0000-0000-0000-000000000002'::uuid, -- manager, not the caller
+ 'Comentario suplantado')
+ $test$,
+ '42501',
+ null,
+ 'staff no puede insertar un comentario en nombre de otro usuario'
+);
+
-- ── DELETE ────────────────────────────────────────────────────────────────────
-- Positive: staff can delete their own comment
diff --git a/supabase/tests/rls/rls_post_ratings.sql b/supabase/tests/rls/rls_post_ratings.sql
index 138dd50..884c705 100644
--- a/supabase/tests/rls/rls_post_ratings.sql
+++ b/supabase/tests/rls/rls_post_ratings.sql
@@ -1,8 +1,9 @@
-- RLS tests: post_ratings table
--- Policies: post_ratings_select_authenticated, post_ratings_insert_self,
--- post_ratings_update_self
--- Note: no DELETE policy exists by design — ratings are updated, never deleted.
--- refs: docs/adr/0002-rbac.md
+-- Policies: post_ratings_select_authenticated, post_ratings_insert_own,
+-- post_ratings_update_own
+-- Note: no DELETE policy exists by design — ratings are updated, never deleted;
+-- DELETE is also not granted to `authenticated`, so it is rejected outright.
+-- refs: docs/adr/0002-rbac.md, migration 20260709000000_rls_post_ratings_n03_03_03.sql
--
-- Seed UUIDs (supabase/seed.sql):
-- admin: aaaaaaaa-0000-0000-0000-000000000001
@@ -10,7 +11,7 @@
-- staff: aaaaaaaa-0000-0000-0000-000000000003
begin;
-select plan(5);
+select plan(6);
create or replace function pg_temp.set_session(uid uuid)
returns void language plpgsql as $$
@@ -99,5 +100,19 @@ select results_eq(
'staff no puede modificar la valoración de otro usuario'
);
+-- ── DELETE ────────────────────────────────────────────────────────────────────
+
+-- Negative: no DELETE policy and no DELETE grant → any delete is rejected
+select throws_ok(
+ $test$
+ delete from public.post_ratings
+ where post_id = 'dddddddd-0000-0000-0000-000000000001'::uuid
+ and user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid
+ $test$,
+ '42501',
+ null,
+ 'staff no puede borrar ninguna valoración'
+);
+
select * from finish();
rollback;
diff --git a/supabase/tests/rls/schema_engagement_sessions.sql b/supabase/tests/rls/schema_engagement_sessions.sql
new file mode 100644
index 0000000..112d678
--- /dev/null
+++ b/supabase/tests/rls/schema_engagement_sessions.sql
@@ -0,0 +1,133 @@
+-- Schema tests: engagement_sessions structure & constraints (#178, I-F-N04-02-01)
+-- Verifies the N04 reading-session shape: session_id PK, focused_seconds/max_scroll_pct
+-- with CHECK ranges, engagement_state enum, on-conflict upsert, and cascade deletes
+-- from posts and profiles. Runs as the default role (BYPASSRLS on public tables),
+-- so RLS does not interfere here.
+-- refs: 20260710000000_replace_engagement_sessions_n04_schema.sql · docs/adr/0003-engagement-reading-sessions.md
+--
+-- 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(16);
+
+-- ── Structure ──────────────────────────────────────────────────────────────
+select has_column('public', 'engagement_sessions', 'session_id', 'has session_id column');
+select col_is_pk('public', 'engagement_sessions', 'session_id', 'primary key is session_id');
+select has_column('public', 'engagement_sessions', 'focused_seconds', 'has focused_seconds column');
+select has_column('public', 'engagement_sessions', 'max_scroll_pct', 'has max_scroll_pct column');
+select has_column('public', 'engagement_sessions', 'state', 'has state column');
+select col_type_is('public', 'engagement_sessions', 'state', 'engagement_state', 'state is engagement_state enum');
+select hasnt_column('public', 'engagement_sessions', 'id', 'legacy surrogate id column is gone');
+select hasnt_column('public', 'engagement_sessions', 'link_clicked', 'legacy link_clicked column is gone');
+select hasnt_column('public', 'engagement_sessions', 'status', 'legacy status column is gone');
+select hasnt_column('public', 'engagement_sessions', 'device', 'legacy device column is gone');
+
+-- ── Fixtures (post authored by manager; session owned by staff's profile) ────
+insert into public.posts (id, author_id, title, external_url)
+select
+ 'dddddddd-0000-0000-0000-000000000001'::uuid,
+ p.id,
+ 'Post for engagement schema',
+ 'https://example.com/e'
+from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid;
+
+insert into public.engagement_sessions (session_id, post_id, user_id, focused_seconds, max_scroll_pct)
+select
+ 'dddddddd-1111-0000-0000-000000000001'::uuid,
+ 'dddddddd-0000-0000-0000-000000000001'::uuid,
+ p.id,
+ 10,
+ 0.5
+from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid;
+
+-- ── Defaults ─────────────────────────────────────────────────────────────────
+select is(
+ (select state::text from public.engagement_sessions
+ where session_id = 'dddddddd-1111-0000-0000-000000000001'::uuid),
+ 'viewed',
+ 'state defaults to viewed'
+);
+
+-- ── CHECK constraints reject invalid values ─────────────────────────────────
+select throws_ok(
+ $test$
+ insert into public.engagement_sessions (session_id, post_id, user_id, max_scroll_pct)
+ select 'dddddddd-1111-0000-0000-0000000000ff'::uuid,
+ 'dddddddd-0000-0000-0000-000000000001'::uuid, p.id, 1.5
+ from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid
+ $test$,
+ '23514',
+ null,
+ 'max_scroll_pct = 1.5 violates the CHECK (0..1)'
+);
+
+select throws_ok(
+ $test$
+ insert into public.engagement_sessions (session_id, post_id, user_id, focused_seconds)
+ select 'dddddddd-1111-0000-0000-0000000000fe'::uuid,
+ 'dddddddd-0000-0000-0000-000000000001'::uuid, p.id, -1
+ from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid
+ $test$,
+ '23514',
+ null,
+ 'focused_seconds = -1 violates the CHECK (>= 0)'
+);
+
+-- ── PK upsert (on conflict (session_id) do update) ───────────────────────────
+insert into public.engagement_sessions (session_id, post_id, user_id, focused_seconds, max_scroll_pct)
+select
+ 'dddddddd-1111-0000-0000-000000000001'::uuid,
+ 'dddddddd-0000-0000-0000-000000000001'::uuid,
+ p.id, 25, 0.8
+from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid
+on conflict (session_id) do update
+ set focused_seconds = excluded.focused_seconds,
+ max_scroll_pct = excluded.max_scroll_pct;
+
+select results_eq(
+ $test$
+ select count(*)::int, max(focused_seconds)::int from public.engagement_sessions
+ where session_id = 'dddddddd-1111-0000-0000-000000000001'::uuid
+ $test$,
+ $expected$ values (1, 25) $expected$,
+ 'on conflict (session_id) updates the existing row in place (no duplicate)'
+);
+
+-- ── Cascade: deleting the post removes its sessions ──────────────────────────
+delete from public.posts where id = 'dddddddd-0000-0000-0000-000000000001'::uuid;
+
+select is(
+ (select count(*)::int from public.engagement_sessions
+ where session_id = 'dddddddd-1111-0000-0000-000000000001'::uuid),
+ 0,
+ 'deleting a post cascades to its engagement_sessions'
+);
+
+-- ── Cascade: deleting a profile removes its sessions ─────────────────────────
+insert into public.posts (id, author_id, title, external_url)
+select
+ 'dddddddd-0000-0000-0000-000000000002'::uuid,
+ p.id, 'Post for cascade', 'https://example.com/e2'
+from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000002'::uuid;
+
+insert into public.engagement_sessions (session_id, post_id, user_id)
+select
+ 'dddddddd-1111-0000-0000-000000000002'::uuid,
+ 'dddddddd-0000-0000-0000-000000000002'::uuid,
+ p.id
+from public.profiles p where p.user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid;
+
+delete from public.profiles where user_id = 'aaaaaaaa-0000-0000-0000-000000000003'::uuid;
+
+select is(
+ (select count(*)::int from public.engagement_sessions
+ where session_id = 'dddddddd-1111-0000-0000-000000000002'::uuid),
+ 0,
+ 'deleting a profile cascades to its engagement_sessions'
+);
+
+select * from finish();
+rollback;