From f08bcf2bb8916b55307e8fa86f5223c7bf9cb48c Mon Sep 17 00:00:00 2001 From: Marcos Fargas Date: Mon, 12 Oct 2026 13:08:45 +0200 Subject: [PATCH] feat(creatives): task 1 - migration, models and seed for creatives module --- sql/creatives_module_migration.sql | 209 +++++++++++++++++++++++++++++ src/lib/creatives-types.ts | 185 +++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 sql/creatives_module_migration.sql create mode 100644 src/lib/creatives-types.ts diff --git a/sql/creatives_module_migration.sql b/sql/creatives_module_migration.sql new file mode 100644 index 0000000..d251cc7 --- /dev/null +++ b/sql/creatives_module_migration.sql @@ -0,0 +1,209 @@ +-- Migration: Module Creatives (CREATIVOS_BRIEF.md) +-- Multi-tenant schema using tenant_id references public.tenants(id) + +-- 1. Extend public.tenants +alter table public.tenants + add column if not exists meta_ad_account_id text, + add column if not exists slack_channel_id text, + add column if not exists drive_folder_id text, + add column if not exists creatives_enabled boolean not null default false; + +-- 2. Adapt/Create public.creatives +-- First, adapt existing table or create if not exists +create table if not exists public.creatives ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + campaign_name text, + adset_id text, + meta_ad_id text unique, + format text not null check (format in ('estatico','carrusel','video')), + angle text, + hook text, + primary_text text, + headline text, + cta text, + asset_url text, + thumbnail_url text, + status text not null default 'borrador' + check (status in ('borrador','pendiente_aprobacion','aprobada','rechazada','publicada','fatigada','retirada')), + visibility text not null default 'agency' check (visibility in ('client','agency')), + parent_id uuid references public.creatives(id) on delete set null, + generation_request_id uuid, + created_by text not null default 'agency', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Ensure all columns exist and checks are aligned if table already existed +alter table public.creatives add column if not exists campaign_name text; +alter table public.creatives add column if not exists adset_id text; +alter table public.creatives add column if not exists meta_ad_id text; +alter table public.creatives add column if not exists angle text; +alter table public.creatives add column if not exists hook text; +alter table public.creatives add column if not exists asset_url text; +alter table public.creatives add column if not exists thumbnail_url text; +alter table public.creatives add column if not exists visibility text not null default 'agency'; +alter table public.creatives add column if not exists parent_id uuid references public.creatives(id) on delete set null; +alter table public.creatives add column if not exists generation_request_id uuid; + +-- Change created_by to text if it was uuid +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'creatives' and column_name = 'created_by' and data_type = 'uuid' + ) then + alter table public.creatives drop constraint if exists creatives_created_by_fkey; + alter table public.creatives alter column created_by type text using created_by::text; + alter table public.creatives alter column created_by set default 'agency'; + end if; +end $$; + +-- Drop obsolete constraints if present +alter table public.creatives drop constraint if exists creatives_status_check; +alter table public.creatives drop constraint if exists creatives_format_check; +alter table public.creatives drop constraint if exists creatives_visibility_check; +alter table public.creatives drop constraint if exists creatives_meta_ad_id_key; + +-- Ensure meta_ad_id unique constraint +alter table public.creatives add constraint creatives_meta_ad_id_key unique (meta_ad_id); + +-- Enforce check constraints from brief +alter table public.creatives add constraint creatives_status_check + check (status in ('borrador','pendiente_aprobacion','aprobada','rechazada','publicada','fatigada','retirada','draft','in_review','approved','rejected')); + +alter table public.creatives add constraint creatives_format_check + check (format in ('estatico','carrusel','video','image','carousel','reel','story','rsa','pmax','pmax_landscape','pmax_square','pmax_portrait','pmax_vertical','pmax_logo','youtube','other')); + +alter table public.creatives add constraint creatives_visibility_check + check (visibility in ('client','agency')); + +create index if not exists creatives_tenant_status_idx on public.creatives (tenant_id, status); +create index if not exists creatives_meta_ad_id_idx on public.creatives (meta_ad_id); + +-- 3. creative_metrics_daily +create table if not exists public.creative_metrics_daily ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + creative_id uuid not null references public.creatives(id) on delete cascade, + meta_ad_id text not null, + date date not null, + impressions integer default 0, + reach integer default 0, + frequency numeric(6,2) default 0, + clicks integer default 0, + ctr numeric(6,3) default 0, + cpm numeric(10,2) default 0, + cpc numeric(10,2) default 0, + spend numeric(10,2) default 0, + results integer default 0, + cpa numeric(10,2), + raw jsonb, + created_at timestamptz not null default now(), + unique (meta_ad_id, date) +); +create index if not exists creative_metrics_daily_tenant_date_idx on public.creative_metrics_daily (tenant_id, date); + +-- 4. fatigue_rules +create table if not exists public.fatigue_rules ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null unique references public.tenants(id) on delete cascade, + lookback_days integer not null default 7, + min_impressions integer not null default 2000, + max_frequency numeric(4,2) not null default 3.0, + ctr_drop_pct numeric(5,2) not null default 30, + cpa_rise_pct numeric(5,2) not null default 25, + signals_required integer not null default 2, + auto_request_relief boolean not null default true, + updated_at timestamptz not null default now() +); + +-- 5. generation_requests +create table if not exists public.generation_requests ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + reason text not null check (reason in ('fatiga','calendario','nueva_campana','manual')), + source_creative_id uuid references public.creatives(id) on delete set null, + brief jsonb not null, + status text not null default 'pendiente' + check (status in ('pendiente','en_proceso','completada','fallida')), + result jsonb, + cost_estimate jsonb, + error text, + requested_at timestamptz not null default now(), + completed_at timestamptz +); +create index if not exists generation_requests_tenant_status_idx on public.generation_requests (tenant_id, status); + +-- Foreign key linking creatives.generation_request_id to generation_requests.id +do $$ +begin + if not exists ( + select 1 from information_schema.table_constraints + where constraint_name = 'creatives_generation_request_id_fkey' + ) then + alter table public.creatives + add constraint creatives_generation_request_id_fkey + foreign key (generation_request_id) references public.generation_requests(id) on delete set null; + end if; +end $$; + +-- 6. approvals +create table if not exists public.approvals ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + creative_id uuid not null references public.creatives(id) on delete cascade, + user_id uuid references public.users(id) on delete set null, + actor_role text not null check (actor_role in ('client','agency')), + decision text not null check (decision in ('aprobada','rechazada')), + comment text, + channel text not null default 'app' check (channel in ('app','slack')), + created_at timestamptz not null default now() +); +create index if not exists approvals_tenant_creative_idx on public.approvals (tenant_id, creative_id); + +-- 7. creative_events +create table if not exists public.creative_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + creative_id uuid references public.creatives(id) on delete cascade, + event_type text not null, + actor text not null, + from_status text, + to_status text, + payload jsonb, + visibility text not null default 'agency' check (visibility in ('client','agency')), + created_at timestamptz not null default now() +); +create index if not exists creative_events_tenant_created_idx on public.creative_events (tenant_id, created_at desc); + +-- 8. Seed Blue Parrot School (bps) +insert into public.fatigue_rules ( + tenant_id, + lookback_days, + min_impressions, + max_frequency, + ctr_drop_pct, + cpa_rise_pct, + signals_required, + auto_request_relief +) +select + id, + 7, + 2000, + 3.0, + 30.0, + 25.0, + 2, + true +from public.tenants +where slug = 'bps' +on conflict (tenant_id) do update set + updated_at = now(); + +update public.tenants +set + creatives_enabled = true, + meta_ad_account_id = coalesce(meta_ad_account_id, 'act_1052328112282270') +where slug = 'bps'; diff --git a/src/lib/creatives-types.ts b/src/lib/creatives-types.ts new file mode 100644 index 0000000..81cc7aa --- /dev/null +++ b/src/lib/creatives-types.ts @@ -0,0 +1,185 @@ +/** + * Tipos y Modelos de datos para el módulo Creatividades (CREATIVOS_BRIEF.md) + * Todas las entidades se estructuran con `tenant_id` (multi-tenant obligatorio). + */ + +export type CreativeFormat = + | 'estatico' + | 'carrusel' + | 'video' + | 'image' + | 'carousel' + | 'reel' + | 'story' + | 'rsa' + | 'pmax' + | 'pmax_landscape' + | 'pmax_square' + | 'pmax_portrait' + | 'pmax_vertical' + | 'pmax_logo' + | 'youtube' + | 'other'; + +export type CreativeStatus = + | 'borrador' + | 'pendiente_aprobacion' + | 'aprobada' + | 'rechazada' + | 'publicada' + | 'fatigada' + | 'retirada' + | 'draft' + | 'in_review' + | 'approved' + | 'rejected'; + +export type CreativeVisibility = 'client' | 'agency'; + +export type CreativeCreatedBy = 'agency' | 'claude' | 'client'; + +export interface Creative { + id: string; + tenant_id: string; + campaign_name: string | null; + adset_id: string | null; + meta_ad_id: string | null; + format: CreativeFormat; + angle: string | null; + hook: string | null; + primary_text: string | null; + headline: string | null; + cta: string | null; + asset_url: string | null; + thumbnail_url: string | null; + status: CreativeStatus; + visibility: CreativeVisibility; + parent_id: string | null; + generation_request_id: string | null; + created_by: CreativeCreatedBy | string; + created_at: string; + updated_at: string; + + // Campos relacionales opcionales al consultar con joins + parent?: Creative | null; + metrics_daily?: CreativeMetricsDaily[]; +} + +export interface CreativeMetricsDaily { + id: string; + tenant_id: string; + creative_id: string; + meta_ad_id: string; + date: string; + impressions: number; + reach: number; + frequency: number; + clicks: number; + ctr: number; + cpm: number; + cpc: number; + spend: number; + results: number; + cpa: number | null; + raw?: Record | null; + created_at: string; +} + +export interface FatigueRules { + id: string; + tenant_id: string; + lookback_days: number; + min_impressions: number; + max_frequency: number; + ctr_drop_pct: number; + cpa_rise_pct: number; + signals_required: number; + auto_request_relief: boolean; + updated_at: string; +} + +export type GenerationRequestReason = 'fatiga' | 'calendario' | 'nueva_campana' | 'manual'; +export type GenerationRequestStatus = 'pendiente' | 'en_proceso' | 'completada' | 'fallida'; + +export interface GenerationRequestBrief { + tenant_id: string; + client_id?: string; // alias compatible si n8n lo envía + client_slug: string; + reason: GenerationRequestReason; + source_creative_id?: string | null; + format: 'estatico' | 'carrusel' | 'video'; + aspect_ratio?: string; + variants_requested: number; + avoid_angles?: string[]; + campaign_name?: string | null; + adset_id?: string | null; + language?: string; + deadline?: string; +} + +export interface GenerationRequestResult { + creatives: string[]; + notes?: string; + skills_used?: string[]; +} + +export interface GenerationRequest { + id: string; + tenant_id: string; + reason: GenerationRequestReason; + source_creative_id: string | null; + brief: GenerationRequestBrief; + status: GenerationRequestStatus; + result: GenerationRequestResult | null; + cost_estimate: Record | null; + error: string | null; + requested_at: string; + completed_at: string | null; +} + +export type ApprovalActorRole = 'client' | 'agency'; +export type ApprovalDecision = 'aprobada' | 'rechazada'; +export type ApprovalChannel = 'app' | 'slack'; + +export interface Approval { + id: string; + tenant_id: string; + creative_id: string; + user_id: string | null; + actor_role: ApprovalActorRole; + decision: ApprovalDecision; + comment: string | null; + channel: ApprovalChannel; + created_at: string; +} + +export type CreativeEventType = + | 'status_change' + | 'metrics_ingested' + | 'fatigue_detected' + | 'generation_requested' + | 'published' + | 'error'; + +export interface CreativeEvent { + id: string; + tenant_id: string; + creative_id: string | null; + event_type: CreativeEventType | string; + actor: string; + from_status: CreativeStatus | null; + to_status: CreativeStatus | null; + payload: Record | null; + visibility: CreativeVisibility; + created_at: string; +} + +export interface TenantWithCreativesMeta { + id: string; + name: string; + slug: string; + meta_ad_account_id?: string | null; + slack_channel_id?: string | null; + drive_folder_id?: string | null; + creatives_enabled: boolean; +}