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:
401
scripts/audit_all_brands.ts
Normal file
401
scripts/audit_all_brands.ts
Normal 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);
|
||||
});
|
||||
82
scripts/audit_four_brands.ts
Normal file
82
scripts/audit_four_brands.ts
Normal 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);
|
||||
});
|
||||
1711
scripts/complete_brandbooks.py
Normal file
1711
scripts/complete_brandbooks.py
Normal file
File diff suppressed because it is too large
Load Diff
46
scripts/enrich_thin_dna.ts
Normal file
46
scripts/enrich_thin_dna.ts
Normal 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);
|
||||
});
|
||||
132
scripts/ensure_ecp_tsp_drive.ts
Normal file
132
scripts/ensure_ecp_tsp_drive.ts
Normal 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
232
scripts/fix_brand_gaps.ts
Normal 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);
|
||||
});
|
||||
96
scripts/heal_agency_roles.ts
Normal file
96
scripts/heal_agency_roles.ts
Normal 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);
|
||||
});
|
||||
@@ -97,7 +97,7 @@ const JOBS: ClientJob[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'talento-y-deporte',
|
||||
slug: 'tyd',
|
||||
brand: 'Talento y Deporte',
|
||||
file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`,
|
||||
sheets: {
|
||||
|
||||
@@ -50,7 +50,7 @@ DRIVE_SHEETS = {
|
||||
# Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero).
|
||||
"force_sheet_months": {"Hoja 1": "2026-10"},
|
||||
},
|
||||
"talento-y-deporte": {
|
||||
"tyd": {
|
||||
"brand": "Talento y Deporte",
|
||||
"file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA",
|
||||
},
|
||||
|
||||
632
scripts/remediate_brand_truth.py
Normal file
632
scripts/remediate_brand_truth.py
Normal 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()
|
||||
135
scripts/rename_brand_slugs.ts
Normal file
135
scripts/rename_brand_slugs.ts
Normal 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);
|
||||
});
|
||||
272
scripts/split_tenants_and_fix.ts
Normal file
272
scripts/split_tenants_and_fix.ts
Normal 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);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -10,8 +11,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
||||
if (userRole !== 'admin' && userRole !== 'team') {
|
||||
if (!isAgencyRole(resolveUserRole(user))) {
|
||||
return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function GET(req: NextRequest) {
|
||||
mapped = filterDriveEntriesForClientRoot(mapped);
|
||||
if (!mapped.length && !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.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
seedBrandbook,
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import { diffFields, logChange } from '@/lib/change-history';
|
||||
import {
|
||||
buildRevertPatch,
|
||||
diffFields,
|
||||
logChange,
|
||||
} from '@/lib/change-history';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -286,6 +290,14 @@ export async function PUT(req: NextRequest) {
|
||||
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({
|
||||
tenantId,
|
||||
tenantName: label,
|
||||
@@ -306,6 +318,8 @@ export async function PUT(req: NextRequest) {
|
||||
: `Guardado Zona de marca · ${label}`,
|
||||
detail: {
|
||||
changes: changes.slice(0, 40),
|
||||
revert_patch: revertPatch,
|
||||
revertable: Object.keys(revertPatch).length > 0,
|
||||
seed: Boolean(body.seed),
|
||||
slug,
|
||||
},
|
||||
|
||||
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal 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 || [],
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
} from '@/lib/pipeline-calendar';
|
||||
import type { PublishedNetworkPost } from '@/lib/calendar-strategy';
|
||||
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 maxDuration = 60;
|
||||
@@ -57,9 +64,10 @@ export async function POST(req: NextRequest) {
|
||||
.map((t) => t.trim().slice(0, 160))
|
||||
.slice(0, 40)
|
||||
: [];
|
||||
const genOptions = (body.gen_options || body.options || null) as
|
||||
const genOptionsRaw = (body.gen_options || body.options || null) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
const genOptions = normalizeGenOptions(genOptionsRaw || undefined);
|
||||
|
||||
let slug = 'cliente';
|
||||
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({
|
||||
ym: month,
|
||||
slug,
|
||||
@@ -137,7 +197,10 @@ export async function POST(req: NextRequest) {
|
||||
brandbook,
|
||||
publishedPosts,
|
||||
avoidTitles,
|
||||
genOptions: genOptions || undefined,
|
||||
genOptions,
|
||||
linkedinStrategyUrl: linkedinStrategyUrl || undefined,
|
||||
linkedinPrompt,
|
||||
linkedinStrategyPosts,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -147,6 +210,9 @@ export async function POST(req: NextRequest) {
|
||||
source: result.source,
|
||||
brand: slug,
|
||||
brandbook: !!brandbook,
|
||||
linkedin_strategy_url: linkedinStrategyUrl || null,
|
||||
linkedin_posts_from_strategy: linkedinStrategyPosts?.length || 0,
|
||||
linkedin_schedule: result.linkedinMeta || null,
|
||||
history_months: history.length,
|
||||
network_posts: publishedPosts.length,
|
||||
});
|
||||
|
||||
262
src/app/api/change-history/undo/route.ts
Normal file
262
src/app/api/change-history/undo/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -74,10 +74,16 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { data: tenant } = await db
|
||||
.from('tenants')
|
||||
.select('id, slug, name')
|
||||
.select('id, slug, name, canva_templates')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'rrss',
|
||||
});
|
||||
|
||||
// Recent pipeline for continuity
|
||||
const { data: recentRows } = await db
|
||||
.from('creative_pipeline')
|
||||
@@ -216,6 +222,7 @@ export async function POST(req: NextRequest) {
|
||||
brandbookPrompt,
|
||||
socialHistory,
|
||||
variantsRequested: Number(body.variants_requested || 2),
|
||||
canvaTemplates,
|
||||
});
|
||||
|
||||
const { data: gr, error: grErr } = await db
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
channelsForCalendarPlatform,
|
||||
normalizeChannels,
|
||||
parseScheduledAt,
|
||||
PUBLISH_STATUSES,
|
||||
@@ -227,13 +228,7 @@ export async function POST(req: NextRequest) {
|
||||
scheduledAt = scheduledAtFromPiece(cal);
|
||||
}
|
||||
if (!channels) {
|
||||
const plat = (cal.platform || '').toLowerCase();
|
||||
channels =
|
||||
plat.includes('linkedin')
|
||||
? ['instagram']
|
||||
: plat.includes('facebook')
|
||||
? ['facebook', 'instagram']
|
||||
: ['instagram', 'facebook'];
|
||||
channels = channelsForCalendarPlatform(cal.platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
*
|
||||
* 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) {
|
||||
const supabase = await createClient();
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { syncCreativeAssetToDrive } from '@/lib/creatives-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 90;
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
@@ -170,6 +172,8 @@ export async function POST(req: NextRequest) {
|
||||
patch.asset_final = true;
|
||||
}
|
||||
|
||||
let creative: Record<string, unknown> | null = null;
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||
@@ -188,12 +192,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({
|
||||
creative: Array.isArray(data) ? data[0] : data,
|
||||
media_url: mediaUrl,
|
||||
});
|
||||
}
|
||||
|
||||
creative = Array.isArray(data) ? data[0] : data;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
@@ -204,5 +204,41 @@ export async function POST(req: NextRequest) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ creative: data, media_url: mediaUrl });
|
||||
creative = data;
|
||||
}
|
||||
|
||||
const ext =
|
||||
safeName.split('.').pop()?.toLowerCase() ||
|
||||
(mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg');
|
||||
|
||||
const drive = await syncCreativeAssetToDrive({
|
||||
tenantId: ctx.tenantId,
|
||||
clientLabel: ctx.label || 'cliente',
|
||||
channel: 'ads',
|
||||
item: {
|
||||
id: creativeId,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseGenerationRequestIdFromNotes,
|
||||
stripGenerationRequestMarker,
|
||||
} from '@/lib/rrss-generation';
|
||||
import { backfillCreativeItemToDrive } from '@/lib/creatives-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -110,7 +111,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { data: item } = await db
|
||||
.from('creative_pipeline')
|
||||
.select('id, notes, caption, format')
|
||||
.select('id, notes, caption, format, title, scheduled_at, created_at')
|
||||
.eq('id', pipelineId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
@@ -236,5 +237,42 @@ export async function POST(req: NextRequest) {
|
||||
.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 });
|
||||
}
|
||||
|
||||
@@ -38,18 +38,11 @@ export async function GET(
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = (client as any)?.canva_templates || {};
|
||||
if (
|
||||
client?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(
|
||||
(client as { canva_templates?: unknown })?.canva_templates,
|
||||
{ slug: client?.slug, channel: 'ads' }
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
@@ -65,7 +58,7 @@ export async function GET(
|
||||
meta_publish_status: client?.meta_publish_status || 'PAUSED',
|
||||
slack_channel_id: client?.slack_channel_id,
|
||||
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({
|
||||
ok: true,
|
||||
data: updated,
|
||||
drive,
|
||||
...updated,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,18 +114,11 @@ export async function POST(req: NextRequest) {
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const clientInfo = {
|
||||
id: tenant?.id || tenantId,
|
||||
@@ -227,7 +220,7 @@ export async function GET(req: NextRequest) {
|
||||
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 || [])
|
||||
.filter((row: any) => {
|
||||
@@ -236,17 +229,10 @@ export async function GET(req: NextRequest) {
|
||||
})
|
||||
.map((row: any) => {
|
||||
const { tenant, ...rest } = row;
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const item: Record<string, any> = {
|
||||
...rest,
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { cookies } from 'next/headers';
|
||||
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() {
|
||||
const supabase = await createClient();
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import AgencyPortfolioBoard from '@/components/AgencyPortfolioBoard';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -15,8 +16,7 @@ export default async function AgencyPage() {
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const role = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
||||
if (role !== 'admin' && role !== 'team') {
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
redirect('/dashboard/kpis');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { logoutAction } from '@/app/auth/actions';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
@@ -30,16 +31,26 @@ export default async function DashboardLayout({
|
||||
data: { user },
|
||||
} = 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 company =
|
||||
(user?.user_metadata?.company as string) ||
|
||||
(user?.app_metadata?.company as string) ||
|
||||
'4Ventures Central';
|
||||
const email = user?.email || 'usuario@4ventures.es';
|
||||
(user.user_metadata?.company as string) ||
|
||||
(user.app_metadata?.company as string) ||
|
||||
(isAgencyRole(role) ? '4Ventures' : 'Portal cliente');
|
||||
const email = user.email;
|
||||
const isAgency = isAgencyRole(role);
|
||||
const cookieStore = await cookies();
|
||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
// Cookies de zoom solo aplican a agencia (evita chrome cliente en ops).
|
||||
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);
|
||||
|
||||
let enabledModules: Record<DashboardModuleId, boolean> | null = null;
|
||||
@@ -94,9 +105,7 @@ export default async function DashboardLayout({
|
||||
isAgency={isAgency}
|
||||
viewName={viewName}
|
||||
enabledModules={enabledModules}
|
||||
clientLogoUrl={
|
||||
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
|
||||
}
|
||||
clientLogoUrl={!isAgency ? clientLogoUrl : null}
|
||||
brandLabel={brandLabel}
|
||||
logoutAction={logoutAction}
|
||||
>
|
||||
|
||||
@@ -68,7 +68,12 @@ function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
autoComplete="on"
|
||||
data-form-type="login"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||
Correo Electrónico
|
||||
@@ -79,10 +84,12 @@ function LoginForm() {
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,6 +105,8 @@ function LoginForm() {
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
||||
@@ -922,7 +922,7 @@ export default function AssetsLibrary({
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{isAgency
|
||||
? `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>
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando Drive…</p>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
CalendarPlus,
|
||||
ExternalLink,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
@@ -138,20 +141,67 @@ export default function CalendarCreator({
|
||||
...DEFAULT_GEN_OPTIONS,
|
||||
});
|
||||
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 base = ymNow();
|
||||
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 m = new Map<string, RrssPiece[]>();
|
||||
for (const p of plan?.pieces || []) {
|
||||
for (const p of filteredPieces) {
|
||||
const k = p.date.slice(0, 10);
|
||||
(m.get(k) || m.set(k, []).get(k)!).push(p);
|
||||
}
|
||||
return m;
|
||||
}, [plan]);
|
||||
}, [filteredPieces]);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
if (!month) return { pad: 0, days: 0 };
|
||||
@@ -213,7 +263,21 @@ export default function CalendarCreator({
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
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 =
|
||||
mode === 'regen_selected' && plan?.pieces?.length
|
||||
? new Set(
|
||||
@@ -264,6 +328,26 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
setSource(`${j.source || 'heuristic'}+regen`);
|
||||
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 {
|
||||
setPlan(j.plan);
|
||||
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 (
|
||||
<>
|
||||
@@ -523,6 +611,52 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setStep('pick')}
|
||||
@@ -601,6 +735,7 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
Ocultar
|
||||
</button>
|
||||
</div>
|
||||
{creatorTab === 'ig-fb' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{(
|
||||
[
|
||||
@@ -628,6 +763,124 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
<p className="text-[11px] font-semibold text-gray-700 mb-1">
|
||||
Días de la semana
|
||||
@@ -866,11 +1119,22 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Añadir publicación
|
||||
{creatorTab === 'linkedin'
|
||||
? 'Añadir post LinkedIn'
|
||||
: 'Añadir publicación'}
|
||||
</button>
|
||||
{dayPieces.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
@@ -906,9 +1170,40 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
style={{ background: colorFor(p) }}
|
||||
/>
|
||||
</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>
|
||||
{/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 !== 'none' ? (
|
||||
<p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Undo2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, EmptyState } from '@/components/ui';
|
||||
|
||||
@@ -52,6 +54,27 @@ function entityLabel(t: string): string {
|
||||
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> }) {
|
||||
const changes = Array.isArray(detail.changes)
|
||||
? (detail.changes as { field?: string; from?: unknown; to?: unknown }[])
|
||||
@@ -141,6 +164,8 @@ export default function ChangeHistoryView({
|
||||
const [entityType, setEntityType] = useState('');
|
||||
const [portfolioWide, setPortfolioWide] = useState(false);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -173,12 +198,42 @@ export default function ChangeHistoryView({
|
||||
void 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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Auditoría"
|
||||
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={
|
||||
<button
|
||||
type="button"
|
||||
@@ -233,6 +288,12 @@ export default function ChangeHistoryView({
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
{undoMsg ? (
|
||||
<Surface className="border-sky-200 bg-sky-50">
|
||||
<p className="text-sm text-sky-900">{undoMsg}</p>
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
<Surface padded={false}>
|
||||
{loading && !rows.length ? (
|
||||
<div className="p-8 text-sm text-gray-500 flex items-center gap-2">
|
||||
@@ -292,19 +353,58 @@ export default function ChangeHistoryView({
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{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>
|
||||
</div>
|
||||
<History className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
|
||||
</div>
|
||||
</button>
|
||||
{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 || {}} />
|
||||
{row.source ? (
|
||||
<p className="mt-2 text-[11px] text-gray-400">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Origen: {row.source}
|
||||
</p>
|
||||
) : 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>
|
||||
) : null}
|
||||
</li>
|
||||
|
||||
@@ -1713,6 +1713,9 @@ function DetailModal({
|
||||
const [chFb, setChFb] = useState(
|
||||
(item.channels || ['instagram', 'facebook']).includes('facebook')
|
||||
);
|
||||
const [chLi, setChLi] = useState(
|
||||
(item.channels || []).includes('linkedin')
|
||||
);
|
||||
const [editTitle, setEditTitle] = useState(item.title);
|
||||
const [editCaption, setEditCaption] = useState(item.caption || '');
|
||||
const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id));
|
||||
@@ -1735,6 +1738,7 @@ function DetailModal({
|
||||
const ch = item.channels || ['instagram', 'facebook'];
|
||||
setChIg(ch.includes('instagram'));
|
||||
setChFb(ch.includes('facebook'));
|
||||
setChLi(ch.includes('linkedin'));
|
||||
setEditTitle(item.title);
|
||||
setEditCaption(item.caption || '');
|
||||
setSyncCal(Boolean(item.calendar_piece_id));
|
||||
@@ -1764,6 +1768,7 @@ function DetailModal({
|
||||
const channels: PublishChannel[] = [];
|
||||
if (chIg) channels.push('instagram');
|
||||
if (chFb) channels.push('facebook');
|
||||
if (chLi) channels.push('linkedin');
|
||||
const igIso = schedIg ? new Date(schedIg).toISOString() : null;
|
||||
const fbIso = schedFb ? new Date(schedFb).toISOString() : null;
|
||||
const primary =
|
||||
@@ -2062,6 +2067,17 @@ function DetailModal({
|
||||
/>
|
||||
Facebook
|
||||
</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>
|
||||
{chIg && chFb ? (
|
||||
<label className="flex items-start gap-2 text-[11px] text-gray-800">
|
||||
|
||||
@@ -189,13 +189,17 @@ export default function DashboardShell({
|
||||
|
||||
const visibleNav = NAV_ITEMS.filter((item) => moduleOn(item.moduleId));
|
||||
|
||||
const showClientBrand = Boolean(clientLogoUrl);
|
||||
const title = showClientBrand ? brandLabel || company : '4VENTURES';
|
||||
const subtitle = showClientBrand
|
||||
? isAgency
|
||||
? 'Vista cliente'
|
||||
: 'Portal cliente'
|
||||
: 'Centro de Mando';
|
||||
// Agencia siempre mantiene identidad 4Ventures (aunque esté "viendo" un cliente).
|
||||
const title = isAgency
|
||||
? '4VENTURES'
|
||||
: clientLogoUrl
|
||||
? brandLabel || company
|
||||
: '4VENTURES';
|
||||
const subtitle = isAgency
|
||||
? viewName
|
||||
? `Agencia · viendo ${viewName}`
|
||||
: 'Centro de Mando'
|
||||
: 'Portal cliente';
|
||||
|
||||
const sidebar = (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -317,8 +321,15 @@ export default function DashboardShell({
|
||||
<span className="text-[10px] text-gray-500 capitalize truncate">
|
||||
{company}
|
||||
</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">
|
||||
{role}
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,12 @@ export type RrssPiece = {
|
||||
pipeline_id?: string;
|
||||
cover_path?: string | null;
|
||||
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 = {
|
||||
@@ -853,6 +859,43 @@ export default function RrssMonthView({
|
||||
{selected.hashtags && (
|
||||
<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 && (
|
||||
<div className="rounded-xl border border-gray-200 p-3 space-y-2">
|
||||
|
||||
@@ -35,22 +35,75 @@ export function isAgencyRole(role: string | undefined | null): boolean {
|
||||
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
|
||||
* so clients cannot self-elevate via user_metadata.updateUser.
|
||||
* Emails @4ventures.es son agencia salvo que app_metadata.role === 'client'
|
||||
* (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: {
|
||||
email?: string | null;
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
} | null): string {
|
||||
const app = (user?.app_metadata?.role as string | undefined)?.trim();
|
||||
if (app === 'admin' || app === 'team' || app === 'client') return app;
|
||||
// Legacy clients may only have user_metadata.role=client — never admin/team.
|
||||
if (app === 'admin' || app === 'team') return app;
|
||||
// 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();
|
||||
if (um === '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: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/** Drive folder names created (or reserved) by the app under the client root. */
|
||||
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). */
|
||||
const APP_PORTAL_FOLDER_PREFIXES = [
|
||||
@@ -16,6 +16,8 @@ const APP_PORTAL_FOLDER_PREFIXES = [
|
||||
'calendario',
|
||||
'pipeline',
|
||||
'pipeline-creatividades',
|
||||
'creatividades-ads',
|
||||
'creatividades-rrss',
|
||||
'ads',
|
||||
'meta',
|
||||
'centro-de-mando',
|
||||
|
||||
@@ -2011,8 +2011,21 @@ export function brandbookToPrompt(book: Brandbook): string {
|
||||
`Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`,
|
||||
(book.preferred_angles?.length || 0) > 0 &&
|
||||
`Á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 &&
|
||||
`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.claims_forbidden.length &&
|
||||
`Prohibido: ${book.claims_forbidden.join('; ')}`,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
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 = {
|
||||
/** Nº de posts feed (Post estático). */
|
||||
posts: number;
|
||||
@@ -12,6 +14,19 @@ export type CalendarGenOptions = {
|
||||
reels: number;
|
||||
/** Nº de Carruseles. */
|
||||
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).
|
||||
* Por defecto Lun–Vie.
|
||||
@@ -29,6 +44,8 @@ export type CalendarGenOptions = {
|
||||
* (modo regen_selected).
|
||||
*/
|
||||
regenInstructions?: string;
|
||||
/** Instrucciones solo para piezas LinkedIn. */
|
||||
linkedinInstructions?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
|
||||
@@ -36,10 +53,14 @@ export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
|
||||
stories: 8,
|
||||
reels: 6,
|
||||
carruseles: 4,
|
||||
linkedinPosts: 0,
|
||||
linkedinMode: 'from_strategy',
|
||||
linkedinStrategyUrl: '',
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
allowSameDayMix: true,
|
||||
instructions: '',
|
||||
regenInstructions: '',
|
||||
linkedinInstructions: '',
|
||||
};
|
||||
|
||||
export function normalizeGenOptions(
|
||||
@@ -51,15 +72,26 @@ export function normalizeGenOptions(
|
||||
const weekdays = Array.isArray(base.weekdays)
|
||||
? [...new Set(base.weekdays.map((d) => Number(d)).filter((d) => d >= 0 && d <= 6))]
|
||||
: DEFAULT_GEN_OPTIONS.weekdays;
|
||||
const modeRaw = String(base.linkedinMode || 'from_strategy').toLowerCase();
|
||||
const linkedinMode: LinkedInGenMode =
|
||||
modeRaw === 'ideas' || modeRaw === 'mixed' ? modeRaw : 'from_strategy';
|
||||
return {
|
||||
posts: clamp(base.posts),
|
||||
stories: clamp(base.stories),
|
||||
reels: clamp(base.reels),
|
||||
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,
|
||||
allowSameDayMix: base.allowSameDayMix !== false,
|
||||
instructions: String(base.instructions || '').trim().slice(0, 4000),
|
||||
regenInstructions: String(base.regenInstructions || '').trim().slice(0, 4000),
|
||||
linkedinInstructions: String(base.linkedinInstructions || '')
|
||||
.trim()
|
||||
.slice(0, 4000),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/** Shared brand DNA + title helpers for calendar strategy / generate. */
|
||||
|
||||
import {
|
||||
brandSiblingNote,
|
||||
resolveBrandSlug,
|
||||
} from '@/lib/tenant-brand-aliases';
|
||||
|
||||
export type TenantBrandBrief = {
|
||||
slug: string;
|
||||
label: string;
|
||||
@@ -11,19 +16,67 @@ export type TenantBrandBrief = {
|
||||
};
|
||||
|
||||
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||
'talento-y-deporte': {
|
||||
vertical: 'Educación deportiva / becas USA',
|
||||
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
|
||||
/** TYD — Talento y Deporte (deportivo; hermano de TSP) */
|
||||
tyd: {
|
||||
vertical: 'Becas deportivas USA / educación deportiva',
|
||||
tone: 'Inspirador, cercano, ambicioso; historias de atletas-alumnos',
|
||||
pillars: [
|
||||
'nuevo-alumno',
|
||||
'beca-academica',
|
||||
'beca-deportiva',
|
||||
'campus',
|
||||
'objeciones',
|
||||
'tips-ingles',
|
||||
'vida-deportiva',
|
||||
],
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
|
||||
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
|
||||
formats: { post: 0.32, reel: 0.28, stories: 0.28, carrusel: 0.12 },
|
||||
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': {
|
||||
vertical: 'Certificación Halal / exportación',
|
||||
@@ -55,6 +108,21 @@ const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||
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.',
|
||||
},
|
||||
'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': {
|
||||
vertical: 'Escuela de idiomas',
|
||||
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 },
|
||||
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 {
|
||||
const base = BRANDS[slug] || {
|
||||
const key = resolveBrandSlug(slug);
|
||||
const base = BRANDS[key] || {
|
||||
vertical: 'Marca / servicios',
|
||||
tone: 'Profesional y claro',
|
||||
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
|
||||
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
|
||||
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 {
|
||||
|
||||
@@ -42,12 +42,17 @@ export type GeneratePreviewInput = {
|
||||
avoidTitles?: string[];
|
||||
/** Cantidades, días, mix e instrucciones del operador. */
|
||||
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 = {
|
||||
plan: RrssMonthPlan;
|
||||
rationale: string;
|
||||
source: 'anthropic' | 'strategy' | 'heuristic';
|
||||
linkedinMeta?: import('@/lib/calendar-strategy').LinkedInScheduleMeta;
|
||||
};
|
||||
|
||||
function colorFor(format: string, kind: string, platform: string): string {
|
||||
@@ -89,6 +94,10 @@ async function anthropicStrategist(
|
||||
input.brandbook,
|
||||
input.publishedPosts || []
|
||||
);
|
||||
if (input.linkedinPrompt) dna.linkedinPrompt = input.linkedinPrompt;
|
||||
if (input.linkedinStrategyUrl) {
|
||||
dna.linkedinStrategyUrl = input.linkedinStrategyUrl;
|
||||
}
|
||||
const go = normalizeGenOptions(input.genOptions);
|
||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||
const prompt = strategistSystemPrompt({
|
||||
@@ -97,14 +106,16 @@ async function anthropicStrategist(
|
||||
dna,
|
||||
trends,
|
||||
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(
|
||||
3,
|
||||
Math.min(
|
||||
8,
|
||||
go.posts + go.stories + go.reels + go.carruseles
|
||||
)
|
||||
1,
|
||||
Math.min(8, Math.max(igTotal + go.linkedinPosts, go.linkedinPosts || igTotal || 3))
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -148,13 +159,35 @@ async function anthropicStrategist(
|
||||
}))
|
||||
.filter((s) => s.text)
|
||||
: 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 {
|
||||
id: newPieceId(date, title),
|
||||
date,
|
||||
time: String(p.time || '13:00').slice(0, 5),
|
||||
platform,
|
||||
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),
|
||||
hook: String(p.hook || title).slice(0, 180),
|
||||
body: String(p.body || title),
|
||||
@@ -172,23 +205,48 @@ async function anthropicStrategist(
|
||||
interactive_story: p.interactive_story
|
||||
? String(p.interactive_story).slice(0, 40)
|
||||
: undefined,
|
||||
strategy_url,
|
||||
strategy_post_id,
|
||||
strategy_angle,
|
||||
meta_line:
|
||||
strategy_url
|
||||
? `${strategy_url}${strategy_post_id ? `#${strategy_post_id}` : ''}`
|
||||
: undefined,
|
||||
} as RrssPiece;
|
||||
})
|
||||
.filter(Boolean) as RrssPiece[];
|
||||
|
||||
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);
|
||||
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 = [
|
||||
`${pieces.length} piezas`,
|
||||
`${pieces.filter((p) => /reel/i.test(p.format || '')).length} Reels`,
|
||||
liN ? `${liN} LinkedIn` : '',
|
||||
'IA strategist',
|
||||
];
|
||||
].filter(Boolean);
|
||||
plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||
return {
|
||||
plan,
|
||||
rationale: parsed.rationale || fallback.rationale,
|
||||
rationale: [parsed.rationale, fallback.rationale]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
source: 'anthropic',
|
||||
linkedinMeta: fallback.linkedinMeta,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
@@ -205,36 +263,65 @@ export async function generateMonthPreview(
|
||||
(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 slotOpts: BuildSlotsOpts & {
|
||||
instructions?: string;
|
||||
regenInstructions?: string;
|
||||
linkedinInstructions?: string;
|
||||
linkedinMode?: string;
|
||||
} = {
|
||||
posts: go.posts,
|
||||
stories: go.stories,
|
||||
reels: go.reels,
|
||||
carruseles: go.carruseles,
|
||||
linkedinPosts: go.linkedinPosts,
|
||||
weekdays: go.weekdays,
|
||||
allowSameDayMix: go.allowSameDayMix,
|
||||
instructions: go.instructions,
|
||||
regenInstructions: go.regenInstructions,
|
||||
linkedinInstructions: go.linkedinInstructions,
|
||||
linkedinMode: go.linkedinMode,
|
||||
};
|
||||
|
||||
const strategist = strategyMonthPreview({
|
||||
...input,
|
||||
history: cleanHistory,
|
||||
history: cleanHistory.length ? cleanHistory : liHistory,
|
||||
linkedinHistory: liHistory,
|
||||
brandbook: input.brandbook,
|
||||
publishedPosts: input.publishedPosts || [],
|
||||
avoidTitles: input.avoidTitles,
|
||||
genOptions: slotOpts,
|
||||
linkedinStrategyPosts: input.linkedinStrategyPosts,
|
||||
linkedinStrategyUrl: input.linkedinStrategyUrl,
|
||||
linkedinPrompt: input.linkedinPrompt,
|
||||
});
|
||||
const fallback: GeneratePreviewResult = {
|
||||
plan: strategist.plan,
|
||||
rationale: strategist.rationale,
|
||||
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(
|
||||
{
|
||||
...input,
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
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. */
|
||||
export type PublishedNetworkPost = {
|
||||
@@ -45,6 +52,9 @@ export type ClientDna = {
|
||||
networkPostsUsed: number;
|
||||
/** Muestras de captiones/títulos publicados (no copiar literal). */
|
||||
networkSamples: string[];
|
||||
/** Bloque de estrategia LinkedIn (URL + posts + brandbook B2B). */
|
||||
linkedinPrompt?: string;
|
||||
linkedinStrategyUrl?: string;
|
||||
};
|
||||
|
||||
export type TrendBeat = {
|
||||
@@ -474,6 +484,8 @@ export function extractClientDna(
|
||||
calendarMonthsUsed,
|
||||
networkPostsUsed,
|
||||
networkSamples: networkSamplesFromPosts(publishedPosts),
|
||||
linkedinPrompt: brandbookLinkedInPromptBlock(brandbook) || undefined,
|
||||
linkedinStrategyUrl: brandbook?.linkedin_strategy_url || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,6 +500,7 @@ export type BuildSlotsOpts = {
|
||||
stories?: number;
|
||||
reels?: number;
|
||||
carruseles?: number;
|
||||
linkedinPosts?: number;
|
||||
weekdays?: number[];
|
||||
/** Si true, se pueden apilar story+post / reel+story el mismo día. */
|
||||
allowSameDayMix?: boolean;
|
||||
@@ -903,8 +916,245 @@ export type StrategyPreviewResult = {
|
||||
source: 'strategy';
|
||||
trends: TrendBeat[];
|
||||
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: {
|
||||
ym: string;
|
||||
slug: string;
|
||||
@@ -912,7 +1162,17 @@ export function strategyMonthPreview(input: {
|
||||
history: RrssMonthPlan[];
|
||||
brandbook?: Brandbook | null;
|
||||
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 {
|
||||
const dna = extractClientDna(
|
||||
input.slug,
|
||||
@@ -921,6 +1181,11 @@ export function strategyMonthPreview(input: {
|
||||
input.brandbook,
|
||||
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 slots = buildMonthSlots(input.ym, dna, input.genOptions);
|
||||
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);
|
||||
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 = [
|
||||
`${pieces.length} piezas`,
|
||||
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
|
||||
`${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',
|
||||
];
|
||||
].filter(Boolean);
|
||||
plan.pieces = pieces;
|
||||
|
||||
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'}.`,
|
||||
`Fuentes ADN: ${dna.calendarMonthsUsed} mes(es) de calendario editorial · ${dna.networkPostsUsed} publicación(es) reales en redes · ${dna.brandbookPrompt ? 'brandbook' : 'sin brandbook'}.`,
|
||||
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.`,
|
||||
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
|
||||
.slice(0, 4)
|
||||
.map((t) => t.label)
|
||||
.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.`,
|
||||
go?.instructions
|
||||
? `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
|
||||
? 'Brandbook activo como constraint creativo.'
|
||||
: dna.voiceNotes,
|
||||
@@ -1008,7 +1312,14 @@ export function strategyMonthPreview(input: {
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return { plan, rationale, source: 'strategy', trends, dna };
|
||||
return {
|
||||
plan,
|
||||
rationale,
|
||||
source: 'strategy',
|
||||
trends,
|
||||
dna,
|
||||
...(liMeta ? { linkedinMeta: liMeta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function strategistSystemPrompt(input: {
|
||||
@@ -1020,6 +1331,8 @@ export function strategistSystemPrompt(input: {
|
||||
genOptions?: BuildSlotsOpts & {
|
||||
instructions?: string;
|
||||
regenInstructions?: string;
|
||||
linkedinInstructions?: string;
|
||||
linkedinMode?: string;
|
||||
};
|
||||
}): string {
|
||||
const netBlock =
|
||||
@@ -1036,30 +1349,50 @@ export function strategistSystemPrompt(input: {
|
||||
.join('\n')}\n`
|
||||
: '';
|
||||
const go = input.genOptions;
|
||||
const liN = go?.linkedinPosts ?? 0;
|
||||
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.`;
|
||||
const instrBlock = [
|
||||
go?.instructions ? `\nINSTRUCCIONES OPERADOR:\n${go.instructions}\n` : '',
|
||||
go?.regenInstructions
|
||||
? `\nINSTRUCCIONES SOLO PARA REGENERADAS:\n${go.regenInstructions}\n`
|
||||
: '',
|
||||
go?.linkedinInstructions
|
||||
? `\nINSTRUCCIONES LINKEDIN:\n${go.linkedinInstructions}\n`
|
||||
: '',
|
||||
].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}).
|
||||
NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
|
||||
1) Zona de marca / brandbook
|
||||
2) Histórico de calendarios editoriales (${input.dna.calendarMonthsUsed} meses)
|
||||
3) Publicaciones reales ya salidas en redes (${input.dna.networkPostsUsed} piezas)
|
||||
4) Estrategia LinkedIn B2B (si existe)
|
||||
ADN series: ${input.dna.signatureSeries.join(', ')}.
|
||||
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)}%.
|
||||
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
|
||||
${liBlock}
|
||||
${netBlock}${avoidBlock}${instrBlock}
|
||||
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
||||
${mixBlock}
|
||||
Tendencias del mes:
|
||||
${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.
|
||||
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).`;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,57 @@ export function diffFields(
|
||||
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 {
|
||||
try {
|
||||
const s = JSON.stringify(v);
|
||||
|
||||
@@ -794,18 +794,11 @@ export async function recomposeCreativeInCanva(
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const clientInfo = {
|
||||
id: tenant?.id || tenantId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { scoreNameToTenant } from '@/lib/match';
|
||||
|
||||
export const DRIVE_PROVIDER = 'google_drive';
|
||||
|
||||
@@ -258,44 +259,18 @@ export function scoreFolderToTenant(
|
||||
tenantName: string,
|
||||
tenantSlug: string
|
||||
): 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
|
||||
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
|
||||
const candidates = [folderName, stripped];
|
||||
const aliasMap: Record<string, string[]> = {
|
||||
bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'],
|
||||
'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)
|
||||
return Math.max(
|
||||
scoreNameToTenant(folderName, tenantName, tenantSlug),
|
||||
scoreNameToTenant(stripped, tenantName, tenantSlug)
|
||||
);
|
||||
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[]> {
|
||||
|
||||
497
src/lib/linkedin-strategy.ts
Normal file
497
src/lib/linkedin-strategy.ts
Normal 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/’/g, "'")
|
||||
.replace(/‘/g, "'")
|
||||
.replace(/“/g, '"')
|
||||
.replace(/”/g, '"')
|
||||
.replace(/–/g, '–')
|
||||
.replace(/—/g, '—')
|
||||
.replace(/ /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;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Shared fuzzy match: account/folder name ↔ tenant name/slug. */
|
||||
|
||||
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
|
||||
|
||||
export function normalizeName(raw: string): string {
|
||||
return raw
|
||||
.normalize('NFD')
|
||||
@@ -10,6 +12,27 @@ export function normalizeName(raw: string): string {
|
||||
.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(
|
||||
accountName: string,
|
||||
tenantName: string,
|
||||
@@ -17,8 +40,23 @@ export function scoreNameToTenant(
|
||||
): number {
|
||||
const f = normalizeName(accountName);
|
||||
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;
|
||||
|
||||
// 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 (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
@@ -37,6 +75,9 @@ export function scoreNameToTenant(
|
||||
'los',
|
||||
'las',
|
||||
'del',
|
||||
'talento', // too weak alone (TYD vs TSP)
|
||||
'formacion',
|
||||
'escuela',
|
||||
]);
|
||||
const ft = new Set(
|
||||
f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t))
|
||||
|
||||
@@ -615,6 +615,40 @@ async function publishFbPost(
|
||||
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(
|
||||
item: PipelinePublishRow,
|
||||
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
|
||||
@@ -627,11 +661,33 @@ export async function publishPipelineItem(
|
||||
...item,
|
||||
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);
|
||||
if (!resolved.targets) {
|
||||
if (wantLi) {
|
||||
const li = await publishLinkedInPost(cleanItem);
|
||||
errors.push(li.error || 'LinkedIn no publicado');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: resolved.error,
|
||||
error: [resolved.error, ...errors].filter(Boolean).join(' · '),
|
||||
channels,
|
||||
sanitizedCaption: scrub.caption,
|
||||
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 igPermalink: 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 (!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 fbOk = !wantFb || Boolean(fbPostId);
|
||||
const ok = igOk && fbOk && errors.length === 0;
|
||||
const ok = igOk && fbOk && liOk && errors.length === 0;
|
||||
|
||||
return {
|
||||
ok,
|
||||
|
||||
@@ -1,187 +1,27 @@
|
||||
/**
|
||||
* Mirror creative_pipeline uploads into the client's Google Drive folder:
|
||||
*
|
||||
* {carpeta-cliente}/
|
||||
* Pipeline-creatividades_{nombre-cliente}/
|
||||
* YYYY/
|
||||
* MM/
|
||||
* DD-MM-YYYY_formato_hook[-N].ext
|
||||
* RRSS pipeline → Drive mirror (delegates to creatives-drive).
|
||||
* Folder: Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
|
||||
*/
|
||||
import { slugify } from '@/lib/calendar-import';
|
||||
import {
|
||||
applyMatches,
|
||||
driveConfigured,
|
||||
ensureChildFolder,
|
||||
getTenantDriveFolderId,
|
||||
listClientFoldersUnderClientes,
|
||||
listFilesInFolder,
|
||||
matchFoldersToTenants,
|
||||
uploadToDrive,
|
||||
getValidAccessToken,
|
||||
} from '@/lib/google-drive';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
backfillCreativeItemToDrive,
|
||||
resolveOrLinkTenantDriveFolder,
|
||||
syncCreativeAssetToDrive,
|
||||
type CreativeDriveItem,
|
||||
} from '@/lib/creatives-drive';
|
||||
|
||||
export type PipelineDriveItem = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
format?: string | null;
|
||||
caption?: string | null;
|
||||
notes?: string | null;
|
||||
scheduled_at?: string | null;
|
||||
created_at?: string | null;
|
||||
export type PipelineDriveItem = CreativeDriveItem & {
|
||||
cover_path?: string | null;
|
||||
slide_paths?: unknown;
|
||||
};
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
export {
|
||||
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: {
|
||||
tenantId: string;
|
||||
clientLabel: string;
|
||||
@@ -191,157 +31,22 @@ export async function syncPipelineAssetToDrive(opts: {
|
||||
ext: string;
|
||||
slot: 'cover' | 'slide';
|
||||
slideIndex: number;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
warning?: string;
|
||||
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,
|
||||
}) {
|
||||
return syncCreativeAssetToDrive({
|
||||
...opts,
|
||||
channel: 'rrss',
|
||||
});
|
||||
|
||||
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: {
|
||||
tenantId: string;
|
||||
clientLabel: string;
|
||||
item: PipelineDriveItem;
|
||||
}): Promise<{ ok: boolean; results: { slot: string; ok: boolean; detail: string }[] }> {
|
||||
const svc = serviceSupabase();
|
||||
const results: { slot: string; ok: boolean; detail: string }[] = [];
|
||||
if (!svc) {
|
||||
return { ok: false, results: [{ slot: '-', ok: false, detail: 'no service' }] };
|
||||
}
|
||||
|
||||
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({
|
||||
}) {
|
||||
return backfillCreativeItemToDrive({
|
||||
tenantId: opts.tenantId,
|
||||
clientLabel: opts.clientLabel,
|
||||
channel: 'rrss',
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -215,6 +215,28 @@ export async function processDuePipelinePublishes(opts?: {
|
||||
let igPermalink: 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')) {
|
||||
const ig = await publishPipelineItem(item, { skipFb: true });
|
||||
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 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 const PUBLISH_STATUSES = [
|
||||
@@ -22,6 +22,16 @@ export function normalizeChannels(raw: unknown): PublishChannel[] {
|
||||
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. */
|
||||
export function parseScheduledAt(raw: unknown): string | null | undefined {
|
||||
if (raw === null) return null;
|
||||
|
||||
@@ -108,6 +108,8 @@ export type RrssGenerationBriefInput = {
|
||||
socialHistory?: unknown;
|
||||
variantsRequested?: number;
|
||||
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) {
|
||||
@@ -119,6 +121,7 @@ export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
|
||||
client_id: input.tenantId,
|
||||
client_slug: input.clientSlug,
|
||||
client_name: input.clientName,
|
||||
canva_templates: input.canvaTemplates || {},
|
||||
reason: 'calendario' as const,
|
||||
source_creative_id: null,
|
||||
source_pipeline_id: input.pipelineId,
|
||||
|
||||
@@ -579,12 +579,41 @@ const TENANT_DOC_ALIASES: Record<string, string[]> = {
|
||||
'delgado-abogados': ['Delgado Abogados', 'Delgado'],
|
||||
'equinsa-parking': ['Equinsa', 'Equinsa Parking'],
|
||||
'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'],
|
||||
gravitae: ['Gravitae'],
|
||||
menttum: ['Menttum'],
|
||||
'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'],
|
||||
'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'],
|
||||
'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'],
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
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) {
|
||||
res.headers.set('X-Frame-Options', 'DENY');
|
||||
@@ -97,24 +105,22 @@ export async function middleware(req: NextRequest) {
|
||||
return withSecurityHeaders(NextResponse.redirect(loginUrl));
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const agency = isAgencyRole(role);
|
||||
|
||||
if (user && path === '/login') {
|
||||
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
|
||||
const dashboardUrl = req.nextUrl.clone();
|
||||
dashboardUrl.pathname =
|
||||
role === 'admin' || role === 'team'
|
||||
? '/dashboard/agencia'
|
||||
: '/dashboard/kpis';
|
||||
dashboardUrl.pathname = agency ? '/dashboard/agencia' : '/dashboard/kpis';
|
||||
return withSecurityHeaders(NextResponse.redirect(dashboardUrl));
|
||||
}
|
||||
|
||||
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
|
||||
if (user && role === 'client' && path.startsWith('/dashboard/agencia')) {
|
||||
if (user && !agency && path.startsWith('/dashboard/agencia')) {
|
||||
const forbiddenUrl = req.nextUrl.clone();
|
||||
forbiddenUrl.pathname = '/dashboard/kpis';
|
||||
return withSecurityHeaders(NextResponse.redirect(forbiddenUrl));
|
||||
}
|
||||
|
||||
if (user && role === 'client' && path.startsWith('/api/admin/')) {
|
||||
if (user && !agency && path.startsWith('/api/admin/')) {
|
||||
return withSecurityHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
@@ -167,6 +173,21 @@ export async function middleware(req: NextRequest) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user