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