feat: LinkedIn editorial, accesos agencia estables y deshacer en historial

Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 04:15:51 +02:00
parent 3b57cdce53
commit 4a1abd3318
54 changed files with 6400 additions and 527 deletions

401
scripts/audit_all_brands.ts Normal file
View File

@@ -0,0 +1,401 @@
/**
* Full portfolio audit: Drive, brandbook, Meta/IG/FB/GAds, creatives folders.
* Optionally --fix to rematch Drive, enrich, ensure Ads/RRSS folders.
*
* npx tsx scripts/audit_all_brands.ts
* npx tsx scripts/audit_all_brands.ts --fix
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
import {
applyMatches,
listClientFoldersUnderClientes,
matchFoldersToTenants,
scoreFolderToTenant,
} from '../src/lib/google-drive';
import { resolveBrandSlug } from '../src/lib/tenant-brand-aliases';
const FIX = process.argv.includes('--fix');
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
};
/** Meta IG user ids / page ids are numeric; reject profile URLs. */
function isMetaNumericId(v: string | null | undefined): boolean {
return Boolean(v && /^\d{5,}$/.test(v.trim()));
}
/** Ad account: act_123 or bare digits (normalize to act_ on fix). */
function isMetaAdAccountId(v: string | null | undefined): boolean {
if (!v) return false;
const s = v.trim();
return /^act_\d+$/i.test(s) || /^\d{5,}$/.test(s);
}
function normalizeAdAccountId(v: string): string {
const s = v.trim();
if (/^act_/i.test(s)) return s;
if (/^\d+$/.test(s)) return `act_${s}`;
return s;
}
type Tenant = {
id: string;
slug: string;
name: string;
agency_followed?: boolean;
drive_folder_id?: string | null;
meta_ad_account_id?: string | null;
meta_page_id?: string | null;
meta_ig_user_id?: string | null;
default_landing_url?: string | null;
creatives_enabled?: boolean | null;
};
type Integ = {
id?: string;
tenant_id: string;
provider: string;
status: string;
external_account_id?: string | null;
label?: string | null;
};
export type Row = {
slug: string;
name: string;
followed: boolean;
brandbook: boolean;
driveTenant: boolean;
driveInteg: boolean;
driveLabel: string;
metaAds: boolean;
facebook: boolean;
instagram: boolean;
googleAds: boolean;
ga4: boolean;
issues: string[];
scoreHint: number;
};
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(`${url}/rest/v1/${path}`, {
...init,
headers: { ...h, ...(init?.headers || {}) },
});
const text = await r.text();
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 300)}`);
return text ? (JSON.parse(text) as T) : ([] as T);
}
async function main() {
const tenants = await rest<Tenant[]>(
'tenants?select=id,slug,name,agency_followed,drive_folder_id,meta_ad_account_id,meta_page_id,meta_ig_user_id,default_landing_url,creatives_enabled&order=name.asc'
);
const active = tenants.filter((t) => t.agency_followed !== false);
const archived = tenants.filter((t) => t.agency_followed === false);
const integs = await rest<Integ[]>(
'integrations?select=id,tenant_id,provider,status,external_account_id,label'
);
const byTenant = new Map<string, Integ[]>();
for (const i of integs) {
const arr = byTenant.get(i.tenant_id) || [];
arr.push(i);
byTenant.set(i.tenant_id, arr);
}
const docs = await rest<{ tenant_id: string; type: string }[]>(
'documents?type=eq.brandbook&select=tenant_id,type'
);
const hasBook = new Set(docs.map((d) => d.tenant_id));
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
console.log(
`Clientes root=${clientesRoot?.name || '?'} folders=${folders.length} active=${active.length} archived=${archived.length} fix=${FIX}`
);
const rows: Row[] = [];
for (const t of active) {
const list = byTenant.get(t.id) || [];
const connected = list.filter((i) => i.status === 'connected');
const find = (p: string) =>
connected.find((i) => i.provider === p) ||
list.find((i) => i.provider === p);
const drive = find('google_drive');
const meta = find('meta_ads');
const fb = find('facebook');
const ig = find('instagram');
const gads = find('google_ads');
const ga4 = find('ga4');
const issues: string[] = [];
if (!hasBook.has(t.id)) issues.push('sin brandbook');
if (!t.drive_folder_id && drive?.status !== 'connected') {
issues.push('sin Drive');
}
if (
drive?.status === 'connected' &&
/navidad|black\s*friday|campa/i.test(drive.label || '')
) {
issues.push(`Drive sospechoso: ${drive.label}`);
}
if (t.drive_folder_id && drive?.status === 'connected') {
if (drive.external_account_id !== t.drive_folder_id) {
issues.push('drive_folder_id ≠ integration');
}
}
if (t.drive_folder_id && drive?.status !== 'connected') {
issues.push('drive_folder_id sin integration connected');
}
// Sync / validate tenant meta ids from integrations
if (!t.meta_ad_account_id && meta?.status === 'connected' && meta.external_account_id) {
issues.push('meta_ad_account_id vacío (hay integration)');
}
if (t.meta_ad_account_id && !isMetaAdAccountId(t.meta_ad_account_id)) {
issues.push(`meta_ad_account_id inválido: ${t.meta_ad_account_id}`);
} else if (
t.meta_ad_account_id &&
/^\d+$/.test(t.meta_ad_account_id.trim()) &&
t.meta_page_id === t.meta_ad_account_id
) {
issues.push('meta_ad_account_id = meta_page_id (falta act_)');
}
if (!t.meta_page_id && fb?.status === 'connected' && fb.external_account_id) {
issues.push('meta_page_id vacío');
}
if (t.meta_page_id && !isMetaNumericId(t.meta_page_id)) {
issues.push(`meta_page_id inválido: ${t.meta_page_id}`);
}
if (!t.meta_ig_user_id && ig?.status === 'connected' && ig.external_account_id) {
issues.push('meta_ig_user_id vacío');
}
if (
t.meta_ig_user_id &&
!isMetaNumericId(t.meta_ig_user_id)
) {
issues.push(`meta_ig_user_id no numérico: ${t.meta_ig_user_id}`);
}
if (
ig?.status === 'connected' &&
ig.external_account_id &&
!isMetaNumericId(ig.external_account_id)
) {
issues.push(`IG integration no numérica: ${ig.external_account_id}`);
}
// Best Drive folder candidate if missing
let scoreHint = 0;
if (!t.drive_folder_id || drive?.status !== 'connected') {
const scored = folders
.map((f) => ({
f,
score: scoreFolderToTenant(f.name, t.name, t.slug),
}))
.filter((x) => x.score >= 70)
.sort((a, b) => b.score - a.score);
scoreHint = scored[0]?.score || 0;
if (scored[0]) {
issues.push(`Drive candidate: ${scored[0].f.name} (${scored[0].score})`);
}
}
rows.push({
slug: t.slug,
name: t.name,
followed: true,
brandbook: hasBook.has(t.id),
driveTenant: Boolean(t.drive_folder_id),
driveInteg: drive?.status === 'connected',
driveLabel: drive?.label || '',
metaAds: meta?.status === 'connected',
facebook: fb?.status === 'connected',
instagram: ig?.status === 'connected',
googleAds: gads?.status === 'connected',
ga4: ga4?.status === 'connected',
issues,
scoreHint,
});
}
// Print table
console.log('\n=== ACTIVE PORTFOLIO ===');
for (const r of rows) {
const flags = [
r.brandbook ? 'BB' : '--',
r.driveInteg ? 'DRV' : '--',
r.metaAds ? 'META' : '----',
r.instagram ? 'IG' : '--',
r.facebook ? 'FB' : '--',
r.googleAds ? 'GADS' : '----',
r.ga4 ? 'GA4' : '---',
].join(' ');
const issueStr = r.issues.length ? ` ⚠ ${r.issues.join('; ')}` : '';
console.log(
`${r.slug.padEnd(22)} ${flags} ${r.driveLabel.slice(0, 36).padEnd(36)}${issueStr}`
);
}
console.log('\n=== ARCHIVED ===');
for (const t of archived) {
console.log(` ${t.slug} · ${t.name}`);
}
if (FIX) {
console.log('\n=== FIXING ===');
// Rematch Drive for those missing / suspicious
const needDrive = active.filter((t) => {
const list = byTenant.get(t.id) || [];
const drive = list.find((i) => i.provider === 'google_drive');
if (drive?.status === 'connected' && /navidad/i.test(drive.label || '')) {
return true;
}
return !t.drive_folder_id || drive?.status !== 'connected';
});
if (needDrive.length && folders.length) {
const matches = await matchFoldersToTenants(
needDrive.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
folders
);
const good = matches.filter(
(m) =>
m.status === 'matched' &&
m.folderId &&
!/navidad/i.test(m.folderName || '')
);
if (good.length) {
await applyMatches(good);
for (const m of good) {
await rest(`tenants?id=eq.${m.tenantId}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({ drive_folder_id: m.folderId }),
});
console.log(`[drive] ${m.tenantSlug} ← ${m.folderName}`);
}
} else {
console.log('[drive] no new high-confidence matches');
}
}
// Sync meta_* columns from connected integrations (numeric IDs only)
for (const t of active) {
const list = byTenant.get(t.id) || [];
const connected = list.filter((i) => i.status === 'connected');
const patch: Record<string, string> = {};
const meta = connected.find((i) => i.provider === 'meta_ads');
const fb = connected.find((i) => i.provider === 'facebook');
const ig = connected.find((i) => i.provider === 'instagram');
if (meta?.external_account_id && isMetaAdAccountId(meta.external_account_id)) {
const ad = normalizeAdAccountId(meta.external_account_id);
if (t.meta_ad_account_id !== ad) patch.meta_ad_account_id = ad;
if (meta.id && meta.external_account_id !== ad) {
await rest(`integrations?id=eq.${meta.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({ external_account_id: ad }),
});
}
} else if (!t.meta_ad_account_id && meta?.external_account_id) {
console.warn(`[ids] skip bad meta_ads for ${t.slug}: ${meta.external_account_id}`);
}
if (
(!t.meta_page_id || !isMetaNumericId(t.meta_page_id)) &&
fb?.external_account_id &&
isMetaNumericId(fb.external_account_id)
) {
patch.meta_page_id = fb.external_account_id.trim();
}
if (
(!t.meta_ig_user_id || !isMetaNumericId(t.meta_ig_user_id)) &&
ig?.external_account_id &&
isMetaNumericId(ig.external_account_id)
) {
patch.meta_ig_user_id = ig.external_account_id.trim();
} else if (
ig?.external_account_id &&
!isMetaNumericId(ig.external_account_id)
) {
console.warn(`[ids] skip bad IG for ${t.slug}: ${ig.external_account_id}`);
}
// Normalize bare digit ad account already on tenant
if (
t.meta_ad_account_id &&
/^\d+$/.test(t.meta_ad_account_id.trim()) &&
!patch.meta_ad_account_id
) {
patch.meta_ad_account_id = normalizeAdAccountId(t.meta_ad_account_id);
}
const drive = connected.find((i) => i.provider === 'google_drive');
if (
drive?.external_account_id &&
!/navidad/i.test(drive.label || '') &&
t.drive_folder_id !== drive.external_account_id
) {
patch.drive_folder_id = drive.external_account_id;
}
if (Object.keys(patch).length) {
await rest(`tenants?id=eq.${t.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify(patch),
});
console.log(`[ids] ${t.slug}`, patch);
}
}
// Enrich + creatives folders for all active
for (const t of active) {
const r = await enrichTenantBrandbook({
tenantId: t.id,
forceSeed: !hasBook.has(t.id),
});
const drive = await ensureCreativesDriveFolders(
t.id,
resolveBrandSlug(t.slug) || t.slug
);
console.log(
`[enrich] ${t.slug} bb=${r.ok} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning || 'skip'}`
);
}
}
// Summary JSON for canvas
const summary = {
active: active.length,
archived: archived.length,
withBrandbook: rows.filter((r) => r.brandbook).length,
withDrive: rows.filter((r) => r.driveInteg).length,
withMeta: rows.filter((r) => r.metaAds).length,
withIg: rows.filter((r) => r.instagram).length,
withIssues: rows.filter((r) => r.issues.length).length,
rows,
archivedSlugs: archived.map((t) => t.slug),
};
const outPath = resolve(process.cwd(), 'scripts/.audit_portfolio.json');
await import('fs/promises').then((fs) =>
fs.writeFile(outPath, JSON.stringify(summary, null, 2), 'utf8')
);
console.log(`\nWrote ${outPath}`);
console.log(
`Summary: ${summary.withBrandbook}/${summary.active} BB · ${summary.withDrive}/${summary.active} Drive · ${summary.withMeta} Meta · ${summary.withIssues} with issues`
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,82 @@
/**
* Re-enrich TYD/TSP/ECP/DTATF + dump Drive folder scores.
* npx tsx scripts/audit_four_brands.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
import { listClientFoldersUnderClientes } from '../src/lib/google-drive';
import { scoreNameToTenant } from '../src/lib/match';
const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const;
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = { apikey: key, Authorization: `Bearer ${key}` };
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
console.log(
'Drive root',
clientesRoot?.name || '(none)',
'folders',
folders.length
);
for (const f of [...folders].sort((a, b) => a.name.localeCompare(b.name))) {
console.log(' ', f.name);
}
for (const slug of FOCUS) {
const tr = await fetch(
`${url}/rest/v1/tenants?slug=eq.${slug}&select=id,name,slug,drive_folder_id`,
{ headers: h }
);
const t = (await tr.json())[0] as
| { id: string; name: string; slug: string; drive_folder_id?: string }
| undefined;
if (!t) {
console.log(`[missing] ${slug}`);
continue;
}
const scored = folders
.map((f) => ({
name: f.name,
id: f.id,
score: scoreNameToTenant(f.name, t.name, t.slug),
}))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 8);
console.log(`\n[${slug}] folder scores`, scored);
const r = await enrichTenantBrandbook({
tenantId: t.id,
forceSeed: true,
});
console.log(
`[enrich] ${slug} ok=${r.ok} created=${r.created} score=${r.completenessAfter} warn=${r.warning || '-'} sources=${r.sources.join('+')}`
);
const drive = await ensureCreativesDriveFolders(t.id, slug);
console.log(
`[creatives-folders] ${slug}`,
drive.ok ? 'ok' : drive.warning
);
const bb = await fetch(
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`,
{ headers: h }
);
const rows = (await bb.json()) as { id: string }[];
console.log(`[bb] ${slug}`, rows[0]?.id || 'MISSING');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
/**
* Force-seed enrich for DNA-thin brandbooks after calendar-generate-brand updates.
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
const THIN = [
'delgado-abogados',
'equinsa-parking',
'fbm-abogados',
'la-freseria',
'virginia-molet',
'wine-advisors',
];
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
};
const tenants = (await fetch(
`${url}/rest/v1/tenants?slug=in.(${THIN.join(',')})&select=id,slug`,
{ headers: h }
).then((r) => r.json())) as { id: string; slug: string }[];
for (const t of tenants) {
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
console.log(
t.slug,
`${r.completenessBefore}→${r.completenessAfter}`,
'filled',
(r.filledFields || []).slice(0, 10).join(',') || '-',
r.warning || ''
);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,132 @@
/**
* Create client Drive roots for ECP + TSP under 03-Clientes if missing,
* link integrations + tenants.drive_folder_id, ensure Ads/RRSS creatives folders,
* re-enrich brandbooks.
*
* npx tsx scripts/ensure_ecp_tsp_drive.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
import {
ensureChildFolder,
findClientesRootFolder,
listClientFoldersUnderClientes,
} from '../src/lib/google-drive';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
};
const TARGETS: { slug: string; folderName: string }[] = [
{ slug: 'ecp', folderName: '50-ECP Escuela de Cuidadores' },
{ slug: 'tsp', folderName: '51-TSP Talento Scholar Program' },
];
async function main() {
const listed = await listClientFoldersUnderClientes();
const root = listed.clientesRoot || (await findClientesRootFolder());
if (!root?.id) throw new Error('No se encontró carpeta Clientes en Drive');
console.log('Clientes root', root.name, root.id);
const folders = listed.folders;
for (const t of TARGETS) {
const tr = await fetch(
`${url}/rest/v1/tenants?slug=eq.${t.slug}&select=id,slug,name`,
{ headers: h }
);
const tenant = (await tr.json())[0] as
| { id: string; slug: string; name: string }
| undefined;
if (!tenant) {
console.log(`[skip] ${t.slug} no existe`);
continue;
}
let folder = folders.find((f) =>
t.slug === 'ecp'
? /ecp|cuidadores/i.test(f.name) && !/navidad/i.test(f.name)
: /tsp|scholar/i.test(f.name)
);
if (!folder) {
const id = await ensureChildFolder(root.id, t.folderName);
folder = { id, name: t.folderName };
console.log(`[create] ${t.folderName} → ${id}`);
} else {
console.log(`[exist] ${folder.name} → ${folder.id}`);
}
await fetch(
`${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive`,
{
method: 'PATCH',
headers: h,
body: JSON.stringify({
status: 'connected',
enabled: true,
external_account_id: folder.id,
label: folder.name,
updated_at: new Date().toISOString(),
}),
}
);
// Upsert if no google_drive row
const check = await fetch(
`${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive&select=id`,
{ headers: h }
);
const rows = (await check.json()) as { id: string }[];
if (!rows.length) {
await fetch(`${url}/rest/v1/integrations`, {
method: 'POST',
headers: h,
body: JSON.stringify({
tenant_id: tenant.id,
provider: 'google_drive',
status: 'connected',
enabled: true,
external_account_id: folder.id,
label: folder.name,
}),
});
}
await fetch(`${url}/rest/v1/tenants?id=eq.${tenant.id}`, {
method: 'PATCH',
headers: h,
body: JSON.stringify({ drive_folder_id: folder.id }),
});
const creatives = await ensureCreativesDriveFolders(tenant.id, t.slug);
console.log(
`[creatives] ${t.slug}`,
creatives.ok ? 'Ads+RRSS ok' : creatives.warning
);
const enrich = await enrichTenantBrandbook({
tenantId: tenant.id,
forceSeed: true,
});
console.log(
`[enrich] ${t.slug} ok=${enrich.ok} score=${enrich.completenessAfter} warn=${enrich.warning || '-'}`
);
}
console.log('Done');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

232
scripts/fix_brand_gaps.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* Fix remaining gaps for TYD / TSP / ECP / DTATF:
* - Rematch Drive folders (esp. ECP, TSP)
* - Ensure brandbooks exist
* - Seed disconnected integration shells for new tenants
* - Ensure Creatividades-Ads/RRSS folders
*
* npx tsx scripts/fix_brand_gaps.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
import {
applyMatches,
listClientFoldersUnderClientes,
matchFoldersToTenants,
scoreFolderToTenant,
} from '../src/lib/google-drive';
import {
BRAND_DISPLAY_NAMES,
brandSiblingNote,
} from '../src/lib/tenant-brand-aliases';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Prefer: 'return=representation',
};
const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const;
const PROVIDERS = [
'meta_ads',
'facebook',
'instagram',
'google_ads',
'google_drive',
'ga4',
'search_console',
'gohighlevel',
'clientify',
'linkedin',
] as const;
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(`${url}/rest/v1/${path}`, {
...init,
headers: { ...h, ...(init?.headers || {}) },
});
const text = await r.text();
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`);
return text ? (JSON.parse(text) as T) : ([] as T);
}
async function ensureIntegrationShells(tenantId: string) {
const existing = await rest<{ provider: string }[]>(
`integrations?tenant_id=eq.${tenantId}&select=provider`
);
const have = new Set(existing.map((r) => r.provider));
for (const provider of PROVIDERS) {
if (have.has(provider)) continue;
await rest('integrations', {
method: 'POST',
headers: { Prefer: 'resolution=ignore-duplicates,return=minimal' },
body: JSON.stringify({
tenant_id: tenantId,
provider,
status: 'disconnected',
enabled: false,
label: provider,
}),
}).catch(() => undefined);
}
}
async function main() {
const tenants = await rest<
{ id: string; slug: string; name: string; drive_folder_id?: string | null }[]
>(
`tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id`
);
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
console.log(
'focus tenants',
tenants.map((t) => t.slug).join(', ')
);
for (const t of tenants) {
await ensureIntegrationShells(t.id);
console.log(`[shells] ${t.slug}`);
}
// Drive rematch under Clientes
try {
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
console.log(
`Drive root=${clientesRoot?.name || '?'} folders=${folders.length}`
);
// Manual preferred matches by name heuristics
const prefer: Record<string, RegExp> = {
ecp: /^(?:\d+[\.\-\s_]*)?(?:ecp|escuela\s*de\s*cuidadores|cuidadores)/i,
tsp: /^(?:\d+[\.\-\s_]*)?(?:tsp|talento\s*scholar|scholar)/i,
tyd: /^(?:\d+[\.\-\s_]*)?(?:06[\.\-\s_]*)?talento\s*y\s*deporte/i,
dtatf: /^(?:\d+[\.\-\s_]*)?(?:08[\.\-\s_]*)?(?:detuatu|de\s*tu\s*a\s*tu)/i,
};
for (const slug of FOCUS) {
const t = bySlug[slug];
if (!t) continue;
// skip Navidad-like noise
const candidates = folders
.map((f) => ({
f,
score: prefer[slug]?.test(f.name)
? 100
: scoreFolderToTenant(f.name, t.name, t.slug),
}))
.filter((x) => x.score >= 70 && !/navidad/i.test(x.f.name))
.sort((a, b) => b.score - a.score);
console.log(
`[drive candidates ${slug}]`,
candidates.slice(0, 5).map((c) => `${c.score}:${c.f.name}`)
);
const best = candidates[0];
if (!best) continue;
await rest(
`integrations?tenant_id=eq.${t.id}&provider=eq.google_drive`,
{
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({
status: 'connected',
enabled: true,
external_account_id: best.f.id,
label: best.f.name,
updated_at: new Date().toISOString(),
}),
}
);
await rest(`tenants?id=eq.${t.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({ drive_folder_id: best.f.id }),
});
console.log(`[drive] ${slug} ← ${best.f.name} (${best.f.id})`);
}
// Also run generic matcher for any still missing
const still = await rest<
{ id: string; slug: string; name: string; drive_folder_id?: string | null }[]
>(
`tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id`
);
const need = still.filter((t) => !t.drive_folder_id);
if (need.length) {
const matches = await matchFoldersToTenants(need, folders);
const good = matches.filter(
(m) =>
m.status === 'matched' &&
m.folderId &&
!/navidad/i.test(m.folderName || '')
);
if (good.length) {
await applyMatches(good);
for (const m of good) {
await rest(`tenants?id=eq.${m.tenantId}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({ drive_folder_id: m.folderId }),
});
console.log(`[drive match] ${m.tenantSlug} ← ${m.folderName}`);
}
}
}
} catch (e) {
console.error('Drive rematch failed', e);
}
// Enrich + ensure folders
for (const slug of FOCUS) {
const t = bySlug[slug];
if (!t) continue;
await rest(`tenants?id=eq.${t.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({
name: BRAND_DISPLAY_NAMES[slug],
agency_notes: [
brandSiblingNote(slug),
`Código canónico: ${slug.toUpperCase()}.`,
]
.filter(Boolean)
.join(' '),
}),
});
const r = await enrichTenantBrandbook({
tenantId: t.id,
forceSeed: true,
});
const drive = await ensureCreativesDriveFolders(t.id, slug);
console.log(
`[enrich] ${slug} created=${r.created} ${r.completenessBefore}→${r.completenessAfter} sources=${r.sources.join('+')} drive=${drive.ok ? 'ok' : drive.warning}`
);
}
// Verify brandbooks
for (const slug of FOCUS) {
const t = bySlug[slug];
if (!t) continue;
const bb = await rest<{ id: string }[]>(
`documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`
);
console.log(`[verify] ${slug} brandbook=${bb[0]?.id || 'MISSING'}`);
}
console.log('Done');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
/**
* Asegura app_metadata.role=admin (o team) para operadores @4ventures.es.
* npx tsx scripts/heal_agency_roles.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { createClient } from '@supabase/supabase-js';
config({ path: resolve(process.cwd(), '.env.local') });
const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
const COMPANY = '4Ventures';
const SLUG = '4ventures-hub';
const FORCE_ADMIN = [
'marketing@4ventures.es',
'marcos@4ventures.es',
'belen@4ventures.es',
'rebecca@4ventures.es',
'victor@4ventures.es',
'agencia@4ventures.es',
'ops@4ventures.es',
];
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) throw new Error('Faltan env Supabase');
const admin = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
const emails = new Set(FORCE_ADMIN.map((e) => e.toLowerCase()));
let page = 1;
let fixed = 0;
let scanned = 0;
for (;;) {
const { data, error } = await admin.auth.admin.listUsers({
page,
perPage: 200,
});
if (error) throw error;
const users = data.users || [];
if (!users.length) break;
for (const u of users) {
scanned += 1;
const email = (u.email || '').toLowerCase();
if (!email.endsWith('@4ventures.es') && !emails.has(email)) continue;
const appRole = String(u.app_metadata?.role || '');
if (appRole === 'admin' || appRole === 'team') {
// Ya OK; repara tenant/company si faltan
if (u.app_metadata?.tenant_id && u.app_metadata?.company) continue;
}
if (appRole === 'client') {
console.log(`skip client explícito: ${email}`);
continue;
}
const role = emails.has(email) ? 'admin' : 'team';
const { error: updErr } = await admin.auth.admin.updateUserById(u.id, {
app_metadata: {
...(u.app_metadata || {}),
role,
tenant_id: u.app_metadata?.tenant_id || HUB_TENANT,
company: u.app_metadata?.company || COMPANY,
slug: u.app_metadata?.slug || SLUG,
},
user_metadata: {
...(u.user_metadata || {}),
role,
company: COMPANY,
},
});
if (updErr) {
console.error(`FAIL ${email}:`, updErr.message);
} else {
fixed += 1;
console.log(`OK ${email} → app_metadata.role=${role}`);
}
}
if (users.length < 200) break;
page += 1;
}
console.log(`Scanned ${scanned}, fixed ${fixed}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -97,7 +97,7 @@ const JOBS: ClientJob[] = [
], ],
}, },
{ {
slug: 'talento-y-deporte', slug: 'tyd',
brand: 'Talento y Deporte', brand: 'Talento y Deporte',
file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`, file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`,
sheets: { sheets: {

View File

@@ -50,7 +50,7 @@ DRIVE_SHEETS = {
# Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero). # Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero).
"force_sheet_months": {"Hoja 1": "2026-10"}, "force_sheet_months": {"Hoja 1": "2026-10"},
}, },
"talento-y-deporte": { "tyd": {
"brand": "Talento y Deporte", "brand": "Talento y Deporte",
"file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA", "file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA",
}, },

View File

@@ -0,0 +1,632 @@
#!/usr/bin/env python3
"""
Remedia brandbooks: elimina kits inventados y aplica solo señales
verificadas desde webs oficiales (+ notas de procedencia).
"""
from __future__ import annotations
import json
import random
import time
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
def env(k: str) -> str:
for line in (ROOT / ".env.local").read_text().splitlines():
if line.startswith(k + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
URL = env("NEXT_PUBLIC_SUPABASE_URL")
KEY = env("SUPABASE_SERVICE_ROLE_KEY")
HR = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
HW = {**HR, "Content-Type": "application/json", "Prefer": "return=minimal"}
def get(path: str) -> Any:
req = urllib.request.Request(f"{URL}/rest/v1/{path}", headers=HR)
return json.loads(urllib.request.urlopen(req).read())
def patch(path: str, body: dict) -> None:
req = urllib.request.Request(
f"{URL}/rest/v1/{path}",
data=json.dumps(body).encode(),
headers=HW,
method="PATCH",
)
urllib.request.urlopen(req)
def nid(p: str) -> str:
return f"{p}_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
def color(name: str, hex_: str, role: str, usage: str) -> dict:
return {
"id": nid("col"),
"name": name,
"hex": hex_.upper() if hex_.startswith("#") else hex_,
"role": role,
"usage": usage,
}
def font(family: str, role: str, source: str, notes: str) -> dict:
return {
"id": nid("font"),
"family": family,
"role": role,
"weights": "",
"source": source,
"url_or_notes": notes,
}
# Solo colores con evidencia fuerte en HTML/CSS de la web oficial.
# Si no hay evidencia clara → colors = [] (mejor vacío que inventado).
VERIFIED_VISUAL: dict[str, dict[str, Any]] = {
"4ventures-hub": {
"colors": [
color(
"Naranja marca (web)",
"#E05200",
"primary",
"Frecuencia alta en CSS de https://4ventures.es/",
),
],
"fonts_kit": [
font(
"DM Sans",
"headline",
"google",
"font-family detectado en HTML de 4ventures.es",
),
font(
"Cormorant Garamond",
"display",
"google",
"font-family detectado en HTML de 4ventures.es",
),
],
"source": "HTML/CSS https://4ventures.es/",
},
"agua-miia": {
"colors": [
color(
"Azul primario",
"#0085F2",
"primary",
"CSS var marca en https://aguamiia.es/",
),
color(
"Azul corporativo",
"#2071CF",
"secondary",
"CSS var / uso frecuente en aguamiia.es",
),
color(
"Azul oscuro",
"#0F4F9E",
"neutral",
"CSS var en aguamiia.es",
),
color(
"Verde acento",
"#2E9B76",
"accent",
"CSS var en aguamiia.es",
),
],
"fonts_kit": [
font(
"Urbanist",
"headline",
"google",
"font-family dominante en HTML de aguamiia.es",
),
font(
"Nunito",
"body",
"google",
"font-family secundario en HTML de aguamiia.es",
),
],
"source": "CSS variables + font-family https://aguamiia.es/",
},
"bps": {
# BPS ya tenía kit; reforzamos con theme-color oficial
"colors": [
color(
"BPS Blue",
"#0051AA",
"primary",
"theme-color meta + uso en https://blueparrotschool.com/",
),
color(
"Accent amarillo",
"#FDC526",
"accent",
"Hex frecuente en CSS de blueparrotschool.com",
),
color(
"Ink",
"#201E22",
"neutral",
"Hex frecuente en CSS de blueparrotschool.com",
),
],
"fonts_kit": [
font(
"Figtree",
"headline",
"google",
"Referenciado en HTML blueparrotschool.com",
),
font(
"Jost",
"body",
"google",
"Referenciado en HTML blueparrotschool.com",
),
],
"source": "theme-color + CSS https://blueparrotschool.com/",
"merge_colors_if_existing": True,
},
"brava-dermoestetica": {
"colors": [
color(
"Rosa/brandy clínica",
"#7A3F4D",
"primary",
"Hex dominante (69×) en https://www.bravadermoestetica.com/",
),
color(
"Rosa oscuro",
"#61323D",
"secondary",
"Hex frecuente en bravadermoestetica.com",
),
color(
"Fondo cálido",
"#F8F5F2",
"neutral",
"Hex de fondo en bravadermoestetica.com",
),
],
"fonts_kit": [
font(
"Playfair Display",
"headline",
"google",
"font-family en HTML bravadermoestetica.com",
),
font(
"Inter",
"body",
"google",
"font-family en HTML bravadermoestetica.com",
),
font(
"Myriad Pro",
"other",
"adobe",
"font-family referenciado en HTML (licencia Adobe/sistema)",
),
],
"source": "HTML/CSS https://www.bravadermoestetica.com/",
},
"delgado-abogados": {
"colors": [
color(
"Azul enlace/marca",
"#1863DC",
"primary",
"Hex frecuente en https://abogadosdelgado.es/",
),
color(
"Ink",
"#212121",
"neutral",
"Hex frecuente en abogadosdelgado.es",
),
],
"fonts_kit": [
font(
"Inter",
"body",
"google",
"WP preset font-family Inter en abogadosdelgado.es",
),
],
"source": "HTML/CSS https://abogadosdelgado.es/ (parcial; ampliar con brand kit PDF)",
},
"dtatf": {
"colors": [
color(
"Amarillo De Tú a Tú",
"#F6A800",
"primary",
"Hex en CSS/HTML https://detuatuformacion.es/",
),
color(
"Azul oscuro De Tú a Tú",
"#273645",
"secondary",
"Hex en CSS/HTML detuatuformacion.es (+ #253645)",
),
],
"fonts_kit": [
font(
"Poppins-DeTUaTU",
"headline",
"file",
"Familia custom referenciada en detuatuformacion.es (archivo/fuente propia)",
),
],
"source": "HTML/CSS https://detuatuformacion.es/",
},
"ecp": {
"colors": [
color(
"Amarillo ECP",
"#F7A801",
"primary",
"Hex dominante en https://escueladecuidadoresprofesionales.com/",
),
color(
"Azul",
"#1863DC",
"secondary",
"Hex frecuente en web ECP",
),
color(
"Ink",
"#212121",
"neutral",
"Hex frecuente en web ECP",
),
],
"fonts_kit": [
font(
"Raleway",
"headline",
"google",
"font-family dominante en web ECP",
),
font(
"Open Sans",
"body",
"google",
"font-family secundario en web ECP",
),
],
"source": "HTML/CSS https://escueladecuidadoresprofesionales.com/",
},
"equinsa-parking": {
"colors": [
color(
"Navy Equinsa",
"#000028",
"primary",
"Hex en https://equinsaparking.com/",
),
color(
"Azul",
"#003388",
"secondary",
"Hex en equinsaparking.com",
),
color(
"Azul enlace",
"#046BD2",
"accent",
"CSS var / hex en equinsaparking.com",
),
],
"fonts_kit": [
font(
"Roboto",
"body",
"google",
"font-family dominante en equinsaparking.com",
),
],
"source": "HTML/CSS https://equinsaparking.com/",
},
"fbm-abogados": {
"colors": [
color(
"Navy FBM",
"#04243C",
"primary",
"Hex dominante (75×) en https://fbmasociados.com/",
),
],
"fonts_kit": [], # tahoma genérico — no afirmar kit de marca
"source": "HTML/CSS https://fbmasociados.com/ (color). Tipografía: pendiente brand kit.",
},
"halal-safety": {
"colors": [], # scrape débil / ruido WP
"fonts_kit": [
font(
"Manrope",
"body",
"google",
"Referenciado en HTML halalsafety.com",
),
],
"source": "Tipografía HTML https://www.halalsafety.com/. Colores: insuficientes en scrape — vacíos a propósito.",
},
"la-freseria": {
"colors": [], # el scrape pilló azules de template Framer, NO son marca fresa
"fonts_kit": [
font(
"La Fresería",
"display",
"file",
"Familia custom 'La Fresería' referenciada en lafreseria.es (archivo propio)",
),
],
"source": "Fuentes custom en https://lafreseria.es/. Colores NO aplicados: el HTML mezcla tokens Framer (#046bd2) no confiables como marca.",
},
"noberplast": {
"colors": [
color(
"Azul Noberplast",
"#0072AB",
"primary",
"Hex en https://noberplast.com/",
),
color(
"Verde",
"#06A161",
"secondary",
"Hex en noberplast.com",
),
color(
"Rojo acento",
"#DB062C",
"accent",
"Hex en noberplast.com",
),
],
"fonts_kit": [],
"source": "Hex en https://noberplast.com/. Tipografía: no detectada con claridad.",
},
"tsp": {
"colors": [
color(
"Navy Scholar",
"#161B2F",
"primary",
"Hex frecuente en https://talentoscholarprogram.com/",
),
color(
"Azul claro",
"#A4CAEF",
"secondary",
"Hex frecuente en talentoscholarprogram.com",
),
color(
"Rojo acento",
"#A40406",
"accent",
"Hex en talentoscholarprogram.com",
),
],
"fonts_kit": [
font(
"Poppins",
"body",
"google",
"font-family dominante en talentoscholarprogram.com",
),
font(
"Fraunces",
"display",
"google",
"font-family en talentoscholarprogram.com",
),
],
"source": "HTML/CSS https://talentoscholarprogram.com/",
},
"tyd": {
"colors": [
color(
"Rojo TYD",
"#E92428",
"primary",
"Hex dominante en https://talentoydeporte.com/",
),
color(
"Azul TYD",
"#004885",
"secondary",
"Hex en talentoydeporte.com",
),
],
"fonts_kit": [
font(
"Montserrat",
"headline",
"google",
"font-family en talentoydeporte.com",
),
font(
"Oswald",
"display",
"google",
"font-family referenciado en talentoydeporte.com",
),
],
"source": "HTML/CSS https://talentoydeporte.com/",
},
"virginia-molet": {
"colors": [
color(
"Azul Gravitae",
"#004271",
"primary",
"Hex frecuente en https://gravitaeacademy.com/",
),
color(
"Azul enlace",
"#1863DC",
"secondary",
"Hex frecuente en gravitaeacademy.com (posible token WP; contrastar con kit)",
),
],
"fonts_kit": [
font(
"quincy-cf",
"display",
"adobe",
"font-family 'quincy-cf' en gravitaeacademy.com",
),
font(
"Inter",
"body",
"google",
"font-family Inter en gravitaeacademy.com",
),
],
"source": "HTML/CSS https://gravitaeacademy.com/ (revisar azul #1863DC vs kit oficial)",
},
"wine-advisors": {
"colors": [
color(
"Rojo WineAdvisor",
"#CC0100",
"primary",
"Hex detectado en https://www.wineadvisor.mx/",
),
],
"fonts_kit": [],
"source": "Hex limitado en https://www.wineadvisor.mx/. Tipografía: no detectada.",
},
}
PROVENANCE_HEADER = (
"[PROCEDENCIA 2026-10-15] "
"Webs/handles/LinkedIn: contrastados con sitio oficial + Meta business_discovery + páginas LinkedIn. "
"Colores/fuentes: solo si aparecen en HTML/CSS de la web (ver visual_notes). "
"Textos de posicionamiento/tono/ICP: síntesis de brand manager a partir del copy público de la web "
"(no son citas literales del cliente ni de un PDF de marca). "
"Si un campo no tiene evidencia → vacío. "
)
def parse(c: Any) -> dict:
if isinstance(c, str):
return json.loads(c)
return dict(c or {})
def main() -> None:
tenants = get(
"tenants?agency_followed=neq.false&select=id,slug,name&order=slug"
)
for t in tenants:
slug = t["slug"]
docs = get(
f"documents?tenant_id=eq.{t['id']}&type=eq.brandbook&select=id,content&order=updated_at.desc&limit=1"
)
if not docs:
print("skip", slug)
continue
book = parse(docs[0]["content"])
vis = VERIFIED_VISUAL.get(slug)
if vis is None:
# no verified visual → clear invented kits
book["colors"] = []
book["fonts_kit"] = []
book["colors_primary"] = []
book["colors_secondary"] = []
book["fonts"] = []
note = "Sin señales visuales verificables en scrape; kit vaciado."
else:
book["colors"] = vis.get("colors") or []
book["fonts_kit"] = vis.get("fonts_kit") or []
book["colors_primary"] = [
c["hex"] for c in book["colors"] if c.get("role") == "primary"
]
book["colors_secondary"] = [
c["hex"]
for c in book["colors"]
if c.get("role") in ("secondary", "accent")
]
book["fonts"] = [f["family"] for f in book["fonts_kit"]]
if book["fonts_kit"]:
heads = [f for f in book["fonts_kit"] if f["role"] in ("headline", "display")]
bodies = [f for f in book["fonts_kit"] if f["role"] == "body"]
if heads:
book["typography_headline"] = heads[0]["family"]
if bodies:
book["typography_body"] = bodies[0]["family"]
note = vis.get("source") or ""
# Visual notes: honest provenance
book["visual_notes"] = (
f"PROCEDENCIA VISUAL: {note}. "
"Los hex/fuentes listados salen del HTML/CSS público. "
"Sustituir por brand kit oficial (PDF/AI) cuando esté en Drive/Zona de marca. "
"NO usar paletas inventadas por IA."
)
# Provenance in freeform
prev = (book.get("freeform_notes") or "").strip()
# remove older provenance blocks to avoid duplication
lines = [
ln
for ln in prev.split("\n")
if not ln.startswith("[PROCEDENCIA")
and "PROCEDENCIA VISUAL" not in ln
]
book["freeform_notes"] = (
PROVENANCE_HEADER
+ (f"Visual: {note}. " if note else "")
+ ("\n" + "\n".join(lines) if lines else "")
).strip()
# Clear weak invented claims that often aren't on-site
# Keep website/handles/products — those were verified in packs
# Soften proof_points that cite unverified stats
if slug == "la-freseria":
# don't keep invented franchise € figures as hard fact without date stamp
pps = book.get("proof_points") or []
book["proof_points"] = [
p
for p in pps
if "65.000" not in p and "EBITDA" not in p
] + [
"Claim web/franquicia: 'las fresas más famosas del mundo' (lafreseria.es)",
"IG oficial @lafreseriaoficial",
"LinkedIn company/la-freseria",
]
if slug == "equinsa-parking":
# keep qualitative; avoid conflicting installation counts
book["proof_points"] = [
"Web: especializada en sistemas de gestión de aparcamientos (equinsaparking.com)",
"LinkedIn company/equinsa-parking",
"Sede comunicada: C. Primavera 16, Torrejón de Ardoz (web)",
"Contacto web: sat@equinsa.es / +34 618 18 89 24",
]
patch(f"documents?id=eq.{docs[0]['id']}", {"content": book})
n_col = len(book.get("colors") or [])
n_font = len(book.get("fonts_kit") or [])
print(f"[ok] {slug:22} colors={n_col} fonts={n_font} · {note[:80]}")
print("DONE — invented kits removed; verified web signals applied")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,135 @@
/**
* Rename portfolio brand slugs to canonical codes:
* talento-y-deporte → tyd
* talento-scholar-program → tsp
* de-tu-a-tu-formacion → dtatf
* ecp → ecp (unchanged)
*
* npx tsx scripts/rename_brand_slugs.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
import {
BRAND_DISPLAY_NAMES,
brandSiblingNote,
} from '../src/lib/tenant-brand-aliases';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Prefer: 'return=representation',
};
const RENAMES: { from: string; to: keyof typeof BRAND_DISPLAY_NAMES }[] = [
{ from: 'talento-y-deporte', to: 'tyd' },
{ from: 'talento-scholar-program', to: 'tsp' },
{ from: 'de-tu-a-tu-formacion', to: 'dtatf' },
];
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(`${url}/rest/v1/${path}`, {
...init,
headers: { ...h, ...(init?.headers || {}) },
});
const text = await r.text();
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`);
return text ? (JSON.parse(text) as T) : ([] as T);
}
async function main() {
const today = new Date().toISOString().slice(0, 10);
const tenants = await rest<{ id: string; slug: string; name: string; agency_notes?: string }[]>(
'tenants?select=id,slug,name,agency_notes'
);
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
for (const { from, to } of RENAMES) {
const existingTo = bySlug[to];
const fromRow = bySlug[from];
if (existingTo && !fromRow) {
console.log(`[${to}] already canonical`);
continue;
}
if (!fromRow) {
console.log(`[skip] ${from} not found`);
continue;
}
if (existingTo && existingTo.id !== fromRow.id) {
throw new Error(`Conflict: both ${from} and ${to} exist`);
}
const sib = brandSiblingNote(to);
const notes = [
`[${today}] Slug canónico: ${to} · ${BRAND_DISPLAY_NAMES[to]}.`,
sib,
fromRow.agency_notes || '',
]
.filter(Boolean)
.join('\n')
.slice(0, 4000);
await rest(`tenants?id=eq.${fromRow.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({
slug: to,
name: BRAND_DISPLAY_NAMES[to],
agency_followed: true,
agency_notes: notes,
}),
});
console.log(`[rename] ${from} → ${to}`);
bySlug[to] = { ...fromRow, slug: to, name: BRAND_DISPLAY_NAMES[to] };
delete bySlug[from];
}
// Ensure ECP display name
const ecp = bySlug['ecp'];
if (ecp) {
await rest(`tenants?id=eq.${ecp.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({
name: BRAND_DISPLAY_NAMES.ecp,
agency_followed: true,
agency_notes: [
`[${today}] Marca canónica ECP (separada de DTATF).`,
brandSiblingNote('ecp'),
ecp.agency_notes || '',
]
.filter(Boolean)
.join('\n')
.slice(0, 4000),
}),
});
console.log('[ecp] display name refreshed');
}
for (const slug of ['tyd', 'tsp', 'ecp', 'dtatf'] as const) {
const t = bySlug[slug] || (await rest<{ id: string; slug: string }[]>(
`tenants?slug=eq.${slug}&select=id,slug&limit=1`
))[0];
if (!t) {
console.log(`[missing] ${slug}`);
continue;
}
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
const drive = await ensureCreativesDriveFolders(t.id, slug);
console.log(
`[enrich] ${slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}`
);
}
console.log('Done');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,272 @@
/**
* Portfolio fix:
* - Menttum: confirm archived (no longer a client)
* - De Tú a Tú Formación ↔ ECP: separate as two active clients (move ECP social/ads)
* - Create Talento Scholar Program as sibling of Talento y Deporte
* - Sync tenants.drive_folder_id from google_drive integrations
* - Seed DNA + enrich brandbooks for affected + active portfolio
*
* npx tsx scripts/split_tenants_and_fix.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Prefer: 'return=representation',
};
async function rest<T = unknown>(
path: string,
init?: RequestInit
): Promise<T> {
const r = await fetch(`${url}/rest/v1/${path}`, {
...init,
headers: { ...h, ...(init?.headers || {}) },
});
const text = await r.text();
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`);
if (!text) return [] as T;
return JSON.parse(text) as T;
}
async function patchTenant(
id: string,
patch: Record<string, unknown>
): Promise<void> {
await rest(`tenants?id=eq.${id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify(patch),
});
}
async function patchIntegration(
tenantId: string,
provider: string,
patch: Record<string, unknown>
): Promise<void> {
await rest(
`integrations?tenant_id=eq.${tenantId}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
}
);
}
async function main() {
const today = new Date().toISOString().slice(0, 10);
const tenants = await rest<
{
id: string;
slug: string;
name: string;
agency_followed?: boolean;
agency_notes?: string | null;
drive_folder_id?: string | null;
}[]
>('tenants?select=id,slug,name,agency_followed,agency_notes,drive_folder_id&order=name.asc');
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
// ——— 1. Menttum: archived, not a client ———
const menttum = bySlug['menttum'];
if (menttum) {
await patchTenant(menttum.id, {
agency_followed: false,
agency_notes: `[archivado ${today}] Ya no es cliente 4Ventures. No enriquecer / no sincronizar creatividades.\n${menttum.agency_notes || ''}`.slice(
0,
4000
),
creatives_enabled: false,
});
for (const p of [
'meta_ads',
'facebook',
'instagram',
'google_ads',
'google_drive',
'ga4',
'search_console',
]) {
await patchIntegration(menttum.id, p, {
status: 'disconnected',
enabled: false,
}).catch(() => undefined);
}
console.log('[menttum] archived + integrations disconnected');
}
// ——— 2. Separate De Tú a Tú vs ECP ———
const detu = bySlug['de-tu-a-tu-formacion'];
const ecp = bySlug['ecp'];
if (!detu || !ecp) throw new Error('missing de-tu-a-tu-formacion or ecp');
// Activate ECP as its own client
await patchTenant(ecp.id, {
agency_followed: true,
name: 'ECP · Escuela de Cuidadores',
creatives_enabled: false,
default_landing_url: 'https://escueladecuidadoresprofesionales.com',
meta_ad_account_id: 'act_1320801442642004',
meta_page_id: '111175967913754',
meta_ig_user_id: '17841453624785447',
agency_notes: `[${today}] Cliente separado de De Tú a Tú Formación. Marca: Escuela de Cuidadores Profesionales (ECP). Meta act_1320801442642004 · IG @escuelaecp · web escueladecuidadoresprofesionales.com. Drive: buscar carpeta cliente ECP/Cuidadores (evitar «Navidad ecp» si no es raíz).`,
});
// Move ECP social/ads ownership onto ECP tenant (connect ECP rows)
await patchIntegration(ecp.id, 'meta_ads', {
status: 'connected',
enabled: true,
external_account_id: 'act_1320801442642004',
label: 'ECP',
});
await patchIntegration(ecp.id, 'facebook', {
status: 'connected',
enabled: true,
external_account_id: '111175967913754',
label: 'Escuela de cuidadores profesionales',
});
await patchIntegration(ecp.id, 'instagram', {
status: 'connected',
enabled: true,
external_account_id: '17841453624785447',
label: '@escuelaecp',
});
await patchIntegration(ecp.id, 'ga4', {
status: 'connected',
enabled: true,
external_account_id: 'G-82HW868X9L',
label: 'Web escueladecuidadoresprofesionales.com - GA4',
});
// Detach ECP assets from De Tú a Tú
await patchIntegration(detu.id, 'meta_ads', {
status: 'disconnected',
enabled: false,
external_account_id: 'act_413393941247349',
label: 'Detuatuformacion (pendiente permiso API system user)',
});
await patchIntegration(detu.id, 'facebook', {
status: 'disconnected',
enabled: false,
external_account_id: null,
label: 'Facebook Page (pendiente página propia De Tú a Tú)',
});
await patchIntegration(detu.id, 'instagram', {
status: 'disconnected',
enabled: false,
external_account_id: null,
label: 'Instagram (pendiente cuenta propia De Tú a Tú)',
});
await patchTenant(detu.id, {
agency_followed: true,
name: 'De Tú a Tú Formación',
creatives_enabled: false,
meta_ad_account_id: 'act_413393941247349',
meta_page_id: null,
meta_ig_user_id: null,
default_landing_url: null,
drive_folder_id: '1PWUL2sn18n9bIURwfuc8YIKfhbl1BQfg',
agency_notes: `[${today}] Separado de ECP. Marca De Tú a Tú Formación. Google Ads 3183140753 · Drive 08-Detuatu · Meta act_413393941247349 (otorgar ads_read al system user API 4V2030). RRSS/Meta page propias pendientes; ECP ya no se gestiona aquí.`,
});
console.log('[detuatu / ecp] separated');
// ——— 3. Create Talento Scholar Program ———
let scholar = bySlug['talento-scholar-program'];
if (!scholar) {
const created = await rest<
{ id: string; slug: string; name: string }[]
>('tenants', {
method: 'POST',
body: JSON.stringify({
slug: 'talento-scholar-program',
name: 'Talento Scholar Program',
agency_followed: true,
creatives_enabled: false,
agency_notes: `[${today}] Separado de Talento y Deporte. Línea académica / Scholar (becas de estudio USA). Emparejar Meta Ads, IG y Drive propios cuando existan.`,
default_landing_url: 'https://talentoydeporte.com',
}),
});
scholar = created[0];
console.log('[talento-scholar-program] created', scholar?.id);
} else {
await patchTenant(scholar.id, {
agency_followed: true,
name: 'Talento Scholar Program',
agency_notes: `[${today}] Cliente separado de Talento y Deporte · línea Scholar / becas académicas.\n${scholar.agency_notes || ''}`.slice(
0,
4000
),
});
console.log('[talento-scholar-program] already existed — activated');
}
// Seed empty integrations row set is usually done by app on first open;
// ensure notes on Talento y Deporte clarify split
const tyd = bySlug['talento-y-deporte'];
if (tyd) {
await patchTenant(tyd.id, {
agency_followed: true,
name: 'Talento y Deporte',
drive_folder_id: '1IwXdvcVFKpHta4jf3nxT7k7qltzSufuL',
default_landing_url: 'https://talentoydeporte.com',
meta_ad_account_id: 'act_1188310622211934',
meta_page_id: '189855164776887',
meta_ig_user_id: '17841405373441110',
agency_notes: `[${today}] Línea deportiva / becas deportivas USA. Sibling: Talento Scholar Program (académico). Drive 06-Talento y Deporte · IG @talentoydeporte.`,
});
console.log('[talento-y-deporte] notes + ids synced');
}
// ——— 4. Sync drive_folder_id from google_drive for all followed tenants ———
const driveRows = await rest<
{ tenant_id: string; external_account_id: string; label?: string }[]
>(
'integrations?provider=eq.google_drive&status=eq.connected&select=tenant_id,external_account_id,label'
);
for (const row of driveRows) {
// Skip bad ECP christmas folder as root — keep label but don't force as client root if name is Navidad
if (/navidad/i.test(row.label || '')) {
console.log(
`[drive] skip suspicious folder for tenant ${row.tenant_id}: ${row.label}`
);
continue;
}
await patchTenant(row.tenant_id, {
drive_folder_id: row.external_account_id,
});
}
console.log(`[drive] synced folder ids for ${driveRows.length} tenants`);
// ——— 5. Enrich brandbooks for active portfolio (skip archived) ———
const active = await rest<{ id: string; slug: string; name: string }[]>(
'tenants?agency_followed=neq.false&select=id,slug,name&order=name.asc'
);
for (const t of active) {
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
const drive = await ensureCreativesDriveFolders(t.id, t.slug);
console.log(
`[enrich] ${t.slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}`
);
}
console.log('Done · active tenants', active.length);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -10,8 +11,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 }); return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
} }
const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client'; if (!isAgencyRole(resolveUserRole(user))) {
if (userRole !== 'admin' && userRole !== 'team') {
return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 }); return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 });
} }

View File

@@ -190,7 +190,7 @@ export async function GET(req: NextRequest) {
mapped = filterDriveEntriesForClientRoot(mapped); mapped = filterDriveEntriesForClientRoot(mapped);
if (!mapped.length && !scopeWarning) { if (!mapped.length && !scopeWarning) {
scopeWarning = scopeWarning =
'Aún no hay carpetas generadas por la app (p. ej. Pipeline-creatividades). El resto del Drive solo lo ve la agencia.'; 'Aún no hay carpetas generadas por la app (Creatividades-Ads / Creatividades-RRSS). El resto del Drive solo lo ve la agencia.';
} }
} }

View File

@@ -16,7 +16,11 @@ import {
seedBrandbook, seedBrandbook,
type Brandbook, type Brandbook,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
import { diffFields, logChange } from '@/lib/change-history'; import {
buildRevertPatch,
diffFields,
logChange,
} from '@/lib/change-history';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -286,6 +290,14 @@ export async function PUT(req: NextRequest) {
book as unknown as Record<string, unknown> book as unknown as Record<string, unknown>
); );
const revertPatch =
!body.seed && !created
? buildRevertPatch(
previousBook as unknown as Record<string, unknown>,
book as unknown as Record<string, unknown>
)
: {};
await logChange({ await logChange({
tenantId, tenantId,
tenantName: label, tenantName: label,
@@ -306,6 +318,8 @@ export async function PUT(req: NextRequest) {
: `Guardado Zona de marca · ${label}`, : `Guardado Zona de marca · ${label}`,
detail: { detail: {
changes: changes.slice(0, 40), changes: changes.slice(0, 40),
revert_patch: revertPatch,
revertable: Object.keys(revertPatch).length > 0,
seed: Boolean(body.seed), seed: Boolean(body.seed),
slug, slug,
}, },

View File

@@ -0,0 +1,116 @@
/**
* GET — resolve LinkedIn strategy URL + summary for active tenant
* (brandbook URL, known slug map, optional ?url= override).
* Used by CalendarCreator / zoom to show associated strategy content.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
BRANDBOOK_DOC_TYPE,
parseBrandbook,
} from '@/lib/brandbook';
import {
loadLinkedInStrategyContext,
resolveLinkedInStrategyUrl,
} from '@/lib/linkedin-strategy';
export const dynamic = 'force-dynamic';
export const maxDuration = 45;
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 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const override = req.nextUrl.searchParams.get('url') || '';
const postId = req.nextUrl.searchParams.get('post_id') || '';
let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-');
const bb = await restGet<{ content?: unknown }[]>(
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
);
const brandbook = parseBrandbook(bb.data?.[0]?.content);
const url = resolveLinkedInStrategyUrl({
brandbook,
slug,
overrideUrl: override,
});
if (!url) {
return NextResponse.json({
ok: true,
url: null,
brandbook_url: brandbook?.linkedin_strategy_url || null,
posts: [],
themes: brandbook?.linkedin_themes || [],
formats: brandbook?.linkedin_formats || [],
ctas: brandbook?.linkedin_cta || [],
message: 'Sin URL de estrategia LinkedIn en brandbook ni mapa conocido.',
});
}
const ctxLi = await loadLinkedInStrategyContext({
url,
brandbook,
mode: 'from_strategy',
});
const posts = (ctxLi?.posts || []).map((p) => ({
id: p.id,
n: p.n,
title: p.title,
meta: p.meta,
angle: p.angle,
body_preview: (p.body || '').slice(0, 500),
body: postId && p.id === postId ? p.body : undefined,
}));
let selected = postId
? ctxLi?.posts.find((p) => p.id === postId) || null
: null;
return NextResponse.json({
ok: true,
url,
brandbook_url: brandbook?.linkedin_strategy_url || null,
posts_count: ctxLi?.posts.length || 0,
posts: posts.slice(0, 80),
selected: selected
? {
id: selected.id,
title: selected.title,
meta: selected.meta,
angle: selected.angle,
body: selected.body,
}
: null,
themes: brandbook?.linkedin_themes || [],
formats: brandbook?.linkedin_formats || [],
ctas: brandbook?.linkedin_cta || [],
thought_leadership: brandbook?.thought_leadership || [],
});
}

View File

@@ -18,6 +18,13 @@ import {
} from '@/lib/pipeline-calendar'; } from '@/lib/pipeline-calendar';
import type { PublishedNetworkPost } from '@/lib/calendar-strategy'; import type { PublishedNetworkPost } from '@/lib/calendar-strategy';
import { normalizeApprovalStatus } from '@/lib/approval-status'; import { normalizeApprovalStatus } from '@/lib/approval-status';
import { normalizeGenOptions } from '@/lib/calendar-gen-options';
import {
collectLinkedInUsage,
loadLinkedInStrategyContext,
pickUnpublishedStrategyPosts,
resolveLinkedInStrategyUrl,
} from '@/lib/linkedin-strategy';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const maxDuration = 60; export const maxDuration = 60;
@@ -57,9 +64,10 @@ export async function POST(req: NextRequest) {
.map((t) => t.trim().slice(0, 160)) .map((t) => t.trim().slice(0, 160))
.slice(0, 40) .slice(0, 40)
: []; : [];
const genOptions = (body.gen_options || body.options || null) as const genOptionsRaw = (body.gen_options || body.options || null) as
| Record<string, unknown> | Record<string, unknown>
| null; | null;
const genOptions = normalizeGenOptions(genOptionsRaw || undefined);
let slug = 'cliente'; let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>( const t = await restGet<{ slug?: string }[]>(
@@ -129,6 +137,58 @@ export async function POST(req: NextRequest) {
}); });
} }
let linkedinStrategyUrl = '';
let linkedinPrompt: string | undefined;
let linkedinStrategyPosts:
| import('@/lib/linkedin-strategy').LinkedInStrategyPost[]
| undefined;
if (genOptions.linkedinPosts > 0 || genOptions.linkedinStrategyUrl) {
linkedinStrategyUrl = resolveLinkedInStrategyUrl({
brandbook,
slug,
overrideUrl: genOptions.linkedinStrategyUrl,
});
if (linkedinStrategyUrl) {
const liCtx = await loadLinkedInStrategyContext({
url: linkedinStrategyUrl,
brandbook,
mode: genOptions.linkedinMode,
});
if (liCtx) {
linkedinStrategyPosts = liCtx.posts;
const usage = collectLinkedInUsage({
history,
publishedPosts,
avoidTitles,
});
const pick = pickUnpublishedStrategyPosts(
liCtx.posts,
usage,
genOptions.linkedinPosts || liCtx.posts.length
);
linkedinPrompt = [
liCtx.promptBlock,
`ESTADO BACKLOG ESTRATEGIA: ${pick.publishedOrPlanned.length} ya publicados o planificados · ${pick.unpublished.length} pendientes.`,
pick.publishedOrPlanned.length
? `YA USADOS (no re-agendar): ${pick.publishedOrPlanned
.slice(0, 40)
.map((p) => p.id)
.join(', ')}`
: '',
pick.unpublished.length
? `PENDIENTES (priorizar en orden): ${pick.unpublished
.slice(0, 24)
.map((p) => `${p.id} — ${p.title}`)
.join(' | ')}`
: 'Backlog agotado → crear ideas nuevas en línea de marca.',
]
.filter(Boolean)
.join('\n');
}
}
}
const result = await generateMonthPreview({ const result = await generateMonthPreview({
ym: month, ym: month,
slug, slug,
@@ -137,7 +197,10 @@ export async function POST(req: NextRequest) {
brandbook, brandbook,
publishedPosts, publishedPosts,
avoidTitles, avoidTitles,
genOptions: genOptions || undefined, genOptions,
linkedinStrategyUrl: linkedinStrategyUrl || undefined,
linkedinPrompt,
linkedinStrategyPosts,
}); });
return NextResponse.json({ return NextResponse.json({
@@ -147,6 +210,9 @@ export async function POST(req: NextRequest) {
source: result.source, source: result.source,
brand: slug, brand: slug,
brandbook: !!brandbook, brandbook: !!brandbook,
linkedin_strategy_url: linkedinStrategyUrl || null,
linkedin_posts_from_strategy: linkedinStrategyPosts?.length || 0,
linkedin_schedule: result.linkedinMeta || null,
history_months: history.length, history_months: history.length,
network_posts: publishedPosts.length, network_posts: publishedPosts.length,
}); });

View File

@@ -0,0 +1,262 @@
/**
* POST /api/change-history/undo
* Body: { id: string }
* Deshace un cambio reversible y registra el deshacer en el historial.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
homeTenantId,
isAgencyRole,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
isHistoryRowRevertable,
logChange,
type ChangeHistoryRow,
} from '@/lib/change-history';
import {
BRANDBOOK_DOC_TITLE,
BRANDBOOK_DOC_TYPE,
parseBrandbook,
type Brandbook,
} from '@/lib/brandbook';
import { createClient as createAdminClient } from '@supabase/supabase-js';
import { resolveActiveTenant } from '@/lib/tenant-context';
export const dynamic = 'force-dynamic';
function getAdmin() {
const url = supabaseUrl();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) return null;
return createAdminClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
function patchFromChanges(
changes: { field?: string; from?: unknown }[]
): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const c of changes) {
if (!c?.field) continue;
const from = c.from;
if (typeof from === 'string' && from.endsWith('…')) {
// Valor clipado — no fiable para deshacer
continue;
}
patch[c.field] = from ?? null;
}
return patch;
}
export async function POST(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);
const isAgency = isAgencyRole(role);
const body = (await req.json().catch(() => ({}))) as { id?: string };
const id = String(body.id || '').trim();
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const admin = getAdmin();
if (!admin) {
return NextResponse.json({ error: 'Sin service role' }, { status: 503 });
}
const { data: row, error: fetchErr } = await admin
.from('change_history')
.select('*')
.eq('id', id)
.maybeSingle();
if (fetchErr || !row) {
return NextResponse.json(
{ error: fetchErr?.message || 'Cambio no encontrado' },
{ status: 404 }
);
}
const historyRow = row as ChangeHistoryRow;
// Autorización por tenant
if (!isAgency) {
const own = homeTenantId(user);
const ctx = await resolveActiveTenant(user);
const allowed = own || ctx.tenantId;
if (!allowed || historyRow.tenant_id !== allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
if (!isHistoryRowRevertable(historyRow)) {
return NextResponse.json(
{
error:
'Este cambio no se puede deshacer (ya deshecho, seed/creación, o sin parche reversible).',
},
{ status: 400 }
);
}
const detail = historyRow.detail || {};
let revertPatch: Record<string, unknown> = {};
if (
detail.revert_patch &&
typeof detail.revert_patch === 'object' &&
!Array.isArray(detail.revert_patch)
) {
revertPatch = { ...(detail.revert_patch as Record<string, unknown>) };
} else if (Array.isArray(detail.changes)) {
revertPatch = patchFromChanges(
detail.changes as { field?: string; from?: unknown }[]
);
}
if (!Object.keys(revertPatch).length) {
return NextResponse.json(
{
error:
'No hay datos suficientes para deshacer (valores anteriores incompletos).',
},
{ status: 400 }
);
}
if (historyRow.entity_type !== 'brandbook') {
return NextResponse.json(
{
error:
'De momento solo se pueden deshacer cambios de Zona de marca (brandbook).',
},
{ status: 400 }
);
}
const tenantId = historyRow.tenant_id;
const docId = historyRow.entity_id;
if (!tenantId || !docId) {
return NextResponse.json(
{ error: 'Falta tenant o documento en el registro' },
{ status: 400 }
);
}
const { data: doc, error: docErr } = await admin
.from('documents')
.select('id, content, type')
.eq('id', docId)
.eq('tenant_id', tenantId)
.maybeSingle();
if (docErr || !doc) {
return NextResponse.json(
{ error: docErr?.message || 'Documento brandbook no encontrado' },
{ status: 404 }
);
}
const current = parseBrandbook(doc.content);
if (!current) {
return NextResponse.json(
{ error: 'Brandbook ilegible' },
{ status: 500 }
);
}
const beforeUndo = { ...(current as unknown as Record<string, unknown>) };
const restoredFields: string[] = [];
const book = { ...current } as Brandbook & Record<string, unknown>;
for (const [field, value] of Object.entries(revertPatch)) {
if (field.startsWith('_')) continue;
if (value === null) {
// Campo que no existía: vaciar tipos comunes
const cur = book[field];
if (Array.isArray(cur)) book[field] = [];
else if (typeof cur === 'string') book[field] = '';
else if (typeof cur === 'object' && cur) book[field] = value;
else book[field] = value;
} else {
book[field] = value as never;
}
restoredFields.push(field);
}
book.updated_at = new Date().toISOString();
const { error: patchErr } = await admin
.from('documents')
.update({
content: book,
title: BRANDBOOK_DOC_TITLE,
updated_at: book.updated_at,
type: BRANDBOOK_DOC_TYPE,
})
.eq('id', docId);
if (patchErr) {
return NextResponse.json({ error: patchErr.message }, { status: 500 });
}
// Marca el original como deshecho (sigue visible en historial)
const markedDetail = {
...detail,
undone: true,
undone_at: new Date().toISOString(),
undone_by: user.email || user.id,
};
await admin
.from('change_history')
.update({ detail: markedDetail })
.eq('id', id);
const label =
historyRow.tenant_name ||
(await resolveActiveTenant(user)).label ||
'cliente';
await logChange({
tenantId,
tenantName: label,
actor: {
userId: user.id,
email: user.email,
role,
},
entityType: 'brandbook',
entityId: docId,
action: 'undo',
summary: `Deshacer · ${historyRow.summary}`.slice(0, 500),
detail: {
undoes: id,
undoes_summary: historyRow.summary,
undoes_at: historyRow.created_at,
restored_fields: restoredFields,
changes: restoredFields.map((f) => ({
field: f,
from: beforeUndo[f] ?? null,
to: revertPatch[f] ?? null,
})),
revertable: false,
note: 'El cambio original permanece en el historial marcado como deshecho.',
},
source: 'api/change-history/undo',
});
return NextResponse.json({
ok: true,
undid: id,
restored_fields: restoredFields,
});
}

View File

@@ -74,10 +74,16 @@ export async function POST(req: NextRequest) {
const { data: tenant } = await db const { data: tenant } = await db
.from('tenants') .from('tenants')
.select('id, slug, name') .select('id, slug, name, canva_templates')
.eq('id', tenantId) .eq('id', tenantId)
.maybeSingle(); .maybeSingle();
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
slug: tenant?.slug,
channel: 'rrss',
});
// Recent pipeline for continuity // Recent pipeline for continuity
const { data: recentRows } = await db const { data: recentRows } = await db
.from('creative_pipeline') .from('creative_pipeline')
@@ -216,6 +222,7 @@ export async function POST(req: NextRequest) {
brandbookPrompt, brandbookPrompt,
socialHistory, socialHistory,
variantsRequested: Number(body.variants_requested || 2), variantsRequested: Number(body.variants_requested || 2),
canvaTemplates,
}); });
const { data: gr, error: grErr } = await db const { data: gr, error: grErr } = await db

View File

@@ -8,6 +8,7 @@ import {
} from '@/lib/agency'; } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import { import {
channelsForCalendarPlatform,
normalizeChannels, normalizeChannels,
parseScheduledAt, parseScheduledAt,
PUBLISH_STATUSES, PUBLISH_STATUSES,
@@ -227,13 +228,7 @@ export async function POST(req: NextRequest) {
scheduledAt = scheduledAtFromPiece(cal); scheduledAt = scheduledAtFromPiece(cal);
} }
if (!channels) { if (!channels) {
const plat = (cal.platform || '').toLowerCase(); channels = channelsForCalendarPlatform(cal.platform);
channels =
plat.includes('linkedin')
? ['instagram']
: plat.includes('facebook')
? ['facebook', 'instagram']
: ['instagram', 'facebook'];
} }
} }
} }

View File

@@ -22,7 +22,7 @@ const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based) * fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
* *
* También espeja el archivo en Drive del cliente: * También espeja el archivo en Drive del cliente:
* {carpeta-cliente}/Pipeline-creatividades_{slug}/YYYY/MM/DD-MM-YYYY_formato_hook[-N].ext * {carpeta-cliente}/Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();

View File

@@ -15,9 +15,11 @@ import {
serviceSupabase, serviceSupabase,
} from '@/lib/assets'; } from '@/lib/assets';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { syncCreativeAssetToDrive } from '@/lib/creatives-drive';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const maxDuration = 90;
const ALLOWED_MIME = new Set([ const ALLOWED_MIME = new Set([
'image/jpeg', 'image/jpeg',
@@ -170,6 +172,8 @@ export async function POST(req: NextRequest) {
patch.asset_final = true; patch.asset_final = true;
} }
let creative: Record<string, unknown> | null = null;
if (agency && headers) { if (agency && headers) {
const r = await fetch( const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`, `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
@@ -188,21 +192,53 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 }); return NextResponse.json({ error: await r.text() }, { status: 500 });
} }
const data = await r.json(); const data = await r.json();
return NextResponse.json({ creative = Array.isArray(data) ? data[0] : data;
creative: Array.isArray(data) ? data[0] : data, } else {
media_url: mediaUrl, const { data, error } = await supabase
}); .from('creatives')
.update(patch)
.eq('id', creativeId)
.select('*')
.single();
if (error) {
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
return NextResponse.json({ error: error.message }, { status: 500 });
}
creative = data;
} }
const { data, error } = await supabase const ext =
.from('creatives') safeName.split('.').pop()?.toLowerCase() ||
.update(patch) (mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg');
.eq('id', creativeId)
.select('*') const drive = await syncCreativeAssetToDrive({
.single(); tenantId: ctx.tenantId,
if (error) { clientLabel: ctx.label || 'cliente',
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); channel: 'ads',
return NextResponse.json({ error: error.message }, { status: 500 }); item: {
} id: creativeId,
return NextResponse.json({ creative: data, media_url: mediaUrl }); title: String(creative?.title || title),
format: String(creative?.format || (mime.startsWith('video/') ? 'video' : 'image')),
created_at: String(creative?.created_at || new Date().toISOString()),
angle: creative?.angle ? String(creative.angle) : null,
hook: creative?.hook ? String(creative.hook) : null,
},
buffer: buf,
mimeType: mime,
ext,
slot: 'asset',
});
return NextResponse.json({
creative,
media_url: mediaUrl,
drive: {
ok: drive.ok,
skipped: drive.skipped || false,
path: drive.drivePath || null,
fileName: drive.fileName || null,
fileId: drive.driveFileId || null,
warning: drive.warning || null,
},
});
} }

View File

@@ -6,6 +6,7 @@ import {
parseGenerationRequestIdFromNotes, parseGenerationRequestIdFromNotes,
stripGenerationRequestMarker, stripGenerationRequestMarker,
} from '@/lib/rrss-generation'; } from '@/lib/rrss-generation';
import { backfillCreativeItemToDrive } from '@/lib/creatives-drive';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -110,7 +111,7 @@ export async function POST(req: NextRequest) {
const { data: item } = await db const { data: item } = await db
.from('creative_pipeline') .from('creative_pipeline')
.select('id, notes, caption, format') .select('id, notes, caption, format, title, scheduled_at, created_at')
.eq('id', pipelineId) .eq('id', pipelineId)
.eq('tenant_id', tenantId) .eq('tenant_id', tenantId)
.maybeSingle(); .maybeSingle();
@@ -236,5 +237,42 @@ export async function POST(req: NextRequest) {
.eq('tenant_id', tenantId); .eq('tenant_id', tenantId);
} }
return NextResponse.json({ ok: true, item: updated }); // Mirror final assets into Creatividades-RRSS_{slug}/YYYY/MM/…
let drive: { ok: boolean; detail?: string } | null = null;
if (coverPath && !/^https?:\/\//i.test(coverPath)) {
try {
const { data: tenant } = await db
.from('tenants')
.select('slug,name')
.eq('id', tenantId)
.maybeSingle();
const bf = await backfillCreativeItemToDrive({
tenantId,
clientLabel: tenant?.slug || tenant?.name || 'cliente',
channel: 'rrss',
item: {
id: pipelineId,
title: updated.title || item.title,
format: updated.format || item.format,
caption: updated.caption,
notes: updated.notes,
scheduled_at: updated.scheduled_at || item.scheduled_at,
created_at: updated.created_at || item.created_at,
cover_path: updated.cover_path,
slide_paths: updated.slide_paths,
},
});
drive = {
ok: bf.ok,
detail: bf.results.map((x) => `${x.slot}:${x.detail}`).join(' | '),
};
} catch (e) {
drive = {
ok: false,
detail: e instanceof Error ? e.message : 'drive sync failed',
};
}
}
return NextResponse.json({ ok: true, item: updated, drive });
} }

View File

@@ -38,18 +38,11 @@ export async function GET(
.eq('id', tenantId) .eq('id', tenantId)
.maybeSingle(); .maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
let canvaTemplates = (client as any)?.canva_templates || {}; const canvaTemplates = resolveTenantCanvaTemplates(
if ( (client as { canva_templates?: unknown })?.canva_templates,
client?.slug === 'bps' && { slug: client?.slug, channel: 'ads' }
(!canvaTemplates || Object.keys(canvaTemplates).length === 0) );
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
return NextResponse.json({ return NextResponse.json({
data: { data: {
@@ -65,7 +58,7 @@ export async function GET(
meta_publish_status: client?.meta_publish_status || 'PAUSED', meta_publish_status: client?.meta_publish_status || 'PAUSED',
slack_channel_id: client?.slack_channel_id, slack_channel_id: client?.slack_channel_id,
canva_templates: canvaTemplates, canva_templates: canvaTemplates,
drive_folder_id: (client as any)?.drive_folder_id || null, drive_folder_id: (client as { drive_folder_id?: string | null })?.drive_folder_id || null,
}, },
}, },
}); });
@@ -186,9 +179,83 @@ export async function PATCH(
}); });
} }
// Mirror final Ads asset → Creatividades-Ads_{slug}/YYYY/MM/{slug}_ads_…
let drive: { ok: boolean; path?: string; warning?: string } | null = null;
if (updates.asset_final || updates.asset_url || updates.asset_final === true) {
try {
const { syncCreativeAssetToDrive } = await import('@/lib/creatives-drive');
const { ASSETS_BUCKET, serviceSupabase } = await import('@/lib/assets');
const { data: tenant } = await adminClient
.from('tenants')
.select('slug,name')
.eq('id', tenantId)
.maybeSingle();
let buffer: Buffer | null = null;
let mimeType = 'image/jpeg';
let ext = 'jpg';
const storagePath = updated.storage_path || creative.storage_path;
const assetUrl = updated.asset_url || creative.asset_url;
if (storagePath && typeof storagePath === 'string' && !/^https?:\/\//i.test(storagePath)) {
const svc = serviceSupabase();
if (svc) {
const dl = await svc.storage.from(ASSETS_BUCKET).download(storagePath);
if (dl.data) {
buffer = Buffer.from(await dl.data.arrayBuffer());
mimeType = dl.data.type || mimeType;
ext = storagePath.split('.').pop()?.toLowerCase() || ext;
}
}
} else if (assetUrl && typeof assetUrl === 'string' && /^https?:\/\//i.test(assetUrl)) {
const r = await fetch(assetUrl, { cache: 'no-store' });
if (r.ok) {
buffer = Buffer.from(await r.arrayBuffer());
mimeType = r.headers.get('content-type') || mimeType;
ext = mimeType.includes('png')
? 'png'
: mimeType.includes('webp')
? 'webp'
: 'jpg';
}
}
if (buffer) {
const sync = await syncCreativeAssetToDrive({
tenantId,
clientLabel: tenant?.slug || tenant?.name || 'cliente',
channel: 'ads',
item: {
id,
title: updated.title || creative.title,
format: updated.format || creative.format || 'image',
created_at: updated.created_at || creative.created_at,
angle: updated.angle || creative.angle,
hook: updated.hook || creative.hook,
},
buffer,
mimeType,
ext,
slot: 'asset',
});
drive = {
ok: sync.ok,
path: sync.drivePath,
warning: sync.warning,
};
}
} catch (e) {
drive = {
ok: false,
warning: e instanceof Error ? e.message : 'drive sync failed',
};
}
}
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
data: updated, data: updated,
drive,
...updated, ...updated,
}); });
} }

View File

@@ -114,18 +114,11 @@ export async function POST(req: NextRequest) {
.eq('id', tenantId) .eq('id', tenantId)
.maybeSingle(); .maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
let canvaTemplates = tenant?.canva_templates || {}; const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
if ( slug: tenant?.slug,
tenant?.slug === 'bps' && channel: 'ads',
(!canvaTemplates || Object.keys(canvaTemplates).length === 0) });
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const clientInfo = { const clientInfo = {
id: tenant?.id || tenantId, id: tenant?.id || tenantId,
@@ -227,7 +220,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
} }
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
const enriched = (rows || []) const enriched = (rows || [])
.filter((row: any) => { .filter((row: any) => {
@@ -236,17 +229,10 @@ export async function GET(req: NextRequest) {
}) })
.map((row: any) => { .map((row: any) => {
const { tenant, ...rest } = row; const { tenant, ...rest } = row;
let canvaTemplates = tenant?.canva_templates || {}; const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
if ( slug: tenant?.slug,
tenant?.slug === 'bps' && channel: 'ads',
(!canvaTemplates || Object.keys(canvaTemplates).length === 0) });
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const item: Record<string, any> = { const item: Record<string, any> = {
...rest, ...rest,

View File

@@ -1,10 +1,27 @@
'use server'; 'use server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import {
VIEW_TENANT_COOKIE,
VIEW_TENANT_NAME_COOKIE,
VIEW_TENANT_SLUG_COOKIE,
clearedViewTenantCookieOpts,
} from '@/lib/agency';
export async function logoutAction() { export async function logoutAction() {
const supabase = await createClient(); const supabase = await createClient();
await supabase.auth.signOut(); await supabase.auth.signOut();
// Evita que el siguiente login herede "vista cliente" de otra sesión.
const jar = await cookies();
const opts = clearedViewTenantCookieOpts(
process.env.NODE_ENV === 'production'
);
jar.set(VIEW_TENANT_COOKIE, '', opts);
jar.set(VIEW_TENANT_SLUG_COOKIE, '', opts);
jar.set(VIEW_TENANT_NAME_COOKIE, '', opts);
redirect('/login'); redirect('/login');
} }

View File

@@ -5,6 +5,7 @@ import AgencyPortfolioBoard from '@/components/AgencyPortfolioBoard';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import { isAgencyRole, userRole } from '@/lib/agency';
export const revalidate = 0; export const revalidate = 0;
@@ -15,8 +16,7 @@ export default async function AgencyPage() {
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (!user) redirect('/login'); if (!user) redirect('/login');
const role = user.app_metadata?.role || user.user_metadata?.role || 'client'; if (!isAgencyRole(userRole(user))) {
if (role !== 'admin' && role !== 'team') {
redirect('/dashboard/kpis'); redirect('/dashboard/kpis');
} }

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { logoutAction } from '@/app/auth/actions'; import { logoutAction } from '@/app/auth/actions';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { import {
VIEW_TENANT_COOKIE, VIEW_TENANT_COOKIE,
VIEW_TENANT_NAME_COOKIE, VIEW_TENANT_NAME_COOKIE,
@@ -30,16 +31,26 @@ export default async function DashboardLayout({
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
// Sin usuario real no falsificamos identidad (antes salía usuario@4ventures.es).
if (!user?.email) {
redirect('/login');
}
const role = userRole(user); const role = userRole(user);
const company = const company =
(user?.user_metadata?.company as string) || (user.user_metadata?.company as string) ||
(user?.app_metadata?.company as string) || (user.app_metadata?.company as string) ||
'4Ventures Central'; (isAgencyRole(role) ? '4Ventures' : 'Portal cliente');
const email = user?.email || 'usuario@4ventures.es'; const email = user.email;
const isAgency = isAgencyRole(role); const isAgency = isAgencyRole(role);
const cookieStore = await cookies(); const cookieStore = await cookies();
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value; // Cookies de zoom solo aplican a agencia (evita chrome cliente en ops).
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null; const viewName = isAgency
? cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value
: undefined;
const viewId = isAgency
? cookieStore.get(VIEW_TENANT_COOKIE)?.value || null
: null;
const ownId = homeTenantId(user); const ownId = homeTenantId(user);
let enabledModules: Record<DashboardModuleId, boolean> | null = null; let enabledModules: Record<DashboardModuleId, boolean> | null = null;
@@ -94,9 +105,7 @@ export default async function DashboardLayout({
isAgency={isAgency} isAgency={isAgency}
viewName={viewName} viewName={viewName}
enabledModules={enabledModules} enabledModules={enabledModules}
clientLogoUrl={ clientLogoUrl={!isAgency ? clientLogoUrl : null}
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
}
brandLabel={brandLabel} brandLabel={brandLabel}
logoutAction={logoutAction} logoutAction={logoutAction}
> >

View File

@@ -68,7 +68,12 @@ function LoginForm() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form
onSubmit={handleSubmit}
className="space-y-4"
autoComplete="on"
data-form-type="login"
>
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1.5"> <label className="block text-xs font-medium text-gray-700 mb-1.5">
Correo Electrónico Correo Electrónico
@@ -79,10 +84,12 @@ function LoginForm() {
</div> </div>
<input <input
type="email" type="email"
name="email"
autoComplete="username"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="ejemplo@cliente.com" placeholder="marketing@4ventures.es"
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all" className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
/> />
</div> </div>
@@ -98,6 +105,8 @@ function LoginForm() {
</div> </div>
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
name="password"
autoComplete="current-password"
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}

View File

@@ -922,7 +922,7 @@ export default function AssetsLibrary({
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
{isAgency {isAgency
? `Misma carpeta enlazada en Agencia (Clientes → ${driveFolder || '…'}).` ? `Misma carpeta enlazada en Agencia (Clientes → ${driveFolder || '…'}).`
: 'Solo carpetas creadas por la app (p. ej. Pipeline-creatividades). El resto del Drive del cliente no se muestra.'} : 'Solo carpetas creadas por la app (Creatividades-Ads / Creatividades-RRSS, calendarios, brandbook…). El resto del Drive del cliente no se muestra.'}
</p> </p>
{loading ? ( {loading ? (
<p className="text-sm text-gray-500">Cargando Drive…</p> <p className="text-sm text-gray-500">Cargando Drive…</p>

View File

@@ -1,9 +1,12 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { import {
CalendarPlus, CalendarPlus,
ExternalLink,
Instagram,
Linkedin,
Loader2, Loader2,
Plus, Plus,
Sparkles, Sparkles,
@@ -138,20 +141,67 @@ export default function CalendarCreator({
...DEFAULT_GEN_OPTIONS, ...DEFAULT_GEN_OPTIONS,
}); });
const [showGenPanel, setShowGenPanel] = useState(true); const [showGenPanel, setShowGenPanel] = useState(true);
/** Pestaña del creador: IG/FB vs LinkedIn */
const [creatorTab, setCreatorTab] = useState<'ig-fb' | 'linkedin'>('ig-fb');
const [liStrategyMeta, setLiStrategyMeta] = useState<{
url: string | null;
posts_count: number;
themes: string[];
} | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const r = await fetch('/api/calendar/linkedin-strategy');
const j = await r.json();
if (!r.ok || cancelled) return;
setLiStrategyMeta({
url: j.url || j.brandbook_url || null,
posts_count: j.posts_count || 0,
themes: j.themes || [],
});
if (j.url || j.brandbook_url) {
setGenOptions((o) =>
o.linkedinStrategyUrl
? o
: {
...o,
linkedinStrategyUrl: String(j.url || j.brandbook_url || ''),
}
);
}
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [open]);
const monthChoices = useMemo(() => { const monthChoices = useMemo(() => {
const base = ymNow(); const base = ymNow();
return Array.from({ length: 6 }, (_, i) => addMonths(base, i)); return Array.from({ length: 6 }, (_, i) => addMonths(base, i));
}, []); }, []);
const filteredPieces = useMemo(() => {
const all = plan?.pieces || [];
if (creatorTab === 'linkedin') {
return all.filter((p) => /linkedin/i.test(p.platform || ''));
}
return all.filter((p) => !/linkedin/i.test(p.platform || ''));
}, [plan, creatorTab]);
const byDate = useMemo(() => { const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>(); const m = new Map<string, RrssPiece[]>();
for (const p of plan?.pieces || []) { for (const p of filteredPieces) {
const k = p.date.slice(0, 10); const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p); (m.get(k) || m.set(k, []).get(k)!).push(p);
} }
return m; return m;
}, [plan]); }, [filteredPieces]);
const grid = useMemo(() => { const grid = useMemo(() => {
if (!month) return { pad: 0, days: 0 }; if (!month) return { pad: 0, days: 0 };
@@ -213,7 +263,21 @@ export default function CalendarCreator({
setBusy(true); setBusy(true);
setErr(null); setErr(null);
try { try {
const opts = normalizeGenOptions(genOptions); let opts = normalizeGenOptions(genOptions);
// Pestaña LinkedIn: prioriza LI (default 8) y no regenera IG/FB en este preview
if (creatorTab === 'linkedin') {
if (opts.linkedinPosts <= 0) {
opts = { ...opts, linkedinPosts: 8 };
setGenOptions((o) => ({ ...o, linkedinPosts: 8 }));
}
opts = {
...opts,
posts: 0,
stories: 0,
reels: 0,
carruseles: 0,
};
}
const keepIds = const keepIds =
mode === 'regen_selected' && plan?.pieces?.length mode === 'regen_selected' && plan?.pieces?.length
? new Set( ? new Set(
@@ -264,6 +328,26 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
setSource(`${j.source || 'heuristic'}+regen`); setSource(`${j.source || 'heuristic'}+regen`);
setRegenIds(new Set()); setRegenIds(new Set());
} }
} else if (creatorTab === 'linkedin' && plan?.pieces?.length) {
// Conserva IG/FB existentes; sustituye solo LinkedIn
const keepIg = (plan.pieces || []).filter(
(p) => !/linkedin/i.test(p.platform || '')
);
const newLi = (j.plan?.pieces || []).filter((p: RrssPiece) =>
/linkedin/i.test(p.platform || '')
);
const merged: RrssMonthPlan = {
...(j.plan || plan),
pieces: [...keepIg, ...newLi].sort((a, b) =>
a.date.localeCompare(b.date)
),
};
setPlan(merged);
setRationale(
`${j.rationale || ''}\n\nLinkedIn regenerado (${newLi.length}); IG/FB conservados (${keepIg.length}).`
);
setSource(`${j.source || 'heuristic'}+li`);
setRegenIds(new Set());
} else { } else {
setPlan(j.plan); setPlan(j.plan);
setRationale(j.rationale || ''); setRationale(j.rationale || '');
@@ -440,7 +524,11 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
} }
} }
const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : []; const dayPieces = useMemo(() => {
if (!dayZoom) return [] as RrssPiece[];
// En el drawer del día mostramos todas las redes; el tab solo filtra el grid
return (plan?.pieces || []).filter((p) => p.date.slice(0, 10) === dayZoom);
}, [dayZoom, plan]);
return ( return (
<> <>
@@ -523,6 +611,52 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
) : ( ) : (
<> <>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-xl border border-gray-200 bg-gray-50 p-0.5">
<button
type="button"
onClick={() => setCreatorTab('ig-fb')}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
creatorTab === 'ig-fb'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500'
}`}
>
<Instagram className="h-3.5 w-3.5" />
IG / FB
<span className="text-[10px] text-gray-400">
{
(plan?.pieces || []).filter(
(p) => !/linkedin/i.test(p.platform || '')
).length
}
</span>
</button>
<button
type="button"
onClick={() => setCreatorTab('linkedin')}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
creatorTab === 'linkedin'
? 'bg-[#7C66B8] text-white shadow-sm'
: 'text-gray-500'
}`}
>
<Linkedin className="h-3.5 w-3.5" />
LinkedIn
<span
className={`text-[10px] ${
creatorTab === 'linkedin'
? 'text-white/70'
: 'text-gray-400'
}`}
>
{
(plan?.pieces || []).filter((p) =>
/linkedin/i.test(p.platform || '')
).length
}
</span>
</button>
</div>
<button <button
type="button" type="button"
onClick={() => setStep('pick')} onClick={() => setStep('pick')}
@@ -601,6 +735,7 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
Ocultar Ocultar
</button> </button>
</div> </div>
{creatorTab === 'ig-fb' ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{( {(
[ [
@@ -628,6 +763,124 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
</label> </label>
))} ))}
</div> </div>
) : (
<div className="space-y-3 rounded-xl border border-[#7C66B8]/30 bg-white/80 p-3">
<p className="text-[11px] font-bold uppercase tracking-wider text-[#7C66B8]">
Generación LinkedIn B2B
</p>
<label className="text-[11px] text-gray-700 block">
Nº de posts LinkedIn
<input
type="number"
min={0}
max={40}
value={genOptions.linkedinPosts}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinPosts: Number(e.target.value) || 0,
}))
}
className="mt-0.5 w-full max-w-[120px] rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm"
/>
</label>
<div>
<p className="text-[11px] font-semibold text-gray-700 mb-1">
Fuente del copy
</p>
<div className="flex flex-wrap gap-1.5">
{(
[
['from_strategy', 'Estrategia (copie listo)'],
['ideas', 'Ideas nuevas'],
['mixed', 'Mixto'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() =>
setGenOptions((o) => ({
...o,
linkedinMode: mode,
}))
}
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold border ${
genOptions.linkedinMode === mode
? 'bg-[#7C66B8] text-white border-[#7C66B8]'
: 'bg-white text-gray-600 border-gray-200'
}`}
>
{label}
</button>
))}
</div>
</div>
<label className="block text-[11px] text-gray-700">
URL estrategia LinkedIn
<input
type="url"
value={genOptions.linkedinStrategyUrl || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinStrategyUrl: e.target.value,
}))
}
placeholder="https://4ventures.es/bps/estrategia-linkedin-bps.html"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-mono"
/>
</label>
{liStrategyMeta?.url ? (
<p className="text-[11px] text-gray-600">
Detectada:{' '}
<a
href={liStrategyMeta.url}
target="_blank"
rel="noopener noreferrer"
className="text-[#7C66B8] underline inline-flex items-center gap-0.5"
>
{liStrategyMeta.url.replace(/^https?:\/\//, '').slice(0, 56)}
<ExternalLink className="h-3 w-3" />
</a>
{liStrategyMeta.posts_count
? ` · ${liStrategyMeta.posts_count} posts en estrategia`
: ''}
</p>
) : (
<p className="text-[11px] text-amber-800">
Sin estrategia en brandbook: pega la URL o guárdala en Zona de marca → LinkedIn B2B.
</p>
)}
{liStrategyMeta?.themes?.length ? (
<p className="text-[11px] text-gray-500">
Temas: {liStrategyMeta.themes.slice(0, 5).join(' · ')}
</p>
) : null}
<label className="block text-[11px] text-gray-700">
Instrucciones LinkedIn
<textarea
value={genOptions.linkedinInstructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinInstructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: 8 posts desde la estrategia; tono C-level; CTA a demo…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
<p className="text-[10px] text-gray-500 leading-relaxed">
Con URL: revisa qué posts de la estrategia ya están publicados o
agendados y programa los pendientes (copie listo). Sin URL o
backlog agotado: crea ideas nuevas siguiendo temas/CTAs del
brandbook y el histórico LinkedIn. Publicación API cuando configures{' '}
<code className="bg-gray-100 px-1 rounded">LINKEDIN_ACCESS_TOKEN</code>.
</p>
</div>
)}
<div> <div>
<p className="text-[11px] font-semibold text-gray-700 mb-1"> <p className="text-[11px] font-semibold text-gray-700 mb-1">
Días de la semana Días de la semana
@@ -866,11 +1119,22 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
<> <>
<button <button
type="button" type="button"
onClick={() => setForm(emptyForm(dayZoom))} onClick={() => {
const f = emptyForm(dayZoom);
if (creatorTab === 'linkedin') {
f.platform = 'linkedin';
f.format = 'Post';
f.pillar = 'b2b';
f.cta = 'Comenta tu caso · DM abierto';
}
setForm(f);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-3 py-2.5 text-sm font-semibold text-white hover:bg-brand-700" className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-3 py-2.5 text-sm font-semibold text-white hover:bg-brand-700"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Añadir publicación {creatorTab === 'linkedin'
? 'Añadir post LinkedIn'
: 'Añadir publicación'}
</button> </button>
{dayPieces.length === 0 ? ( {dayPieces.length === 0 ? (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
@@ -906,9 +1170,40 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
style={{ background: colorFor(p) }} style={{ background: colorFor(p) }}
/> />
</div> </div>
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap"> <p className="text-xs text-gray-600 line-clamp-6 whitespace-pre-wrap">
{p.body || p.hook} {p.body || p.hook}
</p> </p>
{/linkedin/i.test(p.platform || '') ? (
<div className="rounded-lg border border-[#7C66B8]/25 bg-[#7C66B8]/5 p-2 space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#7C66B8] inline-flex items-center gap-1">
<Linkedin className="h-3 w-3" /> Estrategia LinkedIn
</p>
{p.strategy_angle ? (
<p className="text-[11px] text-gray-700">
<span className="font-semibold">Ángulo:</span>{' '}
{p.strategy_angle}
</p>
) : null}
{(p.strategy_url || p.meta_line) && (
<a
href={
(p.meta_line?.includes('http')
? p.meta_line
: p.strategy_url) || '#'
}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-[#7C66B8] underline inline-flex items-center gap-1"
>
Ver en página de estrategia
{p.strategy_post_id
? ` (#${p.strategy_post_id})`
: ''}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
) : null}
{p.interactive_story && {p.interactive_story &&
p.interactive_story !== 'none' ? ( p.interactive_story !== 'none' ? (
<p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1"> <p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">

View File

@@ -10,6 +10,8 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Filter, Filter,
Undo2,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader, Surface, EmptyState } from '@/components/ui'; import { PageHeader, Surface, EmptyState } from '@/components/ui';
@@ -52,6 +54,27 @@ function entityLabel(t: string): string {
return ENTITY_LABELS[t] || t; return ENTITY_LABELS[t] || t;
} }
function canUndo(row: Row): boolean {
if (row.action === 'undo') return false;
const d = row.detail || {};
if (d.undone === true) return false;
if (row.entity_type !== 'brandbook') return false;
if (d.revertable === true) return true;
if (
d.revert_patch &&
typeof d.revert_patch === 'object' &&
Object.keys(d.revert_patch as object).length > 0
) {
return true;
}
// Legacy: updates con lista de cambios
return (
row.action === 'update' &&
Array.isArray(d.changes) &&
(d.changes as unknown[]).length > 0
);
}
function DetailBlock({ detail }: { detail: Record<string, unknown> }) { function DetailBlock({ detail }: { detail: Record<string, unknown> }) {
const changes = Array.isArray(detail.changes) const changes = Array.isArray(detail.changes)
? (detail.changes as { field?: string; from?: unknown; to?: unknown }[]) ? (detail.changes as { field?: string; from?: unknown; to?: unknown }[])
@@ -141,6 +164,8 @@ export default function ChangeHistoryView({
const [entityType, setEntityType] = useState(''); const [entityType, setEntityType] = useState('');
const [portfolioWide, setPortfolioWide] = useState(false); const [portfolioWide, setPortfolioWide] = useState(false);
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [undoingId, setUndoingId] = useState<string | null>(null);
const [undoMsg, setUndoMsg] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -173,12 +198,42 @@ export default function ChangeHistoryView({
void load(); void load();
}, [load]); }, [load]);
async function undoChange(row: Row) {
if (!canUndo(row) || undoingId) return;
const ok = window.confirm(
`¿Deshacer este cambio?\n\n«${row.summary}»\n\nSe restaurarán los valores anteriores en Zona de marca y el deshacer quedará registrado en el historial.`
);
if (!ok) return;
setUndoingId(row.id);
setUndoMsg(null);
try {
const r = await fetch('/api/change-history/undo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id }),
});
const j = await r.json();
if (!r.ok || j.ok === false) {
setUndoMsg(j.error || 'No se pudo deshacer');
return;
}
setUndoMsg(
`Deshecho: ${(j.restored_fields || []).length} campo(s) restaurados. El registro original sigue en el historial.`
);
await load();
} catch (e) {
setUndoMsg(e instanceof Error ? e.message : 'Error de red');
} finally {
setUndoingId(null);
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
eyebrow="Auditoría" eyebrow="Auditoría"
title="Historial de cambios" title="Historial de cambios"
description="Usuario, fecha, cambio y detalle de lo que se modifica en el Centro de Mando." description="Usuario, fecha, cambio y detalle. Puedes deshacer cambios de Zona de marca; el deshacer también queda registrado."
actions={ actions={
<button <button
type="button" type="button"
@@ -233,6 +288,12 @@ export default function ChangeHistoryView({
</Surface> </Surface>
) : null} ) : null}
{undoMsg ? (
<Surface className="border-sky-200 bg-sky-50">
<p className="text-sm text-sky-900">{undoMsg}</p>
</Surface>
) : null}
<Surface padded={false}> <Surface padded={false}>
{loading && !rows.length ? ( {loading && !rows.length ? (
<div className="p-8 text-sm text-gray-500 flex items-center gap-2"> <div className="p-8 text-sm text-gray-500 flex items-center gap-2">
@@ -292,19 +353,58 @@ export default function ChangeHistoryView({
</div> </div>
<p className="text-sm font-medium text-gray-900"> <p className="text-sm font-medium text-gray-900">
{row.summary} {row.summary}
{row.detail?.undone === true ? (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
Deshecho
</span>
) : null}
{row.action === 'undo' ? (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-violet-700 bg-violet-50 border border-violet-200 rounded-full px-2 py-0.5">
Undo
</span>
) : null}
</p> </p>
</div> </div>
<History className="w-4 h-4 text-gray-300 shrink-0 mt-1" /> <History className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</div> </div>
</button> </button>
{open ? ( {open ? (
<div className="px-4 md:px-5 pb-4 pl-11 md:pl-12"> <div className="px-4 md:px-5 pb-4 pl-11 md:pl-12 space-y-3">
<DetailBlock detail={row.detail || {}} /> <DetailBlock detail={row.detail || {}} />
{row.source ? ( {row.source ? (
<p className="mt-2 text-[11px] text-gray-400"> <p className="text-[11px] text-gray-400">
Origen: {row.source} Origen: {row.source}
</p> </p>
) : null} ) : null}
{canUndo(row) ? (
<button
type="button"
disabled={undoingId === row.id}
onClick={(e) => {
e.stopPropagation();
void undoChange(row);
}}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100 disabled:opacity-60"
>
{undoingId === row.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Undo2 className="w-3.5 h-3.5" />
)}
Deshacer este cambio
</button>
) : row.detail?.undone === true ? (
<p className="text-[11px] text-amber-800">
Ya deshecho
{typeof row.detail.undone_by === 'string'
? ` por ${row.detail.undone_by}`
: ''}
{typeof row.detail.undone_at === 'string'
? ` · ${fmtDate(row.detail.undone_at)}`
: ''}
. El registro se conserva en el historial.
</p>
) : null}
</div> </div>
) : null} ) : null}
</li> </li>

View File

@@ -1713,6 +1713,9 @@ function DetailModal({
const [chFb, setChFb] = useState( const [chFb, setChFb] = useState(
(item.channels || ['instagram', 'facebook']).includes('facebook') (item.channels || ['instagram', 'facebook']).includes('facebook')
); );
const [chLi, setChLi] = useState(
(item.channels || []).includes('linkedin')
);
const [editTitle, setEditTitle] = useState(item.title); const [editTitle, setEditTitle] = useState(item.title);
const [editCaption, setEditCaption] = useState(item.caption || ''); const [editCaption, setEditCaption] = useState(item.caption || '');
const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id)); const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id));
@@ -1735,6 +1738,7 @@ function DetailModal({
const ch = item.channels || ['instagram', 'facebook']; const ch = item.channels || ['instagram', 'facebook'];
setChIg(ch.includes('instagram')); setChIg(ch.includes('instagram'));
setChFb(ch.includes('facebook')); setChFb(ch.includes('facebook'));
setChLi(ch.includes('linkedin'));
setEditTitle(item.title); setEditTitle(item.title);
setEditCaption(item.caption || ''); setEditCaption(item.caption || '');
setSyncCal(Boolean(item.calendar_piece_id)); setSyncCal(Boolean(item.calendar_piece_id));
@@ -1764,6 +1768,7 @@ function DetailModal({
const channels: PublishChannel[] = []; const channels: PublishChannel[] = [];
if (chIg) channels.push('instagram'); if (chIg) channels.push('instagram');
if (chFb) channels.push('facebook'); if (chFb) channels.push('facebook');
if (chLi) channels.push('linkedin');
const igIso = schedIg ? new Date(schedIg).toISOString() : null; const igIso = schedIg ? new Date(schedIg).toISOString() : null;
const fbIso = schedFb ? new Date(schedFb).toISOString() : null; const fbIso = schedFb ? new Date(schedFb).toISOString() : null;
const primary = const primary =
@@ -2062,6 +2067,17 @@ function DetailModal({
/> />
Facebook Facebook
</label> </label>
<label className="inline-flex items-center gap-1.5">
<input
type="checkbox"
checked={chLi}
onChange={(e) => setChLi(e.target.checked)}
/>
LinkedIn
<span className="text-[10px] text-gray-500">
(API key pendiente)
</span>
</label>
</div> </div>
{chIg && chFb ? ( {chIg && chFb ? (
<label className="flex items-start gap-2 text-[11px] text-gray-800"> <label className="flex items-start gap-2 text-[11px] text-gray-800">

View File

@@ -189,13 +189,17 @@ export default function DashboardShell({
const visibleNav = NAV_ITEMS.filter((item) => moduleOn(item.moduleId)); const visibleNav = NAV_ITEMS.filter((item) => moduleOn(item.moduleId));
const showClientBrand = Boolean(clientLogoUrl); // Agencia siempre mantiene identidad 4Ventures (aunque esté "viendo" un cliente).
const title = showClientBrand ? brandLabel || company : '4VENTURES'; const title = isAgency
const subtitle = showClientBrand ? '4VENTURES'
? isAgency : clientLogoUrl
? 'Vista cliente' ? brandLabel || company
: 'Portal cliente' : '4VENTURES';
: 'Centro de Mando'; const subtitle = isAgency
? viewName
? `Agencia · viendo ${viewName}`
: 'Centro de Mando'
: 'Portal cliente';
const sidebar = ( const sidebar = (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -317,8 +321,15 @@ export default function DashboardShell({
<span className="text-[10px] text-gray-500 capitalize truncate"> <span className="text-[10px] text-gray-500 capitalize truncate">
{company} {company}
</span> </span>
<span className="text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-brand-50 text-brand-600 shrink-0"> <span
{role} className="text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-brand-50 text-brand-600 shrink-0"
title={role}
>
{role === 'admin'
? 'Agencia'
: role === 'team'
? 'Equipo'
: 'Cliente'}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -59,6 +59,12 @@ export type RrssPiece = {
pipeline_id?: string; pipeline_id?: string;
cover_path?: string | null; cover_path?: string | null;
slide_paths?: string[]; slide_paths?: string[];
/** URL de la estrategia LinkedIn de la que sale / se inspira la pieza. */
strategy_url?: string;
/** Ancla en la página (ej. post-12). */
strategy_post_id?: string;
/** Ángulo / briefing de la estrategia. */
strategy_angle?: string;
}; };
export type RrssMonthPlan = { export type RrssMonthPlan = {
@@ -853,6 +859,43 @@ export default function RrssMonthView({
{selected.hashtags && ( {selected.hashtags && (
<p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p> <p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p>
)} )}
{/linkedin/i.test(selected.platform || '') ? (
<div className="rounded-xl border border-[#7C66B8]/30 bg-[#7C66B8]/5 p-3 space-y-2">
<p className="text-xs font-bold uppercase tracking-wider text-[#7C66B8] inline-flex items-center gap-1.5">
<Linkedin className="w-3.5 h-3.5" /> Contenido estrategia LinkedIn
</p>
{selected.strategy_angle ? (
<p className="text-xs text-gray-800">
<span className="font-semibold">Ángulo:</span>{' '}
{selected.strategy_angle}
</p>
) : null}
{(selected.strategy_url ||
(selected.meta_line && selected.meta_line.includes('http'))) && (
<a
href={
selected.meta_line?.includes('http')
? selected.meta_line.trim()
: `${selected.strategy_url || ''}${
selected.strategy_post_id
? `#${selected.strategy_post_id}`
: ''
}`
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-[#7C66B8] px-3 py-1.5 text-xs font-bold text-white hover:opacity-95"
>
Abrir en estrategia 4Ventures{' '}
<ExternalLink className="w-3 h-3" />
</a>
)}
<p className="text-[11px] text-gray-500">
Copy listo para publicar. La publicación automática vía API
LinkedIn se activará cuando configures la API key.
</p>
</div>
) : null}
{canCollaborate && ( {canCollaborate && (
<div className="rounded-xl border border-gray-200 p-3 space-y-2"> <div className="rounded-xl border border-gray-200 p-3 space-y-2">

View File

@@ -35,22 +35,75 @@ export function isAgencyRole(role: string | undefined | null): boolean {
return role === 'admin' || role === 'team'; return role === 'admin' || role === 'team';
} }
/** Operadores 4Ventures conocidos (fallback si el JWT pierde app_metadata.role). */
export const AGENCY_OPERATOR_EMAILS = new Set(
[
'marketing@4ventures.es',
'marcos@4ventures.es',
'belen@4ventures.es',
'rebecca@4ventures.es',
'victor@4ventures.es',
'agencia@4ventures.es',
'ops@4ventures.es',
].map((e) => e.toLowerCase())
);
/** /**
* Authorization role. Privileged roles (admin/team) ONLY from app_metadata * Emails @4ventures.es son agencia salvo que app_metadata.role === 'client'
* so clients cannot self-elevate via user_metadata.updateUser. * (cliente real con correo corporativo).
*/
export function isAgencyOperatorEmail(email: string | null | undefined): boolean {
const e = String(email || '')
.trim()
.toLowerCase();
if (!e) return false;
if (AGENCY_OPERATOR_EMAILS.has(e)) return true;
return e.endsWith('@4ventures.es');
}
/** Etiqueta UI del rol (evita confusión "admin" ≈ admin@…). */
export function roleLabel(role: string | undefined | null): string {
if (role === 'admin') return 'Agencia';
if (role === 'team') return 'Equipo';
return 'Cliente';
}
/**
* Authorization role. Privileged roles prefer app_metadata so clients cannot
* self-elevate via user_metadata.updateUser. Fallback:
* - operadores @4ventures.es → team
* - user_metadata.role=client → client
*/ */
export function userRole(user: { export function userRole(user: {
email?: string | null;
app_metadata?: Record<string, unknown>; app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown>;
} | null): string { } | null): string {
const app = (user?.app_metadata?.role as string | undefined)?.trim(); const app = (user?.app_metadata?.role as string | undefined)?.trim();
if (app === 'admin' || app === 'team' || app === 'client') return app; if (app === 'admin' || app === 'team') return app;
// Legacy clients may only have user_metadata.role=client — never admin/team. // Cliente explícito en app_metadata gana (aunque el email sea @4ventures.es).
if (app === 'client') return 'client';
// Fallback operadores 4V (JWT incompleto / provision antiguo).
if (isAgencyOperatorEmail(user?.email)) return 'team';
// Legacy clients may only have user_metadata.role=client — never elevate from um.
const um = (user?.user_metadata?.role as string | undefined)?.trim(); const um = (user?.user_metadata?.role as string | undefined)?.trim();
if (um === 'client') return 'client'; if (um === 'client') return 'client';
return 'client'; return 'client';
} }
/** Clear agency "zoomed into client" cookies (logout / leave client view). */
export function clearedViewTenantCookieOpts(secure: boolean) {
return {
path: '/',
maxAge: 0,
sameSite: 'lax' as const,
secure,
httpOnly: false,
};
}
export function homeTenantId(user: { export function homeTenantId(user: {
app_metadata?: Record<string, unknown>; app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown>;

View File

@@ -6,7 +6,7 @@
/** Drive folder names created (or reserved) by the app under the client root. */ /** Drive folder names created (or reserved) by the app under the client root. */
const APP_DRIVE_FOLDER_RES = const APP_DRIVE_FOLDER_RES =
/^(Pipeline-creatividades_|Calendarios?[_\s-]?|Creatividades[_\s-]?|Ads[_\s-]?creatividades|Centro[_\s-]?de[_\s-]?Mando|4Ventures[_\s-]?Portal|Portal[_\s-]?app)/i; /^(Creatividades-Ads_|Creatividades-RRSS_|Pipeline-creatividades_|Calendarios?[_\s-]?|Creatividades[_\s-]?|Ads[_\s-]?creatividades|Centro[_\s-]?de[_\s-]?Mando|4Ventures[_\s-]?Portal|Portal[_\s-]?app)/i;
/** Portal asset_files.folder prefixes written by the app (not free-form agency dumps). */ /** Portal asset_files.folder prefixes written by the app (not free-form agency dumps). */
const APP_PORTAL_FOLDER_PREFIXES = [ const APP_PORTAL_FOLDER_PREFIXES = [
@@ -16,6 +16,8 @@ const APP_PORTAL_FOLDER_PREFIXES = [
'calendario', 'calendario',
'pipeline', 'pipeline',
'pipeline-creatividades', 'pipeline-creatividades',
'creatividades-ads',
'creatividades-rrss',
'ads', 'ads',
'meta', 'meta',
'centro-de-mando', 'centro-de-mando',

View File

@@ -2011,8 +2011,21 @@ export function brandbookToPrompt(book: Brandbook): string {
`Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`, `Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`,
(book.preferred_angles?.length || 0) > 0 && (book.preferred_angles?.length || 0) > 0 &&
`Ángulos campaña: ${(book.preferred_angles || []).slice(0, 8).join(' · ')}`, `Ángulos campaña: ${(book.preferred_angles || []).slice(0, 8).join(' · ')}`,
book.linkedin_strategy_url &&
`URL estrategia LinkedIn: ${book.linkedin_strategy_url}`,
book.linkedin_themes.length && book.linkedin_themes.length &&
`LinkedIn: ${book.linkedin_themes.join(', ')}`, `Temas LinkedIn: ${book.linkedin_themes.join(', ')}`,
book.linkedin_formats.length &&
`Formatos LinkedIn: ${book.linkedin_formats.join(', ')}`,
book.thought_leadership.length &&
`Thought leadership: ${book.thought_leadership.join(', ')}`,
book.buyer_roles.length &&
`Buyer roles LI: ${book.buyer_roles.join(', ')}`,
book.case_study_angles.length &&
`Ángulos caso LI: ${book.case_study_angles.join(', ')}`,
book.linkedin_cta.length &&
`CTAs LinkedIn: ${book.linkedin_cta.join(', ')}`,
book.abm_notes && `ABM LinkedIn: ${book.abm_notes.slice(0, 600)}`,
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`, book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
book.claims_forbidden.length && book.claims_forbidden.length &&
`Prohibido: ${book.claims_forbidden.join('; ')}`, `Prohibido: ${book.claims_forbidden.join('; ')}`,

View File

@@ -3,6 +3,8 @@
*/ */
export type WeekdayKey = 1 | 2 | 3 | 4 | 5 | 6 | 0; // Lun=1 … Dom=0 (JS getUTCDay) export type WeekdayKey = 1 | 2 | 3 | 4 | 5 | 6 | 0; // Lun=1 … Dom=0 (JS getUTCDay)
export type LinkedInGenMode = 'from_strategy' | 'ideas' | 'mixed';
export type CalendarGenOptions = { export type CalendarGenOptions = {
/** Nº de posts feed (Post estático). */ /** Nº de posts feed (Post estático). */
posts: number; posts: number;
@@ -12,6 +14,19 @@ export type CalendarGenOptions = {
reels: number; reels: number;
/** Nº de Carruseles. */ /** Nº de Carruseles. */
carruseles: number; carruseles: number;
/**
* Nº de piezas LinkedIn (posts B2B listos para publicar).
* 0 = no generar LinkedIn en este preview.
*/
linkedinPosts: number;
/**
* from_strategy: copie de la URL de estrategia.
* ideas: copy nuevo al tono de la estrategia / brandbook.
* mixed: mezcla ambos.
*/
linkedinMode: LinkedInGenMode;
/** Override de URL de estrategia LinkedIn (si vacío → brandbook / slug conocido). */
linkedinStrategyUrl?: string;
/** /**
* Días de la semana permitidos (JS: 0=Dom … 6=Sáb). * Días de la semana permitidos (JS: 0=Dom … 6=Sáb).
* Por defecto Lun–Vie. * Por defecto Lun–Vie.
@@ -29,6 +44,8 @@ export type CalendarGenOptions = {
* (modo regen_selected). * (modo regen_selected).
*/ */
regenInstructions?: string; regenInstructions?: string;
/** Instrucciones solo para piezas LinkedIn. */
linkedinInstructions?: string;
}; };
export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = { export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
@@ -36,10 +53,14 @@ export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
stories: 8, stories: 8,
reels: 6, reels: 6,
carruseles: 4, carruseles: 4,
linkedinPosts: 0,
linkedinMode: 'from_strategy',
linkedinStrategyUrl: '',
weekdays: [1, 2, 3, 4, 5], weekdays: [1, 2, 3, 4, 5],
allowSameDayMix: true, allowSameDayMix: true,
instructions: '', instructions: '',
regenInstructions: '', regenInstructions: '',
linkedinInstructions: '',
}; };
export function normalizeGenOptions( export function normalizeGenOptions(
@@ -51,15 +72,26 @@ export function normalizeGenOptions(
const weekdays = Array.isArray(base.weekdays) const weekdays = Array.isArray(base.weekdays)
? [...new Set(base.weekdays.map((d) => Number(d)).filter((d) => d >= 0 && d <= 6))] ? [...new Set(base.weekdays.map((d) => Number(d)).filter((d) => d >= 0 && d <= 6))]
: DEFAULT_GEN_OPTIONS.weekdays; : DEFAULT_GEN_OPTIONS.weekdays;
const modeRaw = String(base.linkedinMode || 'from_strategy').toLowerCase();
const linkedinMode: LinkedInGenMode =
modeRaw === 'ideas' || modeRaw === 'mixed' ? modeRaw : 'from_strategy';
return { return {
posts: clamp(base.posts), posts: clamp(base.posts),
stories: clamp(base.stories), stories: clamp(base.stories),
reels: clamp(base.reels), reels: clamp(base.reels),
carruseles: clamp(base.carruseles), carruseles: clamp(base.carruseles),
linkedinPosts: clamp(base.linkedinPosts),
linkedinMode,
linkedinStrategyUrl: String(base.linkedinStrategyUrl || '')
.trim()
.slice(0, 500),
weekdays: weekdays.length ? weekdays : DEFAULT_GEN_OPTIONS.weekdays, weekdays: weekdays.length ? weekdays : DEFAULT_GEN_OPTIONS.weekdays,
allowSameDayMix: base.allowSameDayMix !== false, allowSameDayMix: base.allowSameDayMix !== false,
instructions: String(base.instructions || '').trim().slice(0, 4000), instructions: String(base.instructions || '').trim().slice(0, 4000),
regenInstructions: String(base.regenInstructions || '').trim().slice(0, 4000), regenInstructions: String(base.regenInstructions || '').trim().slice(0, 4000),
linkedinInstructions: String(base.linkedinInstructions || '')
.trim()
.slice(0, 4000),
}; };
} }

View File

@@ -1,5 +1,10 @@
/** Shared brand DNA + title helpers for calendar strategy / generate. */ /** Shared brand DNA + title helpers for calendar strategy / generate. */
import {
brandSiblingNote,
resolveBrandSlug,
} from '@/lib/tenant-brand-aliases';
export type TenantBrandBrief = { export type TenantBrandBrief = {
slug: string; slug: string;
label: string; label: string;
@@ -11,19 +16,67 @@ export type TenantBrandBrief = {
}; };
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = { const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
'talento-y-deporte': { /** TYD — Talento y Deporte (deportivo; hermano de TSP) */
vertical: 'Educación deportiva / becas USA', tyd: {
tone: 'Inspirador, cercano, ambicioso; historias de alumnos', vertical: 'Becas deportivas USA / educación deportiva',
tone: 'Inspirador, cercano, ambicioso; historias de atletas-alumnos',
pillars: [ pillars: [
'nuevo-alumno', 'nuevo-alumno',
'beca-academica',
'beca-deportiva', 'beca-deportiva',
'campus', 'campus',
'objeciones', 'objeciones',
'tips-ingles', 'tips-ingles',
'vida-deportiva',
], ],
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 }, formats: { post: 0.32, reel: 0.28, stories: 0.28, carrusel: 0.12 },
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.', notes:
'TYD · Talento y Deporte (marca deportiva, separada de TSP Scholar).',
},
/** TSP — Talento Scholar Program (académico; hermano de TYD) */
tsp: {
vertical: 'Becas académicas USA / Scholar',
tone: 'Orientador, serio-cercano, aspiracional académico',
pillars: [
'beca-academica',
'admisiones',
'campus',
'financiacion',
'objeciones',
'casos-exito',
],
formats: { post: 0.35, reel: 0.22, stories: 0.28, carrusel: 0.15 },
notes:
'TSP · Talento Scholar Program (marca académica, separada de TYD Deporte).',
},
/** DTATF — De Tú a Tú Formación (hermano de ECP) */
dtatf: {
vertical: 'Formación profesional / empleabilidad',
tone: 'Cercano, práctico, motivador; transformación laboral',
pillars: [
'cursos',
'empleabilidad',
'testimonios',
'convocatorias',
'tips-formacion',
],
formats: { post: 0.3, reel: 0.28, stories: 0.3, carrusel: 0.12 },
notes:
'DTATF · De Tú a Tú Formación (marca distinta de ECP).',
},
/** ECP — Escuela de Cuidadores Profesionales (hermano de DTATF) */
ecp: {
vertical: 'Escuela de cuidadores profesionales',
tone: 'Humano, profesional, cercano; vocación de cuidado',
pillars: [
'formacion-cuidadores',
'empleabilidad',
'dia-a-dia',
'testimonios',
'convocatorias',
],
formats: { post: 0.28, reel: 0.3, stories: 0.3, carrusel: 0.12 },
notes:
'ECP · Escuela de Cuidadores Profesionales (marca distinta de DTATF). IG @escuelaecp.',
}, },
'halal-safety': { 'halal-safety': {
vertical: 'Certificación Halal / exportación', vertical: 'Certificación Halal / exportación',
@@ -55,6 +108,21 @@ const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
notes: notes:
'ADN BPS: FALSE FRIEND, SLANG, Same word, canción, TV shows, matriculación Madrid/Málaga, Trinity/IELTS — crear ángulos nuevos, no repetir títulos.', 'ADN BPS: FALSE FRIEND, SLANG, Same word, canción, TV shows, matriculación Madrid/Málaga, Trinity/IELTS — crear ángulos nuevos, no repetir títulos.',
}, },
'agua-miia': {
vertical: 'Agua mineral / FMCG',
tone: 'Fresco, natural, premium accesible; bienestar sin postureo',
pillars: [
'producto',
'estilo-de-vida',
'origen',
'sostenibilidad',
'recetas-y-usos',
'comunidad',
],
formats: { post: 0.28, reel: 0.32, stories: 0.28, carrusel: 0.12 },
notes:
'ADN Agua Miia: hidratación, origen del agua, rituales diarios, visual limpio y producto hero.',
},
'blue-parrot-school': { 'blue-parrot-school': {
vertical: 'Escuela de idiomas', vertical: 'Escuela de idiomas',
tone: 'Educativo, pop culture, series recurrentes', tone: 'Educativo, pop culture, series recurrentes',
@@ -83,17 +151,66 @@ const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
formats: { post: 0.35, reel: 0.25, stories: 0.2, carrusel: 0.2 }, formats: { post: 0.35, reel: 0.25, stories: 0.2, carrusel: 0.2 },
notes: 'Contenido de agencia.', notes: 'Contenido de agencia.',
}, },
'delgado-abogados': {
vertical: 'Despacho de abogados',
tone: 'Serio, cercano, autoridad legal sin frialdad',
pillars: ['areas-practica', 'casos', 'educativo-legal', 'confianza'],
formats: { post: 0.4, reel: 0.2, stories: 0.2, carrusel: 0.2 },
notes: 'Delgado Abogados — claims legales prudentes, sin garantías de resultado.',
},
'equinsa-parking': {
vertical: 'Parking / movilidad',
tone: 'Práctico, claro, orientado a conveniencia',
pillars: ['producto', 'ubicaciones', 'tips-parking', 'b2b'],
formats: { post: 0.35, reel: 0.25, stories: 0.25, carrusel: 0.15 },
notes: 'Equinsa Parking — plazas, app, B2B y tipología de aparcamiento.',
},
'fbm-abogados': {
vertical: 'Despacho de abogados / herencias',
tone: 'Empático, claro, autoridad en derecho sucesorio',
pillars: ['herencias', 'educativo-legal', 'casos', 'confianza'],
formats: { post: 0.4, reel: 0.15, stories: 0.25, carrusel: 0.2 },
notes: 'FBM Abogados — herencias y sucesiones; tono humano + rigor.',
},
'la-freseria': {
vertical: 'Fresa / agroalimentación / retail',
tone: 'Fresco, apetitoso, cercano, estacional',
pillars: ['producto', 'origen', 'recetas', 'temporada', 'tienda'],
formats: { post: 0.28, reel: 0.32, stories: 0.28, carrusel: 0.12 },
notes: 'La Fresería — producto hero, temporada y consumo.',
},
'virginia-molet': {
vertical: 'Marca personal / profesional',
tone: 'Cercano, aspiracional, auténtico',
pillars: ['marca-personal', 'tips', 'bastidores', 'comunidad'],
formats: { post: 0.3, reel: 0.35, stories: 0.25, carrusel: 0.1 },
notes: 'Virginia Molet — voz personal; ajustar vertical exacta en Zona de marca.',
},
'wine-advisors': {
vertical: 'Vino / asesoría enológica',
tone: 'Experto, cercano, disfrute sin snobismo',
pillars: ['maridaje', 'educativo-vino', 'seleccion', 'experiencias'],
formats: { post: 0.35, reel: 0.25, stories: 0.25, carrusel: 0.15 },
notes: 'Wine Advisors — catas, maridajes y selección; IG wine.advisor.',
},
}; };
export function brandBrief(slug: string, label: string): TenantBrandBrief { export function brandBrief(slug: string, label: string): TenantBrandBrief {
const base = BRANDS[slug] || { const key = resolveBrandSlug(slug);
const base = BRANDS[key] || {
vertical: 'Marca / servicios', vertical: 'Marca / servicios',
tone: 'Profesional y claro', tone: 'Profesional y claro',
pillars: ['educativo', 'producto', 'comunidad', 'promo'], pillars: ['educativo', 'producto', 'comunidad', 'promo'],
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 }, formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
notes: 'Ajustar al ADN del cliente.', notes: 'Ajustar al ADN del cliente.',
}; };
return { slug, label, ...base }; const sib = brandSiblingNote(key);
return {
slug: key,
label,
...base,
notes: sib ? `${base.notes} ${sib}` : base.notes,
};
} }
export function cleanEditorialTitle(raw: string): string { export function cleanEditorialTitle(raw: string): string {

View File

@@ -42,12 +42,17 @@ export type GeneratePreviewInput = {
avoidTitles?: string[]; avoidTitles?: string[];
/** Cantidades, días, mix e instrucciones del operador. */ /** Cantidades, días, mix e instrucciones del operador. */
genOptions?: Partial<CalendarGenOptions> | null; genOptions?: Partial<CalendarGenOptions> | null;
/** Contexto LinkedIn ya cargado (URL + posts parseados + prompt). */
linkedinStrategyUrl?: string;
linkedinPrompt?: string;
linkedinStrategyPosts?: import('@/lib/linkedin-strategy').LinkedInStrategyPost[];
}; };
export type GeneratePreviewResult = { export type GeneratePreviewResult = {
plan: RrssMonthPlan; plan: RrssMonthPlan;
rationale: string; rationale: string;
source: 'anthropic' | 'strategy' | 'heuristic'; source: 'anthropic' | 'strategy' | 'heuristic';
linkedinMeta?: import('@/lib/calendar-strategy').LinkedInScheduleMeta;
}; };
function colorFor(format: string, kind: string, platform: string): string { function colorFor(format: string, kind: string, platform: string): string {
@@ -89,6 +94,10 @@ async function anthropicStrategist(
input.brandbook, input.brandbook,
input.publishedPosts || [] input.publishedPosts || []
); );
if (input.linkedinPrompt) dna.linkedinPrompt = input.linkedinPrompt;
if (input.linkedinStrategyUrl) {
dna.linkedinStrategyUrl = input.linkedinStrategyUrl;
}
const go = normalizeGenOptions(input.genOptions); const go = normalizeGenOptions(input.genOptions);
const trends = monthTrendBeats(input.ym, dna.brand.vertical); const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const prompt = strategistSystemPrompt({ const prompt = strategistSystemPrompt({
@@ -97,14 +106,16 @@ async function anthropicStrategist(
dna, dna,
trends, trends,
avoidTitles: input.avoidTitles || [], avoidTitles: input.avoidTitles || [],
genOptions: go, genOptions: {
...go,
linkedinMode: go.linkedinMode,
linkedinInstructions: go.linkedinInstructions,
},
}); });
const igTotal = go.posts + go.stories + go.reels + go.carruseles;
const minPieces = Math.max( const minPieces = Math.max(
3, 1,
Math.min( Math.min(8, Math.max(igTotal + go.linkedinPosts, go.linkedinPosts || igTotal || 3))
8,
go.posts + go.stories + go.reels + go.carruseles
)
); );
try { try {
@@ -148,13 +159,35 @@ async function anthropicStrategist(
})) }))
.filter((s) => s.text) .filter((s) => s.text)
: undefined; : undefined;
const strategy_url =
String(
(p as { strategy_url?: string }).strategy_url ||
(platform.includes('linkedin')
? input.linkedinStrategyUrl || ''
: '')
).trim() || undefined;
const strategy_post_id =
String(
(p as { strategy_post_id?: string }).strategy_post_id || ''
).trim() || undefined;
const strategy_angle =
String(
(p as { strategy_angle?: string }).strategy_angle || ''
).trim() || undefined;
return { return {
id: newPieceId(date, title), id: newPieceId(date, title),
date, date,
time: String(p.time || '13:00').slice(0, 5), time: String(p.time || '13:00').slice(0, 5),
platform, platform,
format, format,
pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')), pillar: String(
p.pillar ||
(platform.includes('linkedin')
? 'b2b'
: kind === 'story'
? 'stories'
: 'feed')
),
title: title.slice(0, 160), title: title.slice(0, 160),
hook: String(p.hook || title).slice(0, 180), hook: String(p.hook || title).slice(0, 180),
body: String(p.body || title), body: String(p.body || title),
@@ -172,23 +205,48 @@ async function anthropicStrategist(
interactive_story: p.interactive_story interactive_story: p.interactive_story
? String(p.interactive_story).slice(0, 40) ? String(p.interactive_story).slice(0, 40)
: undefined, : undefined,
strategy_url,
strategy_post_id,
strategy_angle,
meta_line:
strategy_url
? `${strategy_url}${strategy_post_id ? `#${strategy_post_id}` : ''}`
: undefined,
} as RrssPiece; } as RrssPiece;
}) })
.filter(Boolean) as RrssPiece[]; .filter(Boolean) as RrssPiece[];
if (pieces.length < minPieces) return fallback; if (pieces.length < minPieces) return fallback;
// LinkedIn: siempre el backlog determinista (publicados vs pendientes).
// Claude puede inventar/repetir; sustituimos piezas LI por las del fallback.
const wantLi = go.linkedinPosts || 0;
if (wantLi > 0) {
const liFromStrategy = (fallback.plan.pieces || []).filter((p) =>
/linkedin/i.test(p.platform || '')
);
const nonLi = pieces.filter(
(p) => !/linkedin/i.test(p.platform || '')
);
pieces.length = 0;
pieces.push(...nonLi, ...liFromStrategy);
}
const plan = emptyMonthPlan(input.ym, input.label); const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`; const liN = pieces.filter((p) => /linkedin/i.test(p.platform || '')).length;
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}${liN ? ' + LinkedIn' : ''}.`;
plan.hero.chips = [ plan.hero.chips = [
`${pieces.length} piezas`, `${pieces.length} piezas`,
`${pieces.filter((p) => /reel/i.test(p.format || '')).length} Reels`, `${pieces.filter((p) => /reel/i.test(p.format || '')).length} Reels`,
liN ? `${liN} LinkedIn` : '',
'IA strategist', 'IA strategist',
]; ].filter(Boolean);
plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date)); plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date));
return { return {
plan, plan,
rationale: parsed.rationale || fallback.rationale, rationale: [parsed.rationale, fallback.rationale]
.filter(Boolean)
.join('\n\n'),
source: 'anthropic', source: 'anthropic',
linkedinMeta: fallback.linkedinMeta,
}; };
} catch { } catch {
return fallback; return fallback;
@@ -205,36 +263,65 @@ export async function generateMonthPreview(
(p) => p.title && !isJunkPreviewTitle(p.title) (p) => p.title && !isJunkPreviewTitle(p.title)
), ),
})) }))
.filter((p) => (p.pieces?.length || 0) >= 3); .filter(
(p) =>
(p.pieces?.length || 0) >= 3 ||
(p.pieces || []).some((x) => /linkedin/i.test(x.platform || ''))
);
// Histórico completo (incl. meses cortos) para no re-agendar posts LI ya usados
const liHistory = input.history.map((plan) => ({
...plan,
pieces: (plan.pieces || []).filter(
(p) => p.title && !isJunkPreviewTitle(p.title)
),
}));
const go = normalizeGenOptions(input.genOptions); const go = normalizeGenOptions(input.genOptions);
const slotOpts: BuildSlotsOpts & { const slotOpts: BuildSlotsOpts & {
instructions?: string; instructions?: string;
regenInstructions?: string; regenInstructions?: string;
linkedinInstructions?: string;
linkedinMode?: string;
} = { } = {
posts: go.posts, posts: go.posts,
stories: go.stories, stories: go.stories,
reels: go.reels, reels: go.reels,
carruseles: go.carruseles, carruseles: go.carruseles,
linkedinPosts: go.linkedinPosts,
weekdays: go.weekdays, weekdays: go.weekdays,
allowSameDayMix: go.allowSameDayMix, allowSameDayMix: go.allowSameDayMix,
instructions: go.instructions, instructions: go.instructions,
regenInstructions: go.regenInstructions, regenInstructions: go.regenInstructions,
linkedinInstructions: go.linkedinInstructions,
linkedinMode: go.linkedinMode,
}; };
const strategist = strategyMonthPreview({ const strategist = strategyMonthPreview({
...input, ...input,
history: cleanHistory, history: cleanHistory.length ? cleanHistory : liHistory,
linkedinHistory: liHistory,
brandbook: input.brandbook, brandbook: input.brandbook,
publishedPosts: input.publishedPosts || [], publishedPosts: input.publishedPosts || [],
avoidTitles: input.avoidTitles,
genOptions: slotOpts, genOptions: slotOpts,
linkedinStrategyPosts: input.linkedinStrategyPosts,
linkedinStrategyUrl: input.linkedinStrategyUrl,
linkedinPrompt: input.linkedinPrompt,
}); });
const fallback: GeneratePreviewResult = { const fallback: GeneratePreviewResult = {
plan: strategist.plan, plan: strategist.plan,
rationale: strategist.rationale, rationale: strategist.rationale,
source: 'strategy', source: 'strategy',
linkedinMeta: strategist.linkedinMeta,
}; };
// Solo LinkedIn (sin IG): no hace falta Claude
const igTotal = go.posts + go.stories + go.reels + go.carruseles;
if (igTotal <= 0 && go.linkedinPosts > 0) {
return fallback;
}
return anthropicStrategist( return anthropicStrategist(
{ {
...input, ...input,

View File

@@ -16,6 +16,13 @@ import {
type Brandbook, type Brandbook,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases'; import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
import {
brandbookLinkedInPromptBlock,
collectLinkedInUsage,
linkedInDatesInMonth,
pickUnpublishedStrategyPosts,
type LinkedInStrategyPost,
} from '@/lib/linkedin-strategy';
/** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */ /** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */
export type PublishedNetworkPost = { export type PublishedNetworkPost = {
@@ -45,6 +52,9 @@ export type ClientDna = {
networkPostsUsed: number; networkPostsUsed: number;
/** Muestras de captiones/títulos publicados (no copiar literal). */ /** Muestras de captiones/títulos publicados (no copiar literal). */
networkSamples: string[]; networkSamples: string[];
/** Bloque de estrategia LinkedIn (URL + posts + brandbook B2B). */
linkedinPrompt?: string;
linkedinStrategyUrl?: string;
}; };
export type TrendBeat = { export type TrendBeat = {
@@ -474,6 +484,8 @@ export function extractClientDna(
calendarMonthsUsed, calendarMonthsUsed,
networkPostsUsed, networkPostsUsed,
networkSamples: networkSamplesFromPosts(publishedPosts), networkSamples: networkSamplesFromPosts(publishedPosts),
linkedinPrompt: brandbookLinkedInPromptBlock(brandbook) || undefined,
linkedinStrategyUrl: brandbook?.linkedin_strategy_url || undefined,
}; };
} }
@@ -488,6 +500,7 @@ export type BuildSlotsOpts = {
stories?: number; stories?: number;
reels?: number; reels?: number;
carruseles?: number; carruseles?: number;
linkedinPosts?: number;
weekdays?: number[]; weekdays?: number[];
/** Si true, se pueden apilar story+post / reel+story el mismo día. */ /** Si true, se pueden apilar story+post / reel+story el mismo día. */
allowSameDayMix?: boolean; allowSameDayMix?: boolean;
@@ -903,8 +916,245 @@ export type StrategyPreviewResult = {
source: 'strategy'; source: 'strategy';
trends: TrendBeat[]; trends: TrendBeat[];
dna: ClientDna; dna: ClientDna;
linkedinMeta?: LinkedInScheduleMeta;
}; };
function pieceFromStrategyPost(
sp: LinkedInStrategyPost,
date: string,
time: string,
strategyUrl: string
): RrssPiece {
const title = cleanEditorialTitle(sp.title).slice(0, 160);
const body = (sp.body || '').trim();
const hook =
body.split('\n').map((l) => l.trim()).find((l) => l.length > 12) || title;
const ctaMatch = body.match(
/(?:¿|CTA|Comenta|Compartir|Reserva|Agenda|Escríbenos|DM)[^\n]{0,120}/i
);
return {
id: newPieceId(date, title || sp.id),
date,
time,
platform: 'linkedin',
format: 'Post',
pillar: 'b2b',
title: title || `LinkedIn · ${sp.id}`,
hook: hook.slice(0, 220),
body: body || `${title}\n\n${sp.angle || ''}`.trim(),
cta: (ctaMatch?.[0] || 'Comenta tu caso · DM abierto').slice(0, 160),
status: 'planned',
kind: 'post',
color: '#7C66B8',
objective_metric: 'engagement_b2b',
strategy_url: strategyUrl || undefined,
strategy_post_id: sp.id,
strategy_angle: sp.angle || sp.meta || undefined,
meta_line: strategyUrl
? `${strategyUrl}${sp.id ? `#${sp.id}` : ''}`
: undefined,
} as RrssPiece;
}
function buildLinkedInIdeasFromBrand(input: {
ym: string;
count: number;
label: string;
brandbook?: Brandbook | null;
dna: ClientDna;
weekdays?: number[];
strategyUrl?: string;
previousSamples?: string[];
startIndex?: number;
}): RrssPiece[] {
const count = Math.max(0, Math.round(input.count || 0));
if (!count) return [];
const dates = linkedInDatesInMonth(
input.ym,
count,
input.weekdays || [2, 4]
);
const themes =
input.brandbook?.linkedin_themes?.length
? input.brandbook.linkedin_themes
: input.dna.signatureSeries.length
? input.dna.signatureSeries
: ['Thought leadership', 'Caso de éxito', 'Opinión sector'];
const formats =
input.brandbook?.linkedin_formats?.length
? input.brandbook.linkedin_formats
: ['Post texto', 'Carrusel', 'Documento'];
const cta =
input.brandbook?.linkedin_cta?.[0] || '¿Te suena? Comenta o mándame DM.';
const base = input.startIndex || 0;
const out: RrssPiece[] = [];
for (let i = 0; i < count; i++) {
const theme = themes[(base + i) % themes.length] || 'Thought leadership';
const fmtHint = formats[(base + i) % formats.length] || 'Post texto';
const sample =
input.previousSamples?.[(base + i) % Math.max(1, input.previousSamples.length || 1)];
const title = `LinkedIn · ${theme}`.slice(0, 160);
const date = dates[i] || dates[0];
const body = [
`Una verdad incómoda sobre ${theme.toLowerCase()}.`,
'',
`En ${input.label} lo vemos cada semana con equipos que quieren resultados, no ruido.`,
sample
? `\n(Línea editorial previa — no copies literal: ${sample.slice(0, 160)}…)`
: '',
'',
'3 puntos concretos:',
`1) Diagnóstico: qué está frenando ${theme.toLowerCase()}.`,
'2) Prueba: un cambio medible en 30 días.',
`3) Formato: ${fmtHint}.`,
'',
cta,
]
.filter(Boolean)
.join('\n');
out.push({
id: newPieceId(date, `${title}-${base + i}`),
date,
time: i % 2 === 0 ? '08:30' : '12:30',
platform: 'linkedin',
format: /carrusel|documento/i.test(fmtHint) ? 'Carrusel' : 'Post',
pillar: 'b2b',
title,
hook: `Una verdad incómoda sobre ${theme.toLowerCase()}.`,
body,
cta: cta.slice(0, 160),
status: 'planned',
kind: 'post',
color: '#7C66B8',
objective_metric: 'engagement_b2b',
strategy_url: input.strategyUrl || undefined,
strategy_angle: `${theme} · idea nueva (línea de marca)`,
meta_line: input.strategyUrl || undefined,
});
}
return out;
}
export type LinkedInScheduleMeta = {
mode: 'strategy_backlog' | 'brand_ideas' | 'mixed';
strategyTotal: number;
alreadyUsed: number;
scheduledFromStrategy: number;
ideasCreated: number;
unpublishedRemaining: number;
};
function buildLinkedInMonthPieces(input: {
ym: string;
count: number;
label: string;
strategyUrl: string;
strategyPosts: LinkedInStrategyPost[];
brandbook?: Brandbook | null;
dna: ClientDna;
history: RrssMonthPlan[];
publishedPosts?: PublishedNetworkPost[];
avoidTitles?: string[];
weekdays?: number[];
}): { pieces: RrssPiece[]; meta: LinkedInScheduleMeta } {
const count = Math.max(0, Math.round(input.count || 0));
const emptyMeta: LinkedInScheduleMeta = {
mode: 'brand_ideas',
strategyTotal: 0,
alreadyUsed: 0,
scheduledFromStrategy: 0,
ideasCreated: 0,
unpublishedRemaining: 0,
};
if (!count) return { pieces: [], meta: emptyMeta };
const usage = collectLinkedInUsage({
history: input.history,
publishedPosts: input.publishedPosts,
avoidTitles: input.avoidTitles,
});
const hasStrategy =
Boolean(input.strategyUrl) && (input.strategyPosts?.length || 0) > 0;
if (hasStrategy) {
const pick = pickUnpublishedStrategyPosts(
input.strategyPosts,
usage,
count
);
const dates = linkedInDatesInMonth(
input.ym,
count,
input.weekdays || [2, 4]
);
const fromStrategy = pick.toSchedule.map((sp, i) =>
pieceFromStrategyPost(
sp,
dates[i] || dates[0],
i % 2 === 0 ? '08:30' : '12:30',
input.strategyUrl
)
);
const ideas = buildLinkedInIdeasFromBrand({
ym: input.ym,
count: pick.ideasNeeded,
label: input.label,
brandbook: input.brandbook,
dna: input.dna,
weekdays: input.weekdays,
strategyUrl: input.strategyUrl,
previousSamples: usage.previousSamples,
startIndex: pick.publishedOrPlanned.length + fromStrategy.length,
});
// Reasignar fechas a ideas tras los de estrategia
const ideaDates = linkedInDatesInMonth(
input.ym,
fromStrategy.length + ideas.length,
input.weekdays || [2, 4]
);
const ideasDated = ideas.map((p, i) => ({
...p,
date: ideaDates[fromStrategy.length + i] || p.date,
}));
return {
pieces: [...fromStrategy, ...ideasDated],
meta: {
mode: pick.ideasNeeded > 0 ? 'mixed' : 'strategy_backlog',
strategyTotal: input.strategyPosts.length,
alreadyUsed: pick.publishedOrPlanned.length,
scheduledFromStrategy: fromStrategy.length,
ideasCreated: ideasDated.length,
unpublishedRemaining: Math.max(
0,
pick.unpublished.length - fromStrategy.length
),
},
};
}
// Sin URL / sin posts parseados → solo ideas en línea de marca + histórico LI
const ideas = buildLinkedInIdeasFromBrand({
ym: input.ym,
count,
label: input.label,
brandbook: input.brandbook,
dna: input.dna,
weekdays: input.weekdays,
strategyUrl: input.strategyUrl || undefined,
previousSamples: usage.previousSamples,
});
return {
pieces: ideas,
meta: {
...emptyMeta,
mode: 'brand_ideas',
ideasCreated: ideas.length,
alreadyUsed: usage.publishedCount + usage.plannedCount,
},
};
}
export function strategyMonthPreview(input: { export function strategyMonthPreview(input: {
ym: string; ym: string;
slug: string; slug: string;
@@ -912,7 +1162,17 @@ export function strategyMonthPreview(input: {
history: RrssMonthPlan[]; history: RrssMonthPlan[];
brandbook?: Brandbook | null; brandbook?: Brandbook | null;
publishedPosts?: PublishedNetworkPost[]; publishedPosts?: PublishedNetworkPost[];
genOptions?: BuildSlotsOpts & { instructions?: string }; avoidTitles?: string[];
/** Histórico completo para detectar posts LI ya usados (puede incluir meses cortos). */
linkedinHistory?: RrssMonthPlan[];
genOptions?: BuildSlotsOpts & {
instructions?: string;
linkedinInstructions?: string;
};
/** Posts parseados de la URL de estrategia LinkedIn. */
linkedinStrategyPosts?: LinkedInStrategyPost[];
linkedinStrategyUrl?: string;
linkedinPrompt?: string;
}): StrategyPreviewResult { }): StrategyPreviewResult {
const dna = extractClientDna( const dna = extractClientDna(
input.slug, input.slug,
@@ -921,6 +1181,11 @@ export function strategyMonthPreview(input: {
input.brandbook, input.brandbook,
input.publishedPosts || [] input.publishedPosts || []
); );
if (input.linkedinPrompt) dna.linkedinPrompt = input.linkedinPrompt;
if (input.linkedinStrategyUrl) {
dna.linkedinStrategyUrl = input.linkedinStrategyUrl;
}
const trends = monthTrendBeats(input.ym, dna.brand.vertical); const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const slots = buildMonthSlots(input.ym, dna, input.genOptions); const slots = buildMonthSlots(input.ym, dna, input.genOptions);
const pieces: RrssPiece[] = []; const pieces: RrssPiece[] = [];
@@ -974,15 +1239,42 @@ export function strategyMonthPreview(input: {
}); });
}); });
const liCount = Math.max(0, Math.round(input.genOptions?.linkedinPosts ?? 0));
const liUrl = input.linkedinStrategyUrl || dna.linkedinStrategyUrl || '';
let liMeta: LinkedInScheduleMeta | null = null;
if (liCount > 0) {
const built = buildLinkedInMonthPieces({
ym: input.ym,
count: liCount,
label: input.label,
strategyUrl: liUrl,
strategyPosts: input.linkedinStrategyPosts || [],
brandbook: input.brandbook,
dna,
history: input.linkedinHistory?.length
? input.linkedinHistory
: input.history,
publishedPosts: input.publishedPosts,
avoidTitles: input.avoidTitles,
weekdays: input.genOptions?.weekdays,
});
pieces.push(...built.pieces);
liMeta = built.meta;
}
pieces.sort((a, b) => a.date.localeCompare(b.date) || (a.time || '').localeCompare(b.time || ''));
const plan = emptyMonthPlan(input.ym, input.label); const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. ADN = Zona de marca + calendarios + pubs reales (sin copiar).`; const liN = pieces.filter((p) => /linkedin/i.test(p.platform || '')).length;
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. ADN = Zona de marca + calendarios + pubs reales${liN ? ' + LinkedIn B2B' : ''}.`;
plan.hero.chips = [ plan.hero.chips = [
`${pieces.length} piezas`, `${pieces.length} piezas`,
`${pieces.filter((p) => p.format === 'Reel').length} Reels`, `${pieces.filter((p) => p.format === 'Reel').length} Reels`,
`${pieces.filter((p) => p.format === 'Stories').length} Stories`, `${pieces.filter((p) => p.format === 'Stories').length} Stories`,
`${pieces.filter((p) => p.format === 'Post').length} Posts`, `${pieces.filter((p) => p.format === 'Post' && !/linkedin/i.test(p.platform || '')).length} Posts IG`,
liN ? `${liN} LinkedIn` : '',
dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist', dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist',
]; ].filter(Boolean);
plan.pieces = pieces; plan.pieces = pieces;
const go = input.genOptions; const go = input.genOptions;
@@ -990,17 +1282,29 @@ export function strategyMonthPreview(input: {
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`, `Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
`Fuentes ADN: ${dna.calendarMonthsUsed} mes(es) de calendario editorial · ${dna.networkPostsUsed} publicación(es) reales en redes · ${dna.brandbookPrompt ? 'brandbook' : 'sin brandbook'}.`, `Fuentes ADN: ${dna.calendarMonthsUsed} mes(es) de calendario editorial · ${dna.networkPostsUsed} publicación(es) reales en redes · ${dna.brandbookPrompt ? 'brandbook' : 'sin brandbook'}.`,
go go
? `Mix pedido: ${go.reels ?? '—'} Reels · ${go.carruseles ?? '—'} Carrusel · ${go.stories ?? '—'} Stories · ${go.posts ?? '—'} Posts · mix mismo día: ${go.allowSameDayMix !== false ? 'sí' : 'no'}.` ? `Mix pedido: ${go.reels ?? '—'} Reels · ${go.carruseles ?? '—'} Carrusel · ${go.stories ?? '—'} Stories · ${go.posts ?? '—'} Posts IG · ${go.linkedinPosts ?? 0} LinkedIn · mix mismo día: ${go.allowSameDayMix !== false ? 'sí' : 'no'}.`
: `Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`, : `Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
liMeta
? liMeta.mode === 'strategy_backlog'
? `LinkedIn: agendados ${liMeta.scheduledFromStrategy} posts pendientes de la estrategia (${liMeta.alreadyUsed} ya publicados/planificados · quedan ${liMeta.unpublishedRemaining} en cola)${liUrl ? ` · ${liUrl}` : ''}.`
: liMeta.mode === 'mixed'
? `LinkedIn: ${liMeta.scheduledFromStrategy} de la estrategia (aún no publicados) + ${liMeta.ideasCreated} ideas nuevas en línea de marca (${liMeta.alreadyUsed}/${liMeta.strategyTotal} estrategia ya usados).`
: `LinkedIn: ${liMeta.ideasCreated} ideas nuevas (sin URL de estrategia o backlog agotado) siguiendo temas/CTAs del brandbook e histórico LI.`
: liN
? `LinkedIn: ${liN} piezas.`
: '',
`Tendencias ${monthLabel(input.ym)}: ${trends `Tendencias ${monthLabel(input.ym)}: ${trends
.slice(0, 4) .slice(0, 4)
.map((t) => t.label) .map((t) => t.label)
.join(' · ')}.`, .join(' · ')}.`,
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`, `Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post} · LI 08:30/12:30.`,
`Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`, `Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`,
go?.instructions go?.instructions
? `Instrucciones operador: ${go.instructions.slice(0, 400)}` ? `Instrucciones operador: ${go.instructions.slice(0, 400)}`
: '', : '',
(go as { linkedinInstructions?: string } | undefined)?.linkedinInstructions
? `Instrucciones LinkedIn: ${(go as { linkedinInstructions?: string }).linkedinInstructions!.slice(0, 300)}`
: '',
dna.brandbookPrompt dna.brandbookPrompt
? 'Brandbook activo como constraint creativo.' ? 'Brandbook activo como constraint creativo.'
: dna.voiceNotes, : dna.voiceNotes,
@@ -1008,7 +1312,14 @@ export function strategyMonthPreview(input: {
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
return { plan, rationale, source: 'strategy', trends, dna }; return {
plan,
rationale,
source: 'strategy',
trends,
dna,
...(liMeta ? { linkedinMeta: liMeta } : {}),
};
} }
export function strategistSystemPrompt(input: { export function strategistSystemPrompt(input: {
@@ -1020,6 +1331,8 @@ export function strategistSystemPrompt(input: {
genOptions?: BuildSlotsOpts & { genOptions?: BuildSlotsOpts & {
instructions?: string; instructions?: string;
regenInstructions?: string; regenInstructions?: string;
linkedinInstructions?: string;
linkedinMode?: string;
}; };
}): string { }): string {
const netBlock = const netBlock =
@@ -1036,30 +1349,50 @@ export function strategistSystemPrompt(input: {
.join('\n')}\n` .join('\n')}\n`
: ''; : '';
const go = input.genOptions; const go = input.genOptions;
const liN = go?.linkedinPosts ?? 0;
const mixBlock = go const mixBlock = go
? `Mix EXACTO pedido: ${go.reels ?? 0} Reels, ${go.carruseles ?? 0} Carrusel, ${go.stories ?? 0} Stories, ${go.posts ?? 0} Posts. Días permitidos (JS weekday 0=Dom): ${(go.weekdays || [1, 2, 3, 4, 5]).join(',')}. Mix mismo día story+post o reel+story: ${go.allowSameDayMix !== false ? 'permitido' : 'prohibido'}.` ? `Mix EXACTO pedido: ${go.reels ?? 0} Reels, ${go.carruseles ?? 0} Carrusel, ${go.stories ?? 0} Stories, ${go.posts ?? 0} Posts Instagram/Facebook, ${liN} piezas LinkedIn. Días permitidos (JS weekday 0=Dom): ${(go.weekdays || [1, 2, 3, 4, 5]).join(',')}. Mix mismo día story+post o reel+story: ${go.allowSameDayMix !== false ? 'permitido' : 'prohibido'}.`
: `Mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post.`; : `Mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post.`;
const instrBlock = [ const instrBlock = [
go?.instructions ? `\nINSTRUCCIONES OPERADOR:\n${go.instructions}\n` : '', go?.instructions ? `\nINSTRUCCIONES OPERADOR:\n${go.instructions}\n` : '',
go?.regenInstructions go?.regenInstructions
? `\nINSTRUCCIONES SOLO PARA REGENERADAS:\n${go.regenInstructions}\n` ? `\nINSTRUCCIONES SOLO PARA REGENERADAS:\n${go.regenInstructions}\n`
: '', : '',
go?.linkedinInstructions
? `\nINSTRUCCIONES LINKEDIN:\n${go.linkedinInstructions}\n`
: '',
].join(''); ].join('');
const liBlock = [
input.dna.linkedinPrompt || '',
liN > 0
? `\nLINKEDIN OBLIGATORIO: genera exactamente ${liN} piezas con platform:"linkedin".
REGLAS DE PRIORIDAD:
1) Si hay URL/catálogo de estrategia: agenda SOLO posts del catálogo que AÚN NO estén publicados ni ya planificados en calendarios previos (respeta strategy_post_id y títulos evitados). Copia el body tal cual si está completo (listo para publicar).
2) Si el backlog de la estrategia está vacío o no hay URL: crea ideas NUEVAS siguiendo temas/CTAs/tono del brandbook y el histórico LinkedIn (no repitas ángulos ya usados).
3) Nunca re-agendes un post de la estrategia ya marcado como usado.
Incluye strategy_url / strategy_post_id / strategy_angle cuando venga del catálogo. Horas LI: 08:30 o 12:30.`
: '',
]
.filter(Boolean)
.join('\n');
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}). return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}).
NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo: NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
1) Zona de marca / brandbook 1) Zona de marca / brandbook
2) Histórico de calendarios editoriales (${input.dna.calendarMonthsUsed} meses) 2) Histórico de calendarios editoriales (${input.dna.calendarMonthsUsed} meses)
3) Publicaciones reales ya salidas en redes (${input.dna.networkPostsUsed} piezas) 3) Publicaciones reales ya salidas en redes (${input.dna.networkPostsUsed} piezas)
4) Estrategia LinkedIn B2B (si existe)
ADN series: ${input.dna.signatureSeries.join(', ')}. ADN series: ${input.dna.signatureSeries.join(', ')}.
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}. Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
Volumen guía: ~${input.dna.avgPerWeek} piezas/semana · Stories ratio hist. ${(input.dna.storiesRatio * 100).toFixed(0)}%. Volumen guía: ~${input.dna.avgPerWeek} piezas/semana · Stories ratio hist. ${(input.dna.storiesRatio * 100).toFixed(0)}%.
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''} ${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
${liBlock}
${netBlock}${avoidBlock}${instrBlock} ${netBlock}${avoidBlock}${instrBlock}
Playbook 2026 ES: ${PLAYBOOK_2026.principle} Playbook 2026 ES: ${PLAYBOOK_2026.principle}
${mixBlock} ${mixBlock}
Tendencias del mes: Tendencias del mes:
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')} ${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric,slide_copy,reel_script,interactive_story}] } Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric,slide_copy,reel_script,interactive_story,strategy_url,strategy_post_id,strategy_angle}] }
Reglas: títulos creativos y específicos (nuevos); rota series del ADN; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook. Reglas: títulos creativos y específicos (nuevos); rota series del ADN; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.
Piezas Instagram/Facebook: platform "instagram" (o "facebook" si el brief lo pide). Piezas LinkedIn: platform "linkedin".
Para Carrusel: slide_copy = [{n,label,text}] (Slide 1 hook, 2 por qué, …). Para Reel: reel_script = guión desarrollado. Para Stories interactivas: interactive_story = encuesta|quiz|caja|slider y explica el sticker en body (Meta API no publica stickers).`; Para Carrusel: slide_copy = [{n,label,text}] (Slide 1 hook, 2 por qué, …). Para Reel: reel_script = guión desarrollado. Para Stories interactivas: interactive_story = encuesta|quiz|caja|slider y explica el sticker en body (Meta API no publica stickers).`;
} }

View File

@@ -95,6 +95,57 @@ export function diffFields(
return out; return out;
} }
/**
* Parche con valores ANTERIORES completos (sin clip) para poder deshacer.
* Solo incluye claves que cambiaron.
*/
export function buildRevertPatch(
before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined,
opts?: { maxFields?: number; ignoreKeys?: string[] }
): Record<string, unknown> {
const diffs = diffFields(before, after, {
...opts,
// Usamos diff solo para saber qué cambió; el patch lleva valor real de before
});
const a = before || {};
const patch: Record<string, unknown> = {};
for (const d of diffs) {
if (!(d.field in a) && (a[d.field] === undefined || a[d.field] === null)) {
// Campo nuevo: al deshacer se elimina / vacía
patch[d.field] = null;
} else {
patch[d.field] = a[d.field] ?? null;
}
}
return patch;
}
export function isHistoryRowRevertable(
row: Pick<ChangeHistoryRow, 'action' | 'entity_type' | 'detail'>
): boolean {
if (row.action === 'undo') return false;
const d = row.detail || {};
if (d.undone === true) return false;
if (d.revertable === false) return false;
const patch = d.revert_patch;
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
return Object.keys(patch as object).length > 0;
}
// Legacy: changes con from/to (puede estar clipado — el API validará)
const changes = d.changes;
return (
Array.isArray(changes) &&
changes.length > 0 &&
changes.some(
(c) =>
c &&
typeof c === 'object' &&
typeof (c as { field?: string }).field === 'string'
)
);
}
function clipJson(v: unknown, n = 240): unknown { function clipJson(v: unknown, n = 240): unknown {
try { try {
const s = JSON.stringify(v); const s = JSON.stringify(v);

View File

@@ -794,18 +794,11 @@ export async function recomposeCreativeInCanva(
.eq('id', tenantId) .eq('id', tenantId)
.maybeSingle(); .maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
let canvaTemplates = tenant?.canva_templates || {}; const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
if ( slug: tenant?.slug,
tenant?.slug === 'bps' && channel: 'ads',
(!canvaTemplates || Object.keys(canvaTemplates).length === 0) });
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const clientInfo = { const clientInfo = {
id: tenant?.id || tenantId, id: tenant?.id || tenantId,

View File

@@ -1,6 +1,7 @@
/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */ /** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */
import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { scoreNameToTenant } from '@/lib/match';
export const DRIVE_PROVIDER = 'google_drive'; export const DRIVE_PROVIDER = 'google_drive';
@@ -258,44 +259,18 @@ export function scoreFolderToTenant(
tenantName: string, tenantName: string,
tenantSlug: string tenantSlug: string
): number { ): number {
// Seasonal / campaign subfolders are never a client root
if (/navidad|black\s*friday|campa[nñ]a|assets?\b/i.test(folderName)) {
return 0;
}
// Sibling-brand aware scorer (TYD↔TSP, ECP↔DTATF)
// «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix // «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim(); const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
const candidates = [folderName, stripped]; return Math.max(
const aliasMap: Record<string, string[]> = { scoreNameToTenant(folderName, tenantName, tenantSlug),
bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'], scoreNameToTenant(stripped, tenantName, tenantSlug)
'wine advisors': ['wine advisor', 'wineadvisor'], );
'de tu a tu formacion': ['detuatu', 'de tu a tu'],
'clinica dental sonrisas': ['sonrisas', 'clinica sonrisas'],
};
let best = 0;
for (const raw of candidates) {
const f = normalizeName(raw);
const n = normalizeName(tenantName);
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
if (!f || (!n && !s)) continue;
if (f === n || f === s) best = Math.max(best, 100);
else if (n && (f.includes(n) || n.includes(f))) best = Math.max(best, 85);
else if (s && (f.includes(s) || s.includes(f))) best = Math.max(best, 80);
else {
const aliases = aliasMap[n] || aliasMap[s] || [];
for (const a of aliases) {
if (f.includes(a) || a.includes(f)) best = Math.max(best, 90);
}
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
const nt = new Set(
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
);
if (ft.size && nt.size) {
let hit = 0;
for (const t of nt) if (ft.has(t)) hit += 1;
const ratio = hit / nt.size;
if (ratio >= 0.8) best = Math.max(best, 70);
else if (ratio >= 0.5) best = Math.max(best, 55);
else best = Math.max(best, Math.round(ratio * 50));
}
}
}
return best;
} }
export async function listDriveFolders(): Promise<DriveFolder[]> { export async function listDriveFolders(): Promise<DriveFolder[]> {

View File

@@ -0,0 +1,497 @@
/**
* LinkedIn editorial strategies hosted on 4ventures.es
* (e.g. /bps/estrategia-linkedin-bps.html) — parse publish-ready posts
* and build prompt context for calendar generation.
*/
import type { Brandbook } from '@/lib/brandbook';
import { fetchStrategyWebpage } from '@/lib/b2b-analyzer';
export type LinkedInStrategyPost = {
id: string;
n: number;
title: string;
meta?: string;
angle?: string;
body: string;
};
export type LinkedInStrategyContext = {
url: string;
filename?: string;
excerpt: string;
posts: LinkedInStrategyPost[];
promptBlock: string;
};
const KNOWN_STRATEGY_BY_SLUG: Record<string, string> = {
bps: 'https://4ventures.es/bps/estrategia-linkedin-bps.html',
'blue-parrot-school':
'https://4ventures.es/bps/estrategia-linkedin-bps.html',
blueparrot: 'https://4ventures.es/bps/estrategia-linkedin-bps.html',
halal: 'https://4ventures.es/halal/estrategia-linkedin-halal.html',
'halal-safety':
'https://4ventures.es/halal/estrategia-linkedin-halal.html',
hsc: 'https://4ventures.es/halal/estrategia-linkedin-halal.html',
aguamiia: 'https://4ventures.es/aguamiia/estrategia-linkedin-aguamiia.html',
'agua-miia':
'https://4ventures.es/agua-miia/rrss/linkedin/',
miia: 'https://4ventures.es/aguamiia/estrategia-linkedin-aguamiia.html',
};
function decodeHtml(html: string): string {
return html
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&#8217;/g, "'")
.replace(/&#8216;/g, "'")
.replace(/&#8220;/g, '"')
.replace(/&#8221;/g, '"')
.replace(/&#8211;/g, '–')
.replace(/&#8212;/g, '—')
.replace(/&nbsp;/g, ' ');
}
function stripTags(html: string): string {
return decodeHtml(
html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
)
.replace(/[ \t]+/g, ' ')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** Parse post-card blocks from 4Ventures LinkedIn strategy HTML pages. */
export function parseLinkedInStrategyHtml(html: string): LinkedInStrategyPost[] {
if (!html || html.length < 200) return [];
const cards = html.match(
/<div[^>]*class="[^"]*post-card[^"]*"[^>]*>[\s\S]*?(?=<div[^>]*class="[^"]*post-card|$)/gi
);
if (!cards?.length) {
// Fallback: Post N — Title headings
const loose: LinkedInStrategyPost[] = [];
const re =
/(?:id="(post-\d+)"[\s\S]{0,200})?Post\s+(\d+)\s*[—–\-]\s*([^\n<#]{8,140})/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(html)) && loose.length < 80) {
const n = Number(m[2]);
const id = m[1] || `post-${n}`;
if (loose.some((p) => p.id === id || p.n === n)) continue;
loose.push({
id,
n,
title: `Post ${n} — ${m[3].trim()}`,
body: '',
});
}
return loose;
}
const out: LinkedInStrategyPost[] = [];
for (const card of cards) {
const idMatch = card.match(/\bid=["'](post-\d+)["']/i);
const headerMatch = card.match(
/class=["'][^"']*post-header[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>|<div)/i
);
const metaMatch = card.match(
/class=["'][^"']*post-meta[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>|<div)/i
);
const stratMatch = card.match(
/class=["'][^"']*post-strategy[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>|<div)/i
);
const bodyMatch = card.match(
/class=["'][^"']*post-body[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>\s*(?:<div class="post-|<\/section|$))/i
);
const titleRaw = stripTags(headerMatch?.[1] || '');
const body = stripTags(bodyMatch?.[1] || '');
if (!titleRaw && !body) continue;
const nMatch = titleRaw.match(/Post\s+(\d+)/i) || idMatch?.[1]?.match(/(\d+)/);
const n = nMatch ? Number(nMatch[1]) : out.length + 1;
const id = idMatch?.[1] || `post-${n}`;
if (out.some((p) => p.id === id)) continue;
out.push({
id,
n,
title: titleRaw.slice(0, 180) || `Post ${n}`,
meta: stripTags(metaMatch?.[1] || '') || undefined,
angle: stripTags(stratMatch?.[1] || '') || undefined,
body: body.slice(0, 6000),
});
}
return out.sort((a, b) => a.n - b.n);
}
export function resolveLinkedInStrategyUrl(input: {
brandbook?: Brandbook | null;
slug?: string;
overrideUrl?: string;
}): string {
const override = String(input.overrideUrl || '').trim();
if (override.startsWith('http')) return override;
const fromBook = String(input.brandbook?.linkedin_strategy_url || '').trim();
if (fromBook.startsWith('http')) return fromBook;
const slug = String(input.slug || '')
.toLowerCase()
.trim();
if (slug && KNOWN_STRATEGY_BY_SLUG[slug]) return KNOWN_STRATEGY_BY_SLUG[slug];
return '';
}
export function brandbookLinkedInPromptBlock(book: Brandbook | null | undefined): string {
if (!book) return '';
const lines = [
book.linkedin_strategy_url &&
`URL estrategia LinkedIn: ${book.linkedin_strategy_url}`,
book.linkedin_themes?.length &&
`Temas LI: ${book.linkedin_themes.join(' · ')}`,
book.linkedin_formats?.length &&
`Formatos LI: ${book.linkedin_formats.join(' · ')}`,
book.thought_leadership?.length &&
`Thought leadership: ${book.thought_leadership.join(' · ')}`,
book.buyer_roles?.length &&
`Buyer roles: ${book.buyer_roles.join(' · ')}`,
book.case_study_angles?.length &&
`Ángulos caso: ${book.case_study_angles.join(' · ')}`,
book.linkedin_cta?.length &&
`CTAs LI: ${book.linkedin_cta.join(' · ')}`,
book.abm_notes && `ABM / notas B2B: ${book.abm_notes.slice(0, 800)}`,
].filter(Boolean);
return lines.length ? `LINKEDIN B2B (brandbook):\n${lines.join('\n')}` : '';
}
export function buildLinkedInPromptBlock(ctx: {
url: string;
posts: LinkedInStrategyPost[];
excerpt: string;
brandbook?: Brandbook | null;
mode?: 'from_strategy' | 'ideas' | 'mixed';
}): string {
const mode = ctx.mode || (ctx.posts.length ? 'from_strategy' : 'ideas');
const bb = brandbookLinkedInPromptBlock(ctx.brandbook);
const catalog = ctx.posts
.slice(0, 24)
.map((p) => {
const bodyPreview = (p.body || '').slice(0, 420).replace(/\s+/g, ' ');
return `- [${p.id}] ${p.title}${p.angle ? ` | Ángulo: ${p.angle.slice(0, 160)}` : ''}${
bodyPreview ? `\n Copy: ${bodyPreview}` : ''
}`;
})
.join('\n');
const rules =
mode === 'from_strategy'
? `MODO LINKEDIN = from_strategy: prioriza posts YA escritos en la estrategia (copie listo para publicar). Adapta fecha/hora del mes; no inventes otro copy si el post de la estrategia tiene body completo. platform debe ser "linkedin". format: "Post" o "Carrusel" o "Documento".`
: mode === 'ideas'
? `MODO LINKEDIN = ideas: genera piezas NUEVAS con el tono/pilares de la estrategia y brandbook. Copy listo para pegar en LinkedIn (hook 1-2 líneas + cuerpo con saltos de línea + CTA). platform: "linkedin".`
: `MODO LINKEDIN = mixed: usa posts de la estrategia cuando encajen; completa el resto con ideas nuevas del mismo tono. platform: "linkedin".`;
return [
bb,
ctx.url ? `Fuente estrategia: ${ctx.url}` : '',
rules,
catalog
? `CATÁLOGO DE POSTS DE LA ESTRATEGIA (referencia / publicar):\n${catalog}`
: '',
ctx.excerpt
? `EXTRACTO ESTRATEGIA (pilares/reglas):\n${ctx.excerpt.slice(0, 6000)}`
: '',
]
.filter(Boolean)
.join('\n\n');
}
/** Load + parse a strategy URL for calendar generation / zoom. */
export async function loadLinkedInStrategyContext(input: {
url: string;
brandbook?: Brandbook | null;
mode?: 'from_strategy' | 'ideas' | 'mixed';
}): Promise<LinkedInStrategyContext | null> {
const url = String(input.url || '').trim();
if (!url.startsWith('http')) return null;
try {
const page = await fetchStrategyWebpage(url);
// fetchStrategyWebpage returns stripped text; re-fetch raw HTML for cards
let html = '';
try {
const resp = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (compatible; 4VenturesStrategyBot/1.0; +https://4ventures.es)',
Accept: 'text/html',
},
signal: AbortSignal.timeout(20000),
});
if (resp.ok) html = await resp.text();
} catch {
html = '';
}
const posts = parseLinkedInStrategyHtml(html);
const excerpt = page.text.slice(0, 12000);
const promptBlock = buildLinkedInPromptBlock({
url,
posts,
excerpt,
brandbook: input.brandbook,
mode: input.mode,
});
return {
url,
filename: page.filename,
excerpt,
posts,
promptBlock,
};
} catch {
return null;
}
}
/** Normalize title for fuzzy match strategy ↔ calendar / pipeline. */
export function normalizeLiTitleKey(raw: string): string {
return String(raw || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/^post\s*\d+\s*[—–\-:·.]\s*/i, '')
.replace(/^linkedin\s*[·\-—:]\s*/i, '')
.replace(/[^a-z0-9áéíóúñü\s]/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
export type LinkedInUsageIndex = {
/** strategy_post_id ya usados (post-1, …) */
usedIds: Set<string>;
/** Títulos normalizados ya publicados o ya en calendario */
usedTitleKeys: Set<string>;
/** Muestras de piezas LI previas (para generar nuevas en línea de marca) */
previousSamples: string[];
publishedCount: number;
plannedCount: number;
};
type LiUsagePiece = {
platform?: string;
title?: string;
body?: string;
hook?: string;
strategy_post_id?: string;
strategy_url?: string;
meta_line?: string;
status?: string;
};
type LiUsagePlan = { pieces?: LiUsagePiece[]; month?: string };
type LiPublished = {
title?: string;
caption?: string | null;
channels?: string[] | null;
status?: string | null;
};
function extractPostIdFromMeta(meta?: string): string | null {
if (!meta) return null;
const m = meta.match(/#(post-\d+)\b/i) || meta.match(/\b(post-\d+)\b/i);
return m ? m[1].toLowerCase() : null;
}
/**
* Indexa qué posts de la estrategia ya están publicados o ya agendados
* en calendarios editoriales / pipeline.
*/
export function collectLinkedInUsage(input: {
history?: LiUsagePlan[];
publishedPosts?: LiPublished[];
avoidTitles?: string[];
/** Piezas del mes actual a no re-proponer (conservadas). */
currentPieces?: LiUsagePiece[];
}): LinkedInUsageIndex {
const usedIds = new Set<string>();
const usedTitleKeys = new Set<string>();
const previousSamples: string[] = [];
let publishedCount = 0;
let plannedCount = 0;
const markPiece = (p: LiUsagePiece, asPublished: boolean) => {
const plat = String(p.platform || '').toLowerCase();
const looksLi =
plat.includes('linkedin') ||
Boolean(p.strategy_post_id) ||
/linkedin|estrategia-linkedin/i.test(p.strategy_url || '') ||
/linkedin|estrategia-linkedin/i.test(p.meta_line || '');
if (!looksLi && !p.strategy_post_id) return;
if (p.strategy_post_id) usedIds.add(p.strategy_post_id.toLowerCase());
const fromMeta = extractPostIdFromMeta(p.meta_line || p.strategy_url);
if (fromMeta) usedIds.add(fromMeta);
for (const t of [p.title, p.hook]) {
const k = normalizeLiTitleKey(t || '');
if (k.length >= 12) usedTitleKeys.add(k);
}
if (asPublished) publishedCount += 1;
else plannedCount += 1;
const sample = [p.title, (p.body || '').slice(0, 280)]
.filter(Boolean)
.join(' — ');
if (sample && previousSamples.length < 24) previousSamples.push(sample);
};
for (const plan of input.history || []) {
for (const p of plan.pieces || []) markPiece(p, false);
}
for (const p of input.currentPieces || []) markPiece(p, false);
for (const row of input.publishedPosts || []) {
const ch = (row.channels || []).map((c) => String(c).toLowerCase());
const isLi =
ch.includes('linkedin') ||
/linkedin/i.test(row.title || '') ||
/linkedin/i.test(row.caption || '');
if (!isLi && ch.length > 0) continue;
// Si no hay channels, solo contar si el título encaja con estilo LI post N
if (!ch.length && !isLi) continue;
const title = row.title || '';
const k = normalizeLiTitleKey(title);
if (k.length >= 12) usedTitleKeys.add(k);
const idFromTitle = title.match(/\bpost[\s#-]*(\d+)\b/i);
if (idFromTitle) usedIds.add(`post-${idFromTitle[1]}`);
publishedCount += 1;
const sample = [title, (row.caption || '').slice(0, 280)]
.filter(Boolean)
.join(' — ');
if (sample && previousSamples.length < 24) previousSamples.push(sample);
}
for (const t of input.avoidTitles || []) {
const k = normalizeLiTitleKey(t);
if (k.length >= 12) usedTitleKeys.add(k);
const id = t.match(/\b(post-\d+)\b/i) || t.match(/^Post\s+(\d+)/i);
if (id) {
usedIds.add(
id[1].toLowerCase().startsWith('post-')
? id[1].toLowerCase()
: `post-${id[1]}`
);
}
}
return {
usedIds,
usedTitleKeys,
previousSamples,
publishedCount,
plannedCount,
};
}
function strategyPostIsUsed(
sp: LinkedInStrategyPost,
usage: LinkedInUsageIndex
): boolean {
if (usage.usedIds.has(sp.id.toLowerCase())) return true;
if (usage.usedIds.has(`post-${sp.n}`)) return true;
const tk = normalizeLiTitleKey(sp.title);
if (tk.length >= 12 && usage.usedTitleKeys.has(tk)) return true;
// Fuzzy: title key contained in any used key or vice-versa
if (tk.length >= 18) {
for (const u of usage.usedTitleKeys) {
if (u.includes(tk) || tk.includes(u)) return true;
}
}
return false;
}
export type PickStrategyResult = {
unpublished: LinkedInStrategyPost[];
publishedOrPlanned: LinkedInStrategyPost[];
/** Posts a agendar este mes (prioridad: no usados, en orden de la estrategia). */
toSchedule: LinkedInStrategyPost[];
/** Cuántos faltan por cubrir con ideas nuevas de marca. */
ideasNeeded: number;
};
/**
* Con URL de estrategia: agenda primero los posts propuestos aún no
* publicados/agendados (en orden). Si no bastan, ideasNeeded > 0.
*/
export function pickUnpublishedStrategyPosts(
posts: LinkedInStrategyPost[],
usage: LinkedInUsageIndex,
count: number
): PickStrategyResult {
const withBody = posts.filter((p) => (p.body || '').trim().length > 40);
const pool = (withBody.length ? withBody : posts).slice().sort((a, b) => a.n - b.n);
const unpublished: LinkedInStrategyPost[] = [];
const publishedOrPlanned: LinkedInStrategyPost[] = [];
for (const sp of pool) {
if (strategyPostIsUsed(sp, usage)) publishedOrPlanned.push(sp);
else unpublished.push(sp);
}
const toSchedule = unpublished.slice(0, Math.max(0, count));
const ideasNeeded = Math.max(0, count - toSchedule.length);
return { unpublished, publishedOrPlanned, toSchedule, ideasNeeded };
}
/**
* @deprecated Prefer pickUnpublishedStrategyPosts — rotación ciega re-proponía posts ya usados.
*/
export function pickStrategyPostsForMonth(
posts: LinkedInStrategyPost[],
ym: string,
count: number
): LinkedInStrategyPost[] {
if (!posts.length || count <= 0) return [];
const withBody = posts.filter((p) => (p.body || '').trim().length > 40);
const pool = withBody.length ? withBody : posts;
const [y, m] = ym.split('-').map(Number);
const offset = ((y || 2026) * 12 + (m || 1)) % Math.max(1, pool.length);
const out: LinkedInStrategyPost[] = [];
for (let i = 0; i < count; i++) {
out.push(pool[(offset + i) % pool.length]);
}
return out;
}
/** Weekday dates in month for LI posting (default Tue/Thu mornings). */
export function linkedInDatesInMonth(
ym: string,
count: number,
weekdays: number[] = [2, 4]
): string[] {
const [y, m] = ym.split('-').map(Number);
if (!y || !m) return [];
const days: string[] = [];
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
for (let d = 1; d <= last; d++) {
const dt = new Date(Date.UTC(y, m - 1, d));
if (weekdays.includes(dt.getUTCDay())) {
days.push(
`${ym}-${String(d).padStart(2, '0')}`
);
}
}
if (!days.length) {
for (let d = 1; d <= last; d++) {
days.push(`${ym}-${String(d).padStart(2, '0')}`);
}
}
if (count <= days.length) {
// spread evenly
const step = days.length / count;
return Array.from({ length: count }, (_, i) => days[Math.min(days.length - 1, Math.floor(i * step))]);
}
const out: string[] = [];
for (let i = 0; i < count; i++) out.push(days[i % days.length]);
return out;
}

View File

@@ -1,5 +1,7 @@
/** Shared fuzzy match: account/folder name ↔ tenant name/slug. */ /** Shared fuzzy match: account/folder name ↔ tenant name/slug. */
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
export function normalizeName(raw: string): string { export function normalizeName(raw: string): string {
return raw return raw
.normalize('NFD') .normalize('NFD')
@@ -10,6 +12,27 @@ export function normalizeName(raw: string): string {
.replace(/\s+/g, ' '); .replace(/\s+/g, ' ');
} }
/** Alias tokens that strongly identify sibling brands (avoid cross-match). */
const BRAND_STRONG_ALIASES: Record<string, string[]> = {
tyd: ['tyd', 'talento y deporte', 'talentoydeporte', 'beca deportiva'],
tsp: ['tsp', 'talento scholar', 'scholar program', 'scholar'],
ecp: [
'ecp',
'escuela de cuidadores',
'cuidadores profesionales',
'escuelaecp',
],
dtatf: ['dtatf', 'detuatu', 'de tu a tu', 'detuatuformacion'],
};
/** Signals that should NOT match the sibling brand. */
const BRAND_NEGATIVE: Record<string, string[]> = {
tyd: ['scholar', 'tsp', 'academico'],
tsp: ['deporte', 'tyd', 'deportiva', 'atleta'],
ecp: ['detuatu', 'dtatf', 'de tu a tu'],
dtatf: ['ecp', 'cuidadores', 'cuidador', 'escuelaecp'],
};
export function scoreNameToTenant( export function scoreNameToTenant(
accountName: string, accountName: string,
tenantName: string, tenantName: string,
@@ -17,8 +40,23 @@ export function scoreNameToTenant(
): number { ): number {
const f = normalizeName(accountName); const f = normalizeName(accountName);
const n = normalizeName(tenantName); const n = normalizeName(tenantName);
const s = normalizeName(tenantSlug.replace(/-/g, ' ')); const canon = resolveBrandSlug(tenantSlug);
const s = normalizeName(canon.replace(/-/g, ' '));
if (!f || (!n && !s)) return 0; if (!f || (!n && !s)) return 0;
// Hard negatives between sibling brands (TYD↔TSP, ECP↔DTATF)
const negatives = BRAND_NEGATIVE[canon] || [];
for (const neg of negatives) {
if (f.includes(normalizeName(neg))) return 0;
}
// Strong alias boost
const aliases = BRAND_STRONG_ALIASES[canon] || [];
for (const a of aliases) {
const an = normalizeName(a);
if (an && (f === an || f.includes(an) || an.includes(f))) return 95;
}
if (f === n || f === s) return 100; if (f === n || f === s) return 100;
if (n && (f.includes(n) || n.includes(f))) return 85; if (n && (f.includes(n) || n.includes(f))) return 85;
if (s && (f.includes(s) || s.includes(f))) return 80; if (s && (f.includes(s) || s.includes(f))) return 80;
@@ -37,6 +75,9 @@ export function scoreNameToTenant(
'los', 'los',
'las', 'las',
'del', 'del',
'talento', // too weak alone (TYD vs TSP)
'formacion',
'escuela',
]); ]);
const ft = new Set( const ft = new Set(
f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t)) f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t))

View File

@@ -615,6 +615,40 @@ async function publishFbPost(
return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds); return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds);
} }
/**
* Stub LinkedIn publish — ready for LINKEDIN_ACCESS_TOKEN / org id.
* Returns a clear error until credentials exist (does not fake success).
*/
export async function publishLinkedInPost(item: {
title?: string | null;
caption?: string | null;
}): Promise<{ id: string | null; permalink: string | null; error?: string }> {
const token =
process.env.LINKEDIN_ACCESS_TOKEN?.trim() ||
process.env.LINKEDIN_API_KEY?.trim() ||
'';
const orgUrn =
process.env.LINKEDIN_ORG_URN?.trim() ||
process.env.LINKEDIN_AUTHOR_URN?.trim() ||
'';
if (!token || !orgUrn) {
return {
id: null,
permalink: null,
error:
'LinkedIn pendiente: configura LINKEDIN_ACCESS_TOKEN y LINKEDIN_ORG_URN (API key / org). El copy ya está listo en el pipeline.',
};
}
// Placeholder for UGC Posts API — wire when credentials land.
void item;
return {
id: null,
permalink: null,
error:
'LinkedIn API conectada parcialmente: falta implementar UGC Posts. Token detectado.',
};
}
export async function publishPipelineItem( export async function publishPipelineItem(
item: PipelinePublishRow, item: PipelinePublishRow,
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean } opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
@@ -627,11 +661,33 @@ export async function publishPipelineItem(
...item, ...item,
caption: scrub.caption || item.title || '', caption: scrub.caption || item.title || '',
}; };
const wantIg = channels.includes('instagram') && !opts?.skipIg;
const wantFb = channels.includes('facebook') && !opts?.skipFb;
const wantLi = channels.includes('linkedin');
const errors: string[] = [];
// Solo LinkedIn: no exige Meta
if (wantLi && !wantIg && !wantFb) {
const li = await publishLinkedInPost(cleanItem);
return {
ok: Boolean(li.id) && !li.error,
error: li.error,
channels,
sanitizedCaption: scrub.caption,
captionStripped: scrub.stripped,
};
}
const resolved = await resolveTenantMetaTargets(item.tenant_id); const resolved = await resolveTenantMetaTargets(item.tenant_id);
if (!resolved.targets) { if (!resolved.targets) {
if (wantLi) {
const li = await publishLinkedInPost(cleanItem);
errors.push(li.error || 'LinkedIn no publicado');
}
return { return {
ok: false, ok: false,
error: resolved.error, error: [resolved.error, ...errors].filter(Boolean).join(' · '),
channels, channels,
sanitizedCaption: scrub.caption, sanitizedCaption: scrub.caption,
captionStripped: scrub.stripped, captionStripped: scrub.stripped,
@@ -643,10 +699,6 @@ export async function publishPipelineItem(
let fbPostId: string | null = opts?.forceFb ? null : item.meta_fb_post_id || null; let fbPostId: string | null = opts?.forceFb ? null : item.meta_fb_post_id || null;
let igPermalink: string | null = null; let igPermalink: string | null = null;
let fbPermalink: string | null = null; let fbPermalink: string | null = null;
const errors: string[] = [];
const wantIg = channels.includes('instagram') && !opts?.skipIg;
const wantFb = channels.includes('facebook') && !opts?.skipFb;
if (wantIg) { if (wantIg) {
if (!igUserId) { if (!igUserId) {
@@ -674,9 +726,20 @@ export async function publishPipelineItem(
} }
} }
let liOk = !wantLi;
if (wantLi) {
const li = await publishLinkedInPost(cleanItem);
if (li.error || !li.id) {
errors.push(`LI: ${li.error || 'fallo'}`);
liOk = false;
} else {
liOk = true;
}
}
const igOk = !wantIg || Boolean(igMediaId); const igOk = !wantIg || Boolean(igMediaId);
const fbOk = !wantFb || Boolean(fbPostId); const fbOk = !wantFb || Boolean(fbPostId);
const ok = igOk && fbOk && errors.length === 0; const ok = igOk && fbOk && liOk && errors.length === 0;
return { return {
ok, ok,

View File

@@ -1,187 +1,27 @@
/** /**
* Mirror creative_pipeline uploads into the client's Google Drive folder: * RRSS pipeline → Drive mirror (delegates to creatives-drive).
* * Folder: Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
* {carpeta-cliente}/
* Pipeline-creatividades_{nombre-cliente}/
* YYYY/
* MM/
* DD-MM-YYYY_formato_hook[-N].ext
*/ */
import { slugify } from '@/lib/calendar-import';
import { import {
applyMatches, backfillCreativeItemToDrive,
driveConfigured, resolveOrLinkTenantDriveFolder,
ensureChildFolder, syncCreativeAssetToDrive,
getTenantDriveFolderId, type CreativeDriveItem,
listClientFoldersUnderClientes, } from '@/lib/creatives-drive';
listFilesInFolder,
matchFoldersToTenants,
uploadToDrive,
getValidAccessToken,
} from '@/lib/google-drive';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
export type PipelineDriveItem = { export type PipelineDriveItem = CreativeDriveItem & {
id: string;
title?: string | null;
format?: string | null;
caption?: string | null;
notes?: string | null;
scheduled_at?: string | null;
created_at?: string | null;
cover_path?: string | null; cover_path?: string | null;
slide_paths?: unknown; slide_paths?: unknown;
}; };
function pad2(n: number): string { export {
return String(n).padStart(2, '0'); resolveOrLinkTenantDriveFolder,
} pieceLocalDate,
creativesDriveRootFolderName as pipelineRootFolderName,
buildCreativeDriveFileName as formatDriveFileName,
ensureCreativesDriveFolders,
} from '@/lib/creatives-drive';
/** Prefer scheduled local date; fallback created_at / today. */
export function pieceLocalDate(item: PipelineDriveItem): Date {
const raw = item.scheduled_at || item.created_at;
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
}
return new Date();
}
export function hookLabel(item: PipelineDriveItem): string {
const caption = (item.caption || '').trim();
if (caption) {
const first = caption.split(/\n/)[0].trim();
if (first) return first.slice(0, 80);
}
const title = (item.title || '').trim();
if (title) return title.slice(0, 80);
return 'creativo';
}
export function formatDriveFileName(opts: {
item: PipelineDriveItem;
ext: string;
slideNumber?: number;
}): string {
const d = pieceLocalDate(opts.item);
const dd = pad2(d.getDate());
const mm = pad2(d.getMonth() + 1);
const yyyy = d.getFullYear();
const format = slugify(opts.item.format || 'post') || 'post';
const hook = slugify(hookLabel(opts.item)) || 'creativo';
const base = `${dd}-${mm}-${yyyy}_${format}_${hook}`;
const ext = (opts.ext || 'jpg').replace(/^\./, '').toLowerCase();
if (opts.slideNumber && opts.slideNumber > 0) {
return `${base}-${opts.slideNumber}.${ext}`;
}
return `${base}.${ext}`;
}
export function pipelineRootFolderName(clientLabel: string): string {
const name = slugify(clientLabel) || 'cliente';
return `Pipeline-creatividades_${name}`;
}
async function replaceDriveFileContent(
fileId: string,
mimeType: string,
buffer: Buffer
): Promise<void> {
const token = await getValidAccessToken();
const r = await fetch(
`https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(
fileId
)}?uploadType=media&supportsAllDrives=true`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': mimeType,
},
body: new Uint8Array(buffer),
}
);
if (!r.ok) {
throw new Error(`Drive update: ${(await r.text()).slice(0, 300)}`);
}
}
async function uploadOrReplace(opts: {
parentFolderId: string;
fileName: string;
mimeType: string;
buffer: Buffer;
}): Promise<{ id: string; webViewLink?: string; replaced: boolean }> {
const existing = await listFilesInFolder(opts.parentFolderId);
const hit = existing.find(
(f) =>
f.name === opts.fileName &&
f.mimeType !== 'application/vnd.google-apps.folder'
);
if (hit) {
await replaceDriveFileContent(hit.id, opts.mimeType, opts.buffer);
return { id: hit.id, webViewLink: hit.webViewLink, replaced: true };
}
const created = await uploadToDrive(opts);
return { ...created, replaced: false };
}
async function loadTenant(tenantId: string): Promise<{
slug: string;
name: string;
} | null> {
const headers = serviceHeaders();
if (!headers) return null;
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(
tenantId
)}&select=slug,name&limit=1`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = (await r.json()) as { slug?: string; name?: string }[];
if (!rows[0]) return null;
return {
slug: rows[0].slug || rows[0].name || 'cliente',
name: rows[0].name || rows[0].slug || 'cliente',
};
}
/**
* Resolve client Drive folder: linked integration, or auto-match under «Clientes».
*/
export async function resolveOrLinkTenantDriveFolder(
tenantId: string,
clientLabel: string
): Promise<{ folderId: string; folderName?: string; autoLinked?: boolean } | null> {
const linked = await getTenantDriveFolderId(tenantId);
if (linked?.folderId) return linked;
const tenant = await loadTenant(tenantId);
if (!tenant) return null;
const { folders } = await listClientFoldersUnderClientes();
if (!folders.length) return null;
const matches = await matchFoldersToTenants(
[{ id: tenantId, name: tenant.name || clientLabel, slug: tenant.slug }],
folders
);
const hit = matches.find((m) => m.status === 'matched' && m.folderId);
if (!hit?.folderId) return null;
await applyMatches([hit]);
return {
folderId: hit.folderId,
folderName: hit.folderName || undefined,
autoLinked: true,
};
}
/**
* Ensure month folder under client's Drive root and upload the creative.
*/
export async function syncPipelineAssetToDrive(opts: { export async function syncPipelineAssetToDrive(opts: {
tenantId: string; tenantId: string;
clientLabel: string; clientLabel: string;
@@ -191,157 +31,22 @@ export async function syncPipelineAssetToDrive(opts: {
ext: string; ext: string;
slot: 'cover' | 'slide'; slot: 'cover' | 'slide';
slideIndex: number; slideIndex: number;
}): Promise<{ }) {
ok: boolean; return syncCreativeAssetToDrive({
skipped?: boolean; ...opts,
warning?: string; channel: 'rrss',
driveFileId?: string; });
drivePath?: string;
fileName?: string;
autoLinked?: boolean;
}> {
if (!driveConfigured()) {
return {
ok: false,
skipped: true,
warning: 'Google Drive no configurado (OAuth agencia)',
};
}
let linked: {
folderId: string;
folderName?: string;
autoLinked?: boolean;
} | null = null;
try {
linked = await resolveOrLinkTenantDriveFolder(
opts.tenantId,
opts.clientLabel
);
} catch (e) {
return {
ok: false,
warning:
e instanceof Error
? `Drive auto-link: ${e.message}`
: 'Drive auto-link falló',
};
}
if (!linked?.folderId) {
return {
ok: false,
skipped: true,
warning:
'Cliente sin carpeta Drive. Empareja Drive en Agencia (o crea carpeta bajo 03-Clientes).',
};
}
try {
const tenant = await loadTenant(opts.tenantId);
const rootName = pipelineRootFolderName(
tenant?.slug || opts.clientLabel || 'cliente'
);
const d = pieceLocalDate(opts.item);
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const pipelineRootId = await ensureChildFolder(linked.folderId, rootName);
const yearId = await ensureChildFolder(pipelineRootId, year);
const monthId = await ensureChildFolder(yearId, month);
const isCarousel = (opts.item.format || '').toLowerCase() === 'carrusel';
let slideNumber: number | undefined;
if (isCarousel) {
slideNumber =
opts.slot === 'cover' ? 1 : Math.max(2, opts.slideIndex + 2);
}
const fileName = formatDriveFileName({
item: opts.item,
ext: opts.ext,
slideNumber,
});
const up = await uploadOrReplace({
parentFolderId: monthId,
fileName,
mimeType: opts.mimeType,
buffer: opts.buffer,
});
return {
ok: true,
driveFileId: up.id,
fileName,
drivePath: `${linked.folderName || 'cliente'}/${rootName}/${year}/${month}/${fileName}`,
autoLinked: linked.autoLinked,
};
} catch (e) {
return {
ok: false,
warning: e instanceof Error ? e.message : 'Error al subir a Drive',
};
}
} }
/** Backfill: push an already-stored cover/slide from Storage → Drive. */
export async function backfillPipelineItemToDrive(opts: { export async function backfillPipelineItemToDrive(opts: {
tenantId: string; tenantId: string;
clientLabel: string; clientLabel: string;
item: PipelineDriveItem; item: PipelineDriveItem;
}): Promise<{ ok: boolean; results: { slot: string; ok: boolean; detail: string }[] }> { }) {
const svc = serviceSupabase(); return backfillCreativeItemToDrive({
const results: { slot: string; ok: boolean; detail: string }[] = []; tenantId: opts.tenantId,
if (!svc) { clientLabel: opts.clientLabel,
return { ok: false, results: [{ slot: '-', ok: false, detail: 'no service' }] }; channel: 'rrss',
} item: opts.item,
const jobs: { path: string; slot: 'cover' | 'slide'; index: number }[] = [];
if (opts.item.cover_path) {
jobs.push({ path: opts.item.cover_path, slot: 'cover', index: 0 });
}
const slides = Array.isArray(opts.item.slide_paths)
? (opts.item.slide_paths as string[])
: [];
slides.forEach((p, i) => {
if (p) jobs.push({ path: p, slot: 'slide', index: i });
}); });
for (const job of jobs) {
const dl = await svc.storage.from(ASSETS_BUCKET).download(job.path);
if (dl.error || !dl.data) {
results.push({
slot: job.slot,
ok: false,
detail: dl.error?.message || 'download fail',
});
continue;
}
const ab = await dl.data.arrayBuffer();
const buf = Buffer.from(ab);
const ext =
job.path.split('.').pop()?.toLowerCase() ||
(dl.data.type.includes('png') ? 'png' : 'jpg');
const mime = dl.data.type || 'image/jpeg';
const sync = await syncPipelineAssetToDrive({
tenantId: opts.tenantId,
clientLabel: opts.clientLabel,
item: opts.item,
buffer: buf,
mimeType: mime,
ext,
slot: job.slot,
slideIndex: job.index,
});
results.push({
slot: `${job.slot}${job.slot === 'slide' ? job.index + 1 : ''}`,
ok: sync.ok,
detail: sync.ok
? sync.drivePath || 'ok'
: sync.warning || 'fail',
});
}
return { ok: results.every((r) => r.ok), results };
} }

View File

@@ -215,6 +215,28 @@ export async function processDuePipelinePublishes(opts?: {
let igPermalink: string | null = null; let igPermalink: string | null = null;
let fbPermalink: string | null = null; let fbPermalink: string | null = null;
// LinkedIn-only: stub hasta API key (no pasa por Meta)
if (
channels.includes('linkedin') &&
!channels.includes('instagram') &&
!channels.includes('facebook')
) {
const li = await publishPipelineItem(item);
await patchItem(base, headers, item.id, {
publish_status: li.ok ? 'published' : 'failed',
publish_error: li.error || null,
published_at: li.ok ? new Date().toISOString() : null,
publish_attempts: (item.publish_attempts || 0) + 1,
});
results.push({
id: item.id,
title: item.title,
ok: Boolean(li.ok),
detail: li.ok ? 'linkedin' : li.error || 'linkedin_failed',
});
continue;
}
if (channels.includes('instagram') && !igMediaId && channelDue('instagram')) { if (channels.includes('instagram') && !igMediaId && channelDue('instagram')) {
const ig = await publishPipelineItem(item, { skipFb: true }); const ig = await publishPipelineItem(item, { skipFb: true });
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) { if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {

View File

@@ -1,6 +1,6 @@
/** Agency scheduling helpers for creative_pipeline. */ /** Agency scheduling helpers for creative_pipeline. */
export const PUBLISH_CHANNELS = ['instagram', 'facebook'] as const; export const PUBLISH_CHANNELS = ['instagram', 'facebook', 'linkedin'] as const;
export type PublishChannel = (typeof PUBLISH_CHANNELS)[number]; export type PublishChannel = (typeof PUBLISH_CHANNELS)[number];
export const PUBLISH_STATUSES = [ export const PUBLISH_STATUSES = [
@@ -22,6 +22,16 @@ export function normalizeChannels(raw: unknown): PublishChannel[] {
return out.length ? [...out] : ['instagram', 'facebook']; return out.length ? [...out] : ['instagram', 'facebook'];
} }
/** Default channels for a calendar piece platform. */
export function channelsForCalendarPlatform(platform?: string | null): PublishChannel[] {
const plat = String(platform || '').toLowerCase();
if (plat.includes('linkedin')) return ['linkedin'];
if (plat.includes('facebook') && !plat.includes('instagram')) {
return ['facebook', 'instagram'];
}
return ['instagram', 'facebook'];
}
/** Parse datetime-local or ISO into timestamptz ISO, or null to clear. */ /** Parse datetime-local or ISO into timestamptz ISO, or null to clear. */
export function parseScheduledAt(raw: unknown): string | null | undefined { export function parseScheduledAt(raw: unknown): string | null | undefined {
if (raw === null) return null; if (raw === null) return null;

View File

@@ -108,6 +108,8 @@ export type RrssGenerationBriefInput = {
socialHistory?: unknown; socialHistory?: unknown;
variantsRequested?: number; variantsRequested?: number;
extraNotes?: string | null; extraNotes?: string | null;
/** Resolved Canva Brand Template IDs for RRSS (nested ads/rrss or flat). */
canvaTemplates?: Record<string, string> | null;
}; };
export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) { export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
@@ -119,6 +121,7 @@ export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
client_id: input.tenantId, client_id: input.tenantId,
client_slug: input.clientSlug, client_slug: input.clientSlug,
client_name: input.clientName, client_name: input.clientName,
canva_templates: input.canvaTemplates || {},
reason: 'calendario' as const, reason: 'calendario' as const,
source_creative_id: null, source_creative_id: null,
source_pipeline_id: input.pipelineId, source_pipeline_id: input.pipelineId,

View File

@@ -579,12 +579,41 @@ const TENANT_DOC_ALIASES: Record<string, string[]> = {
'delgado-abogados': ['Delgado Abogados', 'Delgado'], 'delgado-abogados': ['Delgado Abogados', 'Delgado'],
'equinsa-parking': ['Equinsa', 'Equinsa Parking'], 'equinsa-parking': ['Equinsa', 'Equinsa Parking'],
'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'], 'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'],
'talento-y-deporte': ['Talento y Deporte', 'Talento'], tyd: [
'TYD',
'Talento y Deporte',
'Talento Deporte',
'Talento y Deporte',
],
tsp: [
'TSP',
'Talento Scholar Program',
'Talento Scholar',
'Scholar Program',
'Scholar',
],
'la-freseria': ['La Fresería', 'Fresería', 'Freseria'], 'la-freseria': ['La Fresería', 'Fresería', 'Freseria'],
gravitae: ['Gravitae'], gravitae: ['Gravitae'],
menttum: ['Menttum'], menttum: ['Menttum'],
'wine-advisors': ['Wine Advisors', 'Wine'], 'wine-advisors': ['Wine Advisors', 'Wine'],
ecp: ['ECP'], ecp: [
'ECP',
'Escuela de Cuidadores',
'Escuela de cuidadores profesionales',
'Cuidadores',
],
dtatf: [
'DTATF',
'De Tú a Tú Formación',
'De Tu A Tu Formación',
'Detuatu',
'Detuatuformacion',
'De Tu A Tu',
],
// legacy slug keys (docs still matching old names)
'talento-y-deporte': ['Talento y Deporte', 'TYD'],
'talento-scholar-program': ['Talento Scholar Program', 'TSP'],
'de-tu-a-tu-formacion': ['De Tú a Tú Formación', 'DTATF'],
mcd: ['MCD'], mcd: ['MCD'],
'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'], 'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'],
'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'], 'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'],

View File

@@ -1,5 +1,13 @@
import { createServerClient } from '@supabase/ssr'; import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import {
VIEW_TENANT_COOKIE,
VIEW_TENANT_NAME_COOKIE,
VIEW_TENANT_SLUG_COOKIE,
clearedViewTenantCookieOpts,
isAgencyRole,
userRole,
} from '@/lib/agency';
function withSecurityHeaders(res: NextResponse, path?: string) { function withSecurityHeaders(res: NextResponse, path?: string) {
res.headers.set('X-Frame-Options', 'DENY'); res.headers.set('X-Frame-Options', 'DENY');
@@ -97,24 +105,22 @@ export async function middleware(req: NextRequest) {
return withSecurityHeaders(NextResponse.redirect(loginUrl)); return withSecurityHeaders(NextResponse.redirect(loginUrl));
} }
const role = userRole(user);
const agency = isAgencyRole(role);
if (user && path === '/login') { if (user && path === '/login') {
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
const dashboardUrl = req.nextUrl.clone(); const dashboardUrl = req.nextUrl.clone();
dashboardUrl.pathname = dashboardUrl.pathname = agency ? '/dashboard/agencia' : '/dashboard/kpis';
role === 'admin' || role === 'team'
? '/dashboard/agencia'
: '/dashboard/kpis';
return withSecurityHeaders(NextResponse.redirect(dashboardUrl)); return withSecurityHeaders(NextResponse.redirect(dashboardUrl));
} }
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client'; if (user && !agency && path.startsWith('/dashboard/agencia')) {
if (user && role === 'client' && path.startsWith('/dashboard/agencia')) {
const forbiddenUrl = req.nextUrl.clone(); const forbiddenUrl = req.nextUrl.clone();
forbiddenUrl.pathname = '/dashboard/kpis'; forbiddenUrl.pathname = '/dashboard/kpis';
return withSecurityHeaders(NextResponse.redirect(forbiddenUrl)); return withSecurityHeaders(NextResponse.redirect(forbiddenUrl));
} }
if (user && role === 'client' && path.startsWith('/api/admin/')) { if (user && !agency && path.startsWith('/api/admin/')) {
return withSecurityHeaders( return withSecurityHeaders(
NextResponse.json( NextResponse.json(
{ {
@@ -167,6 +173,21 @@ export async function middleware(req: NextRequest) {
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
}); });
// Clientes: no deben conservar cookies de "vista cliente" de una sesión agencia.
if (user && !agency) {
const hasView =
req.cookies.get(VIEW_TENANT_COOKIE)?.value ||
req.cookies.get(VIEW_TENANT_NAME_COOKIE)?.value;
if (hasView) {
const opts = clearedViewTenantCookieOpts(
process.env.NODE_ENV === 'production'
);
res.cookies.set(VIEW_TENANT_COOKIE, '', opts);
res.cookies.set(VIEW_TENANT_SLUG_COOKIE, '', opts);
res.cookies.set(VIEW_TENANT_NAME_COOKIE, '', opts);
}
}
return withSecurityHeaders(res); return withSecurityHeaders(res);
} }