feat(creatives): add configurable per-client creative style and skill builder in UI and API
This commit is contained in:
194
src/app/api/creatives/style/route.ts
Normal file
194
src/app/api/creatives/style/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
ClientCreativeStyle,
|
||||
compileClientSkillMarkdown,
|
||||
normalizeCreativeStyle,
|
||||
} from '@/lib/creative-style';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function getAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createAdminClient(url, key);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
if (!isAgencyRole(role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
let tenantId = searchParams.get('tenant_id') || searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
tenantId = ctx.tenantId;
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: tenant, error } = await admin
|
||||
.from('tenants')
|
||||
.select(
|
||||
'id, name, slug, creatives_enabled, meta_ad_account_id, meta_page_id, meta_ig_user_id, slack_channel_id, drive_folder_id, default_landing_url, canva_templates, campaign_audience, campaign_products, creative_style, settings'
|
||||
)
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !tenant) {
|
||||
return NextResponse.json({ error: error?.message || 'Cliente no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
const rawStyle =
|
||||
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
|
||||
? tenant.creative_style
|
||||
: (tenant.settings as any)?.creative_style;
|
||||
|
||||
const style = normalizeCreativeStyle(rawStyle, tenant.name || 'Cliente', tenant.slug || '');
|
||||
const compiledMarkdown = compileClientSkillMarkdown(
|
||||
style,
|
||||
tenant.name || 'Cliente',
|
||||
tenant.slug || 'cliente'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
tenant_id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
creatives_enabled: Boolean(tenant.creatives_enabled),
|
||||
meta_ad_account_id: tenant.meta_ad_account_id || '',
|
||||
meta_page_id: tenant.meta_page_id || '',
|
||||
meta_ig_user_id: tenant.meta_ig_user_id || '',
|
||||
slack_channel_id: tenant.slack_channel_id || '',
|
||||
drive_folder_id: tenant.drive_folder_id || '',
|
||||
default_landing_url: tenant.default_landing_url || '',
|
||||
canva_templates: tenant.canva_templates || {},
|
||||
campaign_audience: tenant.campaign_audience || {},
|
||||
campaign_products: tenant.campaign_products || {},
|
||||
style,
|
||||
compiled_skill: compiledMarkdown,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
if (!isAgencyRole(role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tenantId = body.tenant_id || body.tenantId;
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: existing, error: getErr } = await admin
|
||||
.from('tenants')
|
||||
.select('id, name, slug, settings')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (getErr || !existing) {
|
||||
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
const normalizedStyle: ClientCreativeStyle = normalizeCreativeStyle(
|
||||
body.style,
|
||||
existing.name || 'Cliente',
|
||||
existing.slug || ''
|
||||
);
|
||||
normalizedStyle.updated_at = new Date().toISOString();
|
||||
|
||||
const updatePayload: Record<string, any> = {
|
||||
creative_style: normalizedStyle,
|
||||
settings: {
|
||||
...(existing.settings || {}),
|
||||
creative_style: normalizedStyle,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof body.creatives_enabled === 'boolean') {
|
||||
updatePayload.creatives_enabled = body.creatives_enabled;
|
||||
}
|
||||
if (typeof body.meta_ad_account_id === 'string') {
|
||||
updatePayload.meta_ad_account_id = body.meta_ad_account_id.trim() || null;
|
||||
}
|
||||
if (typeof body.meta_page_id === 'string') {
|
||||
updatePayload.meta_page_id = body.meta_page_id.trim() || null;
|
||||
}
|
||||
if (typeof body.meta_ig_user_id === 'string') {
|
||||
updatePayload.meta_ig_user_id = body.meta_ig_user_id.trim() || null;
|
||||
}
|
||||
if (typeof body.slack_channel_id === 'string') {
|
||||
updatePayload.slack_channel_id = body.slack_channel_id.trim() || null;
|
||||
}
|
||||
if (typeof body.drive_folder_id === 'string') {
|
||||
updatePayload.drive_folder_id = body.drive_folder_id.trim() || null;
|
||||
}
|
||||
if (typeof body.default_landing_url === 'string') {
|
||||
updatePayload.default_landing_url = body.default_landing_url.trim() || null;
|
||||
}
|
||||
if (body.canva_templates && typeof body.canva_templates === 'object') {
|
||||
updatePayload.canva_templates = body.canva_templates;
|
||||
}
|
||||
if (body.campaign_audience && typeof body.campaign_audience === 'object') {
|
||||
updatePayload.campaign_audience = body.campaign_audience;
|
||||
}
|
||||
if (body.campaign_products && typeof body.campaign_products === 'object') {
|
||||
updatePayload.campaign_products = body.campaign_products;
|
||||
}
|
||||
|
||||
const { error: updErr } = await admin
|
||||
.from('tenants')
|
||||
.update(updatePayload)
|
||||
.eq('id', tenantId);
|
||||
|
||||
if (updErr) {
|
||||
return NextResponse.json({ error: updErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const compiledMarkdown = compileClientSkillMarkdown(
|
||||
normalizedStyle,
|
||||
existing.name || 'Cliente',
|
||||
existing.slug || 'cliente'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
style: normalizedStyle,
|
||||
compiled_skill: compiledMarkdown,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { compileClientSkillMarkdown, normalizeCreativeStyle } from '@/lib/creative-style';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -17,10 +18,10 @@ export async function GET(req: NextRequest) {
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const adminClient = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
// 1. Obtener slug del cliente
|
||||
// 1. Obtener slug y creative_style del cliente desde BD
|
||||
const { data: tenant, error: tErr } = await adminClient
|
||||
.from('tenants')
|
||||
.select('id, name, slug')
|
||||
.select('id, name, slug, creative_style, settings')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -31,19 +32,32 @@ export async function GET(req: NextRequest) {
|
||||
const slug = (tenant.slug || 'bps').toLowerCase().trim();
|
||||
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
|
||||
|
||||
// 2. Resolver skill del cliente (skills/clients/<slug>.md)
|
||||
const skillsDir = path.join(process.cwd(), 'skills');
|
||||
const clientCandidates = [
|
||||
path.join(skillsDir, 'clients', `${slug}.md`),
|
||||
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
|
||||
path.join(skillsDir, 'clients', 'blue-parrot-school.md'),
|
||||
];
|
||||
// 2. Resolver skill del cliente: primero desde BD (creative_style editable en la app), fallback a archivo en disco
|
||||
const rawDbStyle =
|
||||
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
|
||||
? tenant.creative_style
|
||||
: (tenant.settings as any)?.creative_style;
|
||||
|
||||
let clientSkill = '';
|
||||
for (const p of clientCandidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
clientSkill = fs.readFileSync(p, 'utf-8');
|
||||
break;
|
||||
const skillsDir = path.join(process.cwd(), 'skills');
|
||||
|
||||
if (rawDbStyle && Object.keys(rawDbStyle).length > 0) {
|
||||
const normalized = normalizeCreativeStyle(rawDbStyle, tenant.name || 'Cliente', slug);
|
||||
clientSkill = compileClientSkillMarkdown(normalized, tenant.name || 'Cliente', slug);
|
||||
} else {
|
||||
const clientCandidates = [
|
||||
path.join(skillsDir, 'clients', `${slug}.md`),
|
||||
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
|
||||
];
|
||||
for (const p of clientCandidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
clientSkill = fs.readFileSync(p, 'utf-8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!clientSkill) {
|
||||
const normalized = normalizeCreativeStyle(null, tenant.name || 'Cliente', slug);
|
||||
clientSkill = compileClientSkillMarkdown(normalized, tenant.name || 'Cliente', slug);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user