Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
7989905
feat(db): align post_comments schema with N03 spec — body CHECK + pag…
Piedra-1988 Jul 8, 2026
27c6d7f
Merge remote-tracking branch 'origin/develop' into task/n03-02-01-pos…
Piedra-1988 Jul 8, 2026
73ac331
fix(db): rename post_comments migration to avoid 20260708000000 versi…
Piedra-1988 Jul 8, 2026
e48751c
Merge pull request #214 from CodeCrafters-ES/task/n03-02-01-post-comm…
Piedra-1988 Jul 8, 2026
6f1a7a2
feat(comments): mobile UI to create/list post comments (paginated) — …
Piedra-1988 Jul 8, 2026
92798aa
Merge pull request #215 from CodeCrafters-ES/task/n03-02-02-mobile-co…
Piedra-1988 Jul 8, 2026
2f674f9
test(rls): add post_comments INSERT-negative case + align ADR-002 pol…
Piedra-1988 Jul 8, 2026
4b6294a
Merge pull request #216 from CodeCrafters-ES/task/n03-02-03-comments-rls
Piedra-1988 Jul 8, 2026
518d16c
feat(comments): post comment count aggregate + finalize RLS moderation
Piedra-1988 Jul 8, 2026
bf747fd
Merge pull request #217 from CodeCrafters-ES/feat/n03-02-comments
Piedra-1988 Jul 8, 2026
5383375
feat(ratings): add StarRating UI with optimistic idempotent upsert (#…
Azfe Jul 9, 2026
a77f028
test(ratings): cover usePostRating and StarRating (#169)
Azfe Jul 9, 2026
b413f9a
Merge branch 'develop' into task/n03-03-02-star-rating-ui
Azfe Jul 9, 2026
4ebc097
Merge pull request #218 from CodeCrafters-ES/task/n03-03-02-star-rati…
Azfe Jul 9, 2026
8e1a6fd
feat(ratings): formalize post_ratings RLS policy names to _own (#168)
Azfe Jul 9, 2026
32a4659
Merge pull request #219 from CodeCrafters-ES/task/n03-03-03-rls-post-…
Azfe Jul 9, 2026
dd5b237
feat(ratings): show post rating average in the feed (#159)
Azfe Jul 9, 2026
cad9229
test(ratings): cover feed rating average in PostCard and useFeed (#159)
Azfe Jul 9, 2026
e739a24
Merge pull request #220 from CodeCrafters-ES/feat/n03-03-ratings
Azfe Jul 9, 2026
6486a20
feat(engagement): track link_clicked before opening external link (#170)
Azfe Jul 9, 2026
0e4ea77
Merge pull request #221 from CodeCrafters-ES/task/n03-04-01-link-clic…
Azfe Jul 9, 2026
9c399b7
feat(engagement): add usePostEngagement hook (#176)
Piedra-1988 Jul 9, 2026
0dbc784
Merge pull request #222 from CodeCrafters-ES/task/n04-01-01-use-post-…
Piedra-1988 Jul 9, 2026
0fcb857
feat(engagement): heartbeats + offline queue (#177)
Piedra-1988 Jul 9, 2026
0f7bb67
Merge pull request #223 from CodeCrafters-ES/task/n04-01-02-heartbeat…
Piedra-1988 Jul 9, 2026
73d5e01
feat(engagement): wire post engagement tracking into UI (#173)
Piedra-1988 Jul 9, 2026
6185038
Merge pull request #224 from CodeCrafters-ES/feat/n04-01-engagement-c…
Piedra-1988 Jul 9, 2026
8eda107
feat(engagement): replace engagement_sessions with reading-session mo…
Piedra-1988 Jul 9, 2026
ddd67b4
fix(docs): satisfy markdownlint MD032 in ADR-0003
Piedra-1988 Jul 9, 2026
f014fe9
Merge pull request #226 from CodeCrafters-ES/task/n04-02-01-engagemen…
Piedra-1988 Jul 9, 2026
d968730
revert: engagement_sessions reading-session model (#178)
Azfe Jul 10, 2026
a32a60d
docs(adr): add ADR-0006 for additive engagement behavioral signals
Azfe Jul 10, 2026
8bc3030
chore(github): add CODEOWNERS, PR template and branch protection ruleset
Azfe Jul 10, 2026
4d61599
feat(engagement): align status and add behavioral columns to engageme…
Azfe Jul 10, 2026
c429234
test(engagement): add schema/constraints pgTAP for engagement_session…
Azfe Jul 10, 2026
f16a94f
Merge pull request #227 from CodeCrafters-ES/task/n04-02-01-engagemen…
Azfe Jul 10, 2026
53896a0
feat(engagement): add apply_engagement_events RPC for batch upsert (#…
Azfe Jul 10, 2026
4e7fb6e
feat(engagement): rewrite track-engagement to accept event batches (#…
Azfe Jul 10, 2026
a0d75b9
fix(engagement): send link click as a one-event batch (#179)
Azfe Jul 10, 2026
05e7014
Merge pull request #228 from CodeCrafters-ES/task/n04-02-02-edge-func…
Azfe Jul 10, 2026
5c2d6a0
feat(engagement): queue external-link clicks for offline retry (#171)
Azfe Jul 10, 2026
1ea2595
test(engagement): cover offline queueing of link clicks (#171)
Azfe Jul 10, 2026
e0b6086
Merge pull request #229 from CodeCrafters-ES/task/n03-04-02-offline-c…
Azfe Jul 10, 2026
63d83b5
docs(engagement): align README with viewed/engaged/clicked model (#173)
Azfe Jul 10, 2026
892aaa4
ci: serve edge functions before integration tests (#179)
Azfe Jul 11, 2026
47bf0fc
Merge pull request #230 from CodeCrafters-ES/feat/n04-01-engagement-c…
Azfe Jul 11, 2026
93d1797
fix(engagement): include session_id in track-engagement logs (#174)
Azfe Jul 11, 2026
5b0eedb
test(engagement): raise timeout and warm up track-engagement e2e (#174)
Azfe Jul 11, 2026
907fd6d
Merge pull request #231 from CodeCrafters-ES/feat/n04-02-engagement-a…
Azfe Jul 11, 2026
60de634
feat(engagement): add post_engagement_metrics view (#180)
Azfe Jul 11, 2026
6665d22
test(engagement): add fixed-dataset pgTAP for post_engagement_metrics…
Azfe Jul 11, 2026
ff6a6d3
Merge pull request #232 from CodeCrafters-ES/task/n04-03-01-engagemen…
Azfe Jul 11, 2026
438dbfe
feat(engagement): add post_engagement_daily materialized view (#181)
Azfe Jul 11, 2026
337b274
test(engagement): add fixed-dataset pgTAP for post_engagement_daily (…
Azfe Jul 11, 2026
2ad3ef0
Merge pull request #233 from CodeCrafters-ES/task/n04-03-02-post-enga…
Azfe Jul 11, 2026
e4646cb
feat(engagement): add post engagement metrics query and hook (#182)
Azfe Jul 11, 2026
b45f765
feat(engagement): add admin engagement screen (#182)
Azfe Jul 11, 2026
0e8f994
test(engagement): cover the engagement metrics query and screen (#182)
Azfe Jul 11, 2026
89a9a07
ci: warm the edge function before integration tests (#182)
Azfe Jul 11, 2026
174f1d0
Merge pull request #234 from CodeCrafters-ES/task/n04-03-03-engagemen…
Azfe Jul 11, 2026
97f880e
feat(engagement): add manual refresh RPC for the engagement dashboard…
Azfe Jul 11, 2026
70a4791
test(engagement): add end-to-end dashboard integration test (#175)
Azfe Jul 11, 2026
ca77032
ci: read the anon key from status --output json and warm with a beare…
Azfe Jul 11, 2026
3d156fd
Merge pull request #235 from CodeCrafters-ES/feat/n04-03-engagement-d…
Azfe Jul 11, 2026
d0a4afd
feat(engagement): add engaged_users to post_engagement_daily (#172)
Azfe Jul 11, 2026
160e6a9
feat(engagement): surface engaged users on the dashboard (#172)
Azfe Jul 11, 2026
f102ddf
test(engagement): cover engaged users end to end (#172)
Azfe Jul 11, 2026
b72921a
test(engagement): retry transient 5xx in the track-engagement e2e (#172)
Azfe Jul 11, 2026
6055772
Merge pull request #236 from CodeCrafters-ES/feat/n04-engaged-users
Azfe Jul 11, 2026
5ac8a33
fix(errors): surface Supabase query errors instead of swallowing them
Azfe Jul 11, 2026
21dae75
fix(errors): report database failures from the hooks and profile screen
Azfe Jul 11, 2026
e1dda58
Merge pull request #237 from CodeCrafters-ES/fix/surface-postgrest-er…
Azfe Jul 11, 2026
2179766
fix(posts): embed author via profiles_public so staff can load the feed
Azfe Jul 11, 2026
0593d69
Merge pull request #238 from CodeCrafters-ES/fix/feed-author-embed-pr…
Azfe Jul 11, 2026
e39ab45
fix(session): share auth state via SessionProvider to stop redirect loop
Azfe Jul 11, 2026
1141796
Merge pull request #239 from CodeCrafters-ES/fix/session-provider-red…
Azfe Jul 11, 2026
a88a4a8
fix(routing): move admin role guard into the admin layout
Azfe Jul 11, 2026
0a6b9da
fix(composer): surface every scrape-og failure, not just 5xx
Azfe Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Propietarios de código — Nun Ibiza PinBoard App
#
# Gana la ÚLTIMA regla que coincide, no la más específica.
# Por eso el comodín va primero.
#
# Solo surte efecto de bloqueo si el ruleset tiene require_code_owner_review: true.

* @Azfe

# Zonas críticas: decisiones de arquitectura, esquema de datos y RLS.
/docs/adr/ @Azfe
/supabase/migrations/ @Azfe
/supabase/functions/ @Azfe
/supabase/tests/ @Azfe

# La propia configuración de revisión no se cambia sin revisión.
/.github/ @Azfe
27 changes: 27 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Qué cambia

<!-- Una o dos frases. Qué hace este PR y por qué. -->

Closes #

## Decisiones de arquitectura

- [ ] Este PR **no** cambia ninguna decisión registrada en `docs/adr/`.
- [ ] Este PR **sí** la cambia. ADR que lo respalda: `docs/adr/____`
(el ADR debe existir y estar aprobado **antes** de la implementación,
no escribirse después para justificarla)

Si supersede a un ADR anterior, marca el antiguo como `Superseded por ADR-XXXX`
y actualiza el índice en `docs/adr/README.md`.

## Consumidores afectados

<!-- Si tocas una tabla, una Edge Function o un tipo compartido: qué más deja de
compilar o de funcionar. Enumera las issues que quedan abiertas por ello. -->

## Comprobaciones

- [ ] Migración aplicada en local y revisada.
- [ ] Cada policy RLS nueva tiene al menos un test positivo y uno negativo en `supabase/tests/`.
- [ ] `pnpm lint`, `pnpm typecheck` y `pnpm test` pasan.
- [ ] Los tipos de Supabase están regenerados si cambió el esquema.
48 changes: 48 additions & 0 deletions .github/rulesets/protect-main-and-develop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "Protección de main y develop",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/develop"],
"exclude": []
}
},
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"require_code_owner_review": true,
"require_last_push_approval": true,
"dismiss_stale_reviews_on_push": true,
"required_review_thread_resolution": true,
"allowed_merge_methods": ["squash", "merge", "rebase"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"do_not_enforce_on_create": false,
"required_status_checks": [
{ "context": "Lint", "integration_id": 15368 },
{ "context": "Markdown Lint (ADRs)", "integration_id": 15368 },
{ "context": "TypeScript", "integration_id": 15368 },
{ "context": "Tests (Jest)", "integration_id": 15368 },
{ "context": "RLS Tests (Supabase)", "integration_id": 15368 },
{ "context": "Integration Tests (Supabase)", "integration_id": 15368 }
]
}
}
]
}
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,41 @@ jobs:
with:
version: 2.108.0
- run: supabase start
- run: pnpm test -- --ci --testPathPattern="__tests__/integration/"
# `supabase start` no sirve las Edge Functions: hay que arrancarlas aparte
# para que el test de integración de track-engagement pueda alcanzarlas.
# Se sirve en background dentro del mismo step (así el proceso sigue vivo
# durante los tests) y se CALIENTA antes de lanzar Jest.
#
# El calentamiento manda un JWT (la anon key) en `Authorization`, y eso importa:
# sin esa cabecera la función corta en su primer check ("Missing Authorization
# header") sin llegar a crear el cliente Supabase, así que el probe daría "listo"
# sin haber pagado el arranque en frío. Con el Bearer, la función recorre
# createClient + getUser y responde 401 "Unauthorized": eso prueba que el camino
# caro ya está caliente, y el cold start se paga aquí y no en un timeout de Jest.
- name: Serve edge functions and run integration tests
run: |
supabase functions serve track-engagement --no-verify-jwt > /tmp/functions.log 2>&1 &

# `status -o json` es estable entre versiones de la CLI; el parseo de
# `-o env` no lo era y dejaba ANON_KEY vacío en silencio.
ANON_KEY=$(supabase status -o json \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).ANON_KEY))")
if [ -z "$ANON_KEY" ]; then
echo "::error::no se pudo leer ANON_KEY de 'supabase status -o json'"; exit 1
fi

for i in $(seq 1 30); do
body=$(curl -s -m 30 -X POST http://127.0.0.1:54321/functions/v1/track-engagement \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" -d '[]' || true)
if echo "$body" | grep -q 'Unauthorized'; then
echo "edge function warm (createClient + getUser exercised)"; break
fi
echo "warming edge function... ($body)"; sleep 2
done

pnpm test -- --ci --testPathPattern="__tests__/integration/"
- if: always()
run: cat /tmp/functions.log || true
- if: always()
run: supabase stop
46 changes: 46 additions & 0 deletions __tests__/components/CommentComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CommentComposer onSubmit={onSubmit} />);

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(<CommentComposer onSubmit={onSubmit} />);

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(<CommentComposer onSubmit={onSubmit} />);

fireEvent.changeText(getByLabelText('Nuevo comentario'), ' ');
fireEvent.press(getByLabelText('Comentar'));

expect(onSubmit).not.toHaveBeenCalled();
});

it('muestra el contador de caracteres', () => {
const { getByText, getByLabelText } = render(<CommentComposer onSubmit={jest.fn()} />);

fireEvent.changeText(getByLabelText('Nuevo comentario'), 'abc');
expect(getByText('3/2000')).toBeTruthy();
});
});
124 changes: 124 additions & 0 deletions __tests__/components/EngagementScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React from 'react';
import { render, screen } from '@testing-library/react-native';

import EngagementScreen from '@/app/(app)/(tabs)/admin/engagement/index';
import type { PostEngagement } from '@/lib/supabase/queries/engagement';

// ─── Mocks ──────────────────────────────────────────────────────────────────

const mockUseSession = jest.fn();
const mockUseMetrics = jest.fn();

// El módulo de queries arrastra el singleton de Supabase, que lee
// Constants.expoConfig.extra (no poblado bajo Jest).
jest.mock('@/lib/supabase', () => ({ supabase: {} }));

jest.mock('@/hooks/useSession', () => ({
useSession: () => mockUseSession(),
}));

jest.mock('@/hooks/usePostEngagementMetrics', () => ({
usePostEngagementMetrics: () => mockUseMetrics(),
}));

jest.mock('expo-router', () => ({
Redirect: () => null,
Stack: { Screen: () => null },
}));

jest.mock('react-native-safe-area-context', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { View } = require('react-native');
return { SafeAreaView: View };
});

const ROWS: PostEngagement[] = [
{
post_id: 'p1',
title: 'Nueva carta de temporada',
unique_readers: 4,
unique_clicks: 3,
click_rate: 0.75,
avg_rating: 4.5,
total_reactions: 6,
engaged_users: 2,
avg_seconds: 12,
avg_scroll: 0.8,
},
];

function metrics(overrides: Partial<ReturnType<typeof mockUseMetrics>> = {}) {
return {
rows: ROWS,
loading: false,
error: null,
refresh: jest.fn(),
...overrides,
};
}

beforeEach(() => {
jest.clearAllMocks();
mockUseSession.mockReturnValue({ session: { userId: 'u1', role: 'admin' } });
mockUseMetrics.mockReturnValue(metrics());
});

// ─── Tests ───────────────────────────────────────────────────────────────────

describe('EngagementScreen', () => {
it('shows the metrics of each post to an admin', () => {
render(<EngagementScreen />);

expect(screen.getByText('Nueva carta de temporada')).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy(); // unique_clicks
expect(screen.getByText('75%')).toBeTruthy(); // click_rate
expect(screen.getByText('4.5')).toBeTruthy(); // avg_rating
expect(screen.getByText('6')).toBeTruthy(); // total_reactions
// engaged (ADR-001): interactuaron pero no llegaron al enlace externo.
expect(screen.getByText(/2 interactuaron sin clicar/)).toBeTruthy();
});

it('shows the metrics to a manager too', () => {
mockUseSession.mockReturnValue({ session: { userId: 'u2', role: 'manager' } });

render(<EngagementScreen />);

expect(screen.getByText('Nueva carta de temporada')).toBeTruthy();
});

it('does not render the metrics for staff (redirects away)', () => {
mockUseSession.mockReturnValue({ session: { userId: 'u3', role: 'staff' } });

render(<EngagementScreen />);

expect(screen.queryByText('Nueva carta de temporada')).toBeNull();
});

it('renders an em dash when a post has no readers or ratings', () => {
mockUseMetrics.mockReturnValue(
metrics({
rows: [{ ...ROWS[0]!, unique_readers: 0, unique_clicks: 0, click_rate: null, avg_rating: null }],
}),
);

render(<EngagementScreen />);

expect(screen.getAllByText('—')).toHaveLength(2); // click_rate y avg_rating
});

it('shows the empty state when there is no activity', () => {
mockUseMetrics.mockReturnValue(metrics({ rows: [] }));

render(<EngagementScreen />);

expect(screen.getByText(/Todavía no hay actividad registrada/)).toBeTruthy();
});

it('shows the error state', () => {
mockUseMetrics.mockReturnValue(metrics({ rows: [], error: 'Fallo de red' }));

render(<EngagementScreen />);

expect(screen.getByText('Fallo de red')).toBeTruthy();
});
});
17 changes: 17 additions & 0 deletions __tests__/components/PostCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jest.mock('expo-image', () => {

jest.mock('lucide-react-native', () => ({
ExternalLink: () => null,
MessageCircle: () => null,
Star: () => null,
}));

jest.mock('@/hooks/usePostReactions', () => ({
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -91,6 +96,18 @@ describe('PostCard', () => {
expect(getByText('—')).toBeTruthy();
});

it('renders the rounded rating average when the post has ratings', () => {
const { getByText } = render(<PostCard post={POST} onPress={jest.fn()} />);
expect(getByText('4.3')).toBeTruthy(); // 4.25 -> toFixed(1)
});

it('does not render a rating when the post has no ratings', () => {
const { queryByText } = render(
<PostCard post={{ ...POST, rating_average: 0, rating_count: 0 }} onPress={jest.fn()} />,
);
expect(queryByText('0.0')).toBeNull();
});

it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByRole } = render(<PostCard post={POST} onPress={onPress} />);
Expand Down
Loading
Loading