feat(creatives): task 1 - migration, models and seed for creatives module
This commit is contained in:
209
sql/creatives_module_migration.sql
Normal file
209
sql/creatives_module_migration.sql
Normal file
@@ -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';
|
||||
185
src/lib/creatives-types.ts
Normal file
185
src/lib/creatives-types.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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;
|
||||
}
|
||||
Reference in New Issue
Block a user