Documentation Index
Fetch the complete documentation index at: https://mintlify.com/santiagodc8/tu_perfil.net/llms.txt
Use this file to discover all available pages before exploring further.
TuPerfil.net uses a single Supabase PostgreSQL database. The schema was built incrementally through 19 migration files located in supabase/migrations/. All tables have Row Level Security (RLS) enabled.
Tables
articles
The central table. Each row is one news article.
| Column | Type | Notes |
|---|
id | UUID | Primary key, auto-generated |
title | TEXT | Article headline |
slug | TEXT | URL-friendly identifier, unique |
content | TEXT | Rich HTML from TipTap editor |
excerpt | TEXT | Short summary for cards and SEO |
image_url | TEXT | NULL | Cover image URL (Supabase Storage) |
gallery | JSONB | Array of additional image URLs, default [] |
category_id | UUID | Foreign key → categories.id |
published | BOOLEAN | Whether the article is live |
published_at | TIMESTAMPTZ | NULL | Scheduled publish date; NULL means publish immediately |
featured | BOOLEAN | Pinned to the hero carousel |
views | INTEGER | Cumulative view count, default 0 |
author_id | UUID | Foreign key → auth.users.id |
author_name | TEXT | Display name, default Redacción TuPerfil.net |
deleted_at | TIMESTAMPTZ | NULL | Soft-delete timestamp; NULL means not deleted |
created_at | TIMESTAMPTZ | Row creation time |
updated_at | TIMESTAMPTZ | Auto-updated by trigger |
RLS policies
| Policy | Role | Operation | Condition |
|---|
articles_public_read | anon, authenticated | SELECT | published = true AND deleted_at IS NULL, or authenticated |
articles_auth_insert | authenticated | INSERT | Always allowed |
articles_auth_update | authenticated | UPDATE | Always allowed |
articles_auth_delete | authenticated | DELETE | Always allowed |
categories
Editorial sections of the site.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
name | TEXT | Display name, e.g. Perfil Político |
slug | TEXT | URL segment, unique |
color | TEXT | Hex color code used in the UI |
created_at | TIMESTAMPTZ | Row creation time |
The six initial categories are seeded by 001_initial_schema.sql and their colors are updated by 003_update_category_colors.sql:
supabase/migrations/001_initial_schema.sql
-- Initial seed
INSERT INTO categories (name, slug, color) VALUES
('Perfil Político', 'perfil-politico', '#1a1a2e'),
('Perfil Judicial', 'perfil-judicial', '#6c3483'),
('Perfil Salud', 'perfil-salud', '#27ae60'),
('Perfil Deportivo', 'perfil-deportivo', '#e94560'),
('Perfil Regional', 'perfil-regional', '#2980b9'),
('Perfil Internacional', 'perfil-internacional', '#e67e22');
supabase/migrations/003_update_category_colors.sql
-- Color update (applied on top of initial seed)
UPDATE categories SET color = '#2563EB' WHERE slug = 'perfil-politico';
UPDATE categories SET color = '#DC2626' WHERE slug = 'perfil-judicial';
UPDATE categories SET color = '#16A34A' WHERE slug = 'perfil-salud';
UPDATE categories SET color = '#EAB308' WHERE slug = 'perfil-deportivo';
UPDATE categories SET color = '#9333EA' WHERE slug = 'perfil-regional';
UPDATE categories SET color = '#0891B2' WHERE slug = 'perfil-internacional';
RLS policies: public read; authenticated insert, update, delete.
Messages submitted through the public contact form.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
name | TEXT | Sender name |
email | TEXT | Sender email |
message | TEXT | Message body |
read | BOOLEAN | Whether an admin has read it, default false |
created_at | TIMESTAMPTZ | Submission time |
RLS policies: anonymous INSERT; authenticated SELECT, UPDATE, DELETE.
Freeform labels that can be attached to multiple articles.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
name | TEXT | Tag label, unique |
slug | TEXT | URL-friendly identifier, unique |
created_at | TIMESTAMPTZ | Row creation time |
RLS policies: public read; authenticated insert, update, delete.
article_tags
Junction table linking articles to tags (many-to-many).
| Column | Type | Notes |
|---|
article_id | UUID | Foreign key → articles.id, ON DELETE CASCADE |
tag_id | UUID | Foreign key → tags.id, ON DELETE CASCADE |
Primary key is (article_id, tag_id).
RLS policies: public read; authenticated insert, delete.
breaking_news
A single-row table for the urgent breaking news banner shown at the top of the site.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
text | TEXT | Banner message |
link | TEXT | NULL | Optional destination URL |
active | BOOLEAN | Whether the banner is displayed |
created_at | TIMESTAMPTZ | Row creation time |
updated_at | TIMESTAMPTZ | Auto-updated by trigger |
RLS policies: public read; authenticated insert, update.
Reader comments awaiting or having passed moderation.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
article_id | UUID | Foreign key → articles.id, ON DELETE CASCADE |
author_name | TEXT | Commenter display name |
author_email | TEXT | Commenter email (not shown publicly) |
content | TEXT | Comment body |
approved | BOOLEAN | Moderation status, default false |
created_at | TIMESTAMPTZ | Submission time |
RLS policies
| Policy | Role | Operation | Condition |
|---|
comments_public_insert | anon, authenticated | INSERT | Always allowed |
comments_public_read | anon | SELECT | approved = true only |
comments_auth_read | authenticated | SELECT | All comments (for moderation) |
comments_auth_update | authenticated | UPDATE | Always allowed (approve / reject) |
comments_auth_delete | authenticated | DELETE | Always allowed |
subscribers
Newsletter subscribers managed through the Resend integration.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
email | TEXT | Subscriber email, unique |
name | TEXT | NULL | Optional display name |
active | BOOLEAN | Subscription status, default true |
created_at | TIMESTAMPTZ | Subscription time |
unsubscribed_at | TIMESTAMPTZ | NULL | Opt-out timestamp |
RLS policies: anonymous INSERT (subscribe); authenticated SELECT, UPDATE, DELETE.
profiles
Extended user data for everyone in auth.users. Created automatically by the handle_new_user trigger.
| Column | Type | Notes |
|---|
id | UUID | Primary key, mirrors auth.users.id |
email | TEXT | User email |
full_name | TEXT | Display name |
role | TEXT | admin or editor (CHECK constraint) |
created_at | TIMESTAMPTZ | Row creation time |
updated_at | TIMESTAMPTZ | Auto-updated by trigger |
The first user created in the project is automatically assigned the admin role. All subsequent users receive editor.
RLS policies: authenticated SELECT for all; only admin-role users can UPDATE or DELETE profiles (enforced via the is_admin() helper function).
ads
Advertisements displayed in configurable positions across the site.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
title | TEXT | Internal reference name |
image_url | TEXT | Ad image (Supabase Storage) |
link_url | TEXT | Click destination URL |
position | TEXT | sidebar, header, or between_articles |
active | BOOLEAN | Whether the ad is live |
sort_order | INT | Display order |
created_at | TIMESTAMPTZ | Row creation time |
updated_at | TIMESTAMPTZ | Auto-updated |
RLS policies: public SELECT for active ads; authenticated INSERT, UPDATE, DELETE.
page_views
Granular view events used for analytics (popular articles, trending, referrer source breakdown).
| Column | Type | Notes |
|---|
id | UUID | Primary key |
article_id | UUID | Foreign key → articles.id, ON DELETE CASCADE |
viewed_at | TIMESTAMPTZ | Event timestamp, default now() |
referrer | TEXT | NULL | Raw referrer URL |
referrer_source | TEXT | NULL | Classified source: direct, google, facebook, twitter, whatsapp, other |
RLS policies: anonymous INSERT; authenticated SELECT.
ad_events
Impression and click events for advertisements.
| Column | Type | Notes |
|---|
id | UUID | Primary key |
ad_id | UUID | Foreign key → ads.id, ON DELETE CASCADE |
event_type | TEXT | impression or click (CHECK constraint) |
created_at | TIMESTAMPTZ | Event timestamp |
RLS policies: anonymous INSERT; authenticated SELECT.
Storage
Supabase Storage hosts article cover images and gallery images in the article-images bucket:
| Setting | Value |
|---|
| Bucket ID | article-images |
| Public | Yes (read without authentication) |
Storage RLS policies
| Policy | Role | Operation |
|---|
article_images_public_read | anon, authenticated | SELECT |
article_images_auth_insert | authenticated | INSERT |
article_images_auth_update | authenticated | UPDATE |
article_images_auth_delete | authenticated | DELETE |
Database indexes
-- articles
CREATE INDEX idx_articles_category ON articles(category_id);
CREATE INDEX idx_articles_published ON articles(published);
CREATE INDEX idx_articles_slug ON articles(slug);
CREATE INDEX idx_articles_created_at ON articles(created_at DESC);
CREATE INDEX idx_articles_featured ON articles(featured) WHERE featured = true;
CREATE INDEX idx_articles_deleted_at ON articles(deleted_at);
-- categories
CREATE INDEX idx_categories_slug ON categories(slug);
-- tags
CREATE INDEX idx_tags_slug ON tags(slug);
CREATE INDEX idx_article_tags_article ON article_tags(article_id);
CREATE INDEX idx_article_tags_tag ON article_tags(tag_id);
-- comments
CREATE INDEX idx_comments_article_id ON comments(article_id);
-- subscribers
CREATE INDEX subscribers_email_idx ON subscribers(email);
CREATE INDEX subscribers_active_idx ON subscribers(active);
-- page_views
CREATE INDEX idx_page_views_article_id ON page_views(article_id);
CREATE INDEX idx_page_views_viewed_at ON page_views(viewed_at);
CREATE INDEX idx_page_views_source ON page_views(referrer_source);
-- ad_events
CREATE INDEX idx_ad_events_ad_id ON ad_events(ad_id);
CREATE INDEX idx_ad_events_created_at ON ad_events(created_at);
CREATE INDEX idx_ad_events_type ON ad_events(event_type);
RPC functions
Supabase exposes these PostgreSQL functions as callable RPCs from the application.
increment_views
Increments the views counter on an article and inserts a page_views row in a single atomic operation. Skips soft-deleted articles.
CREATE OR REPLACE FUNCTION increment_views(
article_id UUID,
p_referrer TEXT DEFAULT NULL,
p_referrer_source TEXT DEFAULT NULL
)
RETURNS void AS $$
BEGIN
UPDATE articles SET views = views + 1
WHERE id = article_id AND deleted_at IS NULL;
INSERT INTO page_views (article_id, referrer, referrer_source)
VALUES (article_id, p_referrer, p_referrer_source);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Called from the POST /api/views/[id] route handler on every article page load.
popular_articles_this_week
Returns the most-viewed published articles in the past 7 days.
CREATE OR REPLACE FUNCTION popular_articles_this_week(lim INT DEFAULT 5)
RETURNS TABLE (article_id UUID, view_count BIGINT) AS $$
BEGIN
RETURN QUERY
SELECT pv.article_id, COUNT(*) AS view_count
FROM page_views pv
INNER JOIN articles a ON a.id = pv.article_id
WHERE pv.viewed_at >= now() - interval '7 days'
AND a.published = true
AND a.deleted_at IS NULL
GROUP BY pv.article_id
ORDER BY view_count DESC
LIMIT lim;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
trending_articles_24h
Returns the most-viewed published articles in the past 24 hours.
CREATE OR REPLACE FUNCTION trending_articles_24h(lim INT DEFAULT 5)
RETURNS TABLE (article_id UUID, view_count BIGINT) AS $$
BEGIN
RETURN QUERY
SELECT pv.article_id, COUNT(*) AS view_count
FROM page_views pv
INNER JOIN articles a ON a.id = pv.article_id
WHERE pv.viewed_at >= now() - interval '24 hours'
AND a.published = true
AND a.deleted_at IS NULL
GROUP BY pv.article_id
ORDER BY view_count DESC
LIMIT lim;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
related_articles_by_tags
Finds articles that share the most tags with a given article. Falls back to same-category articles when tag overlap is low.
CREATE OR REPLACE FUNCTION related_articles_by_tags(
p_article_id UUID,
p_category_id UUID,
lim INT DEFAULT 4
)
RETURNS TABLE (
id UUID, title TEXT, slug TEXT, excerpt TEXT,
image_url TEXT, created_at TIMESTAMPTZ,
category_name TEXT, category_color TEXT, shared_tags BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
a.id, a.title, a.slug, a.excerpt, a.image_url, a.created_at,
c.name AS category_name, c.color AS category_color,
COUNT(at2.tag_id) AS shared_tags
FROM article_tags at1
INNER JOIN article_tags at2
ON at1.tag_id = at2.tag_id AND at2.article_id != p_article_id
INNER JOIN articles a ON a.id = at2.article_id
LEFT JOIN categories c ON c.id = a.category_id
WHERE at1.article_id = p_article_id
AND a.published = true
AND a.deleted_at IS NULL
GROUP BY a.id, a.title, a.slug, a.excerpt, a.image_url, a.created_at,
c.name, c.color
ORDER BY shared_tags DESC, a.created_at DESC
LIMIT lim;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
ad_metrics
Aggregates impression and click counts for ads over a configurable time window.
CREATE OR REPLACE FUNCTION ad_metrics(days INT DEFAULT 30)
RETURNS TABLE (ad_id UUID, impressions BIGINT, clicks BIGINT) AS $$
BEGIN
RETURN QUERY
SELECT
e.ad_id,
COUNT(*) FILTER (WHERE e.event_type = 'impression') AS impressions,
COUNT(*) FILTER (WHERE e.event_type = 'click') AS clicks
FROM ad_events e
WHERE e.created_at >= now() - (days || ' days')::interval
GROUP BY e.ad_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Running migrations
All migrations are plain SQL files in supabase/migrations/. Run them in numeric order using the Supabase SQL Editor or the Supabase CLI:
The SECURITY DEFINER attribute on RPC functions means they execute with the permissions of the function owner, not the calling user. This allows anonymous users to call increment_views without bypassing RLS on the articles table itself.