Skip to main content

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.
ColumnTypeNotes
idUUIDPrimary key, auto-generated
titleTEXTArticle headline
slugTEXTURL-friendly identifier, unique
contentTEXTRich HTML from TipTap editor
excerptTEXTShort summary for cards and SEO
image_urlTEXT | NULLCover image URL (Supabase Storage)
galleryJSONBArray of additional image URLs, default []
category_idUUIDForeign key → categories.id
publishedBOOLEANWhether the article is live
published_atTIMESTAMPTZ | NULLScheduled publish date; NULL means publish immediately
featuredBOOLEANPinned to the hero carousel
viewsINTEGERCumulative view count, default 0
author_idUUIDForeign key → auth.users.id
author_nameTEXTDisplay name, default Redacción TuPerfil.net
deleted_atTIMESTAMPTZ | NULLSoft-delete timestamp; NULL means not deleted
created_atTIMESTAMPTZRow creation time
updated_atTIMESTAMPTZAuto-updated by trigger
RLS policies
PolicyRoleOperationCondition
articles_public_readanon, authenticatedSELECTpublished = true AND deleted_at IS NULL, or authenticated
articles_auth_insertauthenticatedINSERTAlways allowed
articles_auth_updateauthenticatedUPDATEAlways allowed
articles_auth_deleteauthenticatedDELETEAlways allowed

categories

Editorial sections of the site.
ColumnTypeNotes
idUUIDPrimary key
nameTEXTDisplay name, e.g. Perfil Político
slugTEXTURL segment, unique
colorTEXTHex color code used in the UI
created_atTIMESTAMPTZRow 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.

contacts

Messages submitted through the public contact form.
ColumnTypeNotes
idUUIDPrimary key
nameTEXTSender name
emailTEXTSender email
messageTEXTMessage body
readBOOLEANWhether an admin has read it, default false
created_atTIMESTAMPTZSubmission time
RLS policies: anonymous INSERT; authenticated SELECT, UPDATE, DELETE.

tags

Freeform labels that can be attached to multiple articles.
ColumnTypeNotes
idUUIDPrimary key
nameTEXTTag label, unique
slugTEXTURL-friendly identifier, unique
created_atTIMESTAMPTZRow creation time
RLS policies: public read; authenticated insert, update, delete.

article_tags

Junction table linking articles to tags (many-to-many).
ColumnTypeNotes
article_idUUIDForeign key → articles.id, ON DELETE CASCADE
tag_idUUIDForeign 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.
ColumnTypeNotes
idUUIDPrimary key
textTEXTBanner message
linkTEXT | NULLOptional destination URL
activeBOOLEANWhether the banner is displayed
created_atTIMESTAMPTZRow creation time
updated_atTIMESTAMPTZAuto-updated by trigger
RLS policies: public read; authenticated insert, update.

comments

Reader comments awaiting or having passed moderation.
ColumnTypeNotes
idUUIDPrimary key
article_idUUIDForeign key → articles.id, ON DELETE CASCADE
author_nameTEXTCommenter display name
author_emailTEXTCommenter email (not shown publicly)
contentTEXTComment body
approvedBOOLEANModeration status, default false
created_atTIMESTAMPTZSubmission time
RLS policies
PolicyRoleOperationCondition
comments_public_insertanon, authenticatedINSERTAlways allowed
comments_public_readanonSELECTapproved = true only
comments_auth_readauthenticatedSELECTAll comments (for moderation)
comments_auth_updateauthenticatedUPDATEAlways allowed (approve / reject)
comments_auth_deleteauthenticatedDELETEAlways allowed

subscribers

Newsletter subscribers managed through the Resend integration.
ColumnTypeNotes
idUUIDPrimary key
emailTEXTSubscriber email, unique
nameTEXT | NULLOptional display name
activeBOOLEANSubscription status, default true
created_atTIMESTAMPTZSubscription time
unsubscribed_atTIMESTAMPTZ | NULLOpt-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.
ColumnTypeNotes
idUUIDPrimary key, mirrors auth.users.id
emailTEXTUser email
full_nameTEXTDisplay name
roleTEXTadmin or editor (CHECK constraint)
created_atTIMESTAMPTZRow creation time
updated_atTIMESTAMPTZAuto-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.
ColumnTypeNotes
idUUIDPrimary key
titleTEXTInternal reference name
image_urlTEXTAd image (Supabase Storage)
link_urlTEXTClick destination URL
positionTEXTsidebar, header, or between_articles
activeBOOLEANWhether the ad is live
sort_orderINTDisplay order
created_atTIMESTAMPTZRow creation time
updated_atTIMESTAMPTZAuto-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).
ColumnTypeNotes
idUUIDPrimary key
article_idUUIDForeign key → articles.id, ON DELETE CASCADE
viewed_atTIMESTAMPTZEvent timestamp, default now()
referrerTEXT | NULLRaw referrer URL
referrer_sourceTEXT | NULLClassified source: direct, google, facebook, twitter, whatsapp, other
RLS policies: anonymous INSERT; authenticated SELECT.

ad_events

Impression and click events for advertisements.
ColumnTypeNotes
idUUIDPrimary key
ad_idUUIDForeign key → ads.id, ON DELETE CASCADE
event_typeTEXTimpression or click (CHECK constraint)
created_atTIMESTAMPTZEvent timestamp
RLS policies: anonymous INSERT; authenticated SELECT.

Storage

Supabase Storage hosts article cover images and gallery images in the article-images bucket:
SettingValue
Bucket IDarticle-images
PublicYes (read without authentication)
Storage RLS policies
PolicyRoleOperation
article_images_public_readanon, authenticatedSELECT
article_images_auth_insertauthenticatedINSERT
article_images_auth_updateauthenticatedUPDATE
article_images_auth_deleteauthenticatedDELETE

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.
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;

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;

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:
supabase db push
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.

Build docs developers (and LLMs) love