From 7ed6e641cbade451289071524cf1a15b4b7bcee0 Mon Sep 17 00:00:00 2001
From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local>
Date: Thu, 24 Sep 2026 12:41:51 +0200
Subject: [PATCH] feat: al conectar Google, auto-emparejar Drive/GA4/GSC/Ads en
fichas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bootstrap unificado post-OAuth + botón Emparejar todo. Meta discover
también intenta FB/IG pages con el token system user (API 4V2030).
Co-authored-by: Cursor
---
docs/OPERATOR.md | 3 +-
src/app/api/admin/drive/callback/route.ts | 47 ++---
src/app/api/admin/google/organic/route.ts | 36 +++-
src/components/AdsDiscoverPanel.tsx | 12 +-
src/components/DriveConnectPanel.tsx | 14 +-
src/components/GoogleOrganicPanel.tsx | 46 +++--
src/lib/ads-discovery.ts | 84 ++++++++-
src/lib/agency-google-bootstrap.ts | 207 ++++++++++++++++++++++
8 files changed, 374 insertions(+), 75 deletions(-)
create mode 100644 src/lib/agency-google-bootstrap.ts
diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md
index 1adfedb..37fe88f 100644
--- a/docs/OPERATOR.md
+++ b/docs/OPERATOR.md
@@ -8,7 +8,8 @@
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente)
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
-- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → scopes Drive + Analytics readonly + Search Console. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Google. Tras consent: panel **Google orgánico** → Descubrir/emparejar (sync automático). Orgánico se refresca al abrir la vista si faltan datos / sync >6h.
+- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → scopes Drive + Analytics readonly + Search Console. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Google. Tras consent: **auto-bootstrap** (Drive + GA4 + GSC + Google Ads MCC → fichas + sync Orgánico). Repetir: panel 2 → Emparejar todo Google.
+- Meta (API 4V2030 / system user): pegar token en Agencia → Descubrir ambos. Empareja Meta Ads + páginas Facebook/Instagram a fichas (si el token tiene `pages_show_list` / business).
- Cron diario (recomendado): env `CRON_SECRET` en Dokploy + schedule:
`curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync`
(actualiza últimos 7 días de todos los clientes con GA4/GSC conectados).
diff --git a/src/app/api/admin/drive/callback/route.ts b/src/app/api/admin/drive/callback/route.ts
index 7ec90b3..50db51a 100644
--- a/src/app/api/admin/drive/callback/route.ts
+++ b/src/app/api/admin/drive/callback/route.ts
@@ -1,16 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
-import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
+import { isAgencyRole, userRole } from '@/lib/agency';
import {
- applyMatches,
exchangeCode,
fetchGoogleEmail,
- listDriveFolders,
- matchFoldersToTenants,
saveAgencyOAuth,
} from '@/lib/google-drive';
+import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
export const dynamic = 'force-dynamic';
+export const maxDuration = 120;
function appUrl(): string {
return (
@@ -19,7 +18,7 @@ function appUrl(): string {
);
}
-/** GET — OAuth callback: save tokens + auto-match folders → tenants. */
+/** GET — OAuth callback: save tokens + auto-emparejar Drive/GA4/GSC/Ads → fichas. */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
@@ -56,35 +55,27 @@ export async function GET(req: NextRequest) {
email,
});
- // Auto-match folders (non-fatal: tokens already saved)
let linked = 0;
let ambiguous = 0;
+ let bootstrapMsg = '';
try {
- const headers = serviceHeaders();
- if (headers) {
- const tRes = await fetch(
- `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
- { headers, cache: 'no-store' }
- );
- if (tRes.ok) {
- const tenants = (await tRes.json()) as {
- id: string;
- name: string;
- slug: string;
- }[];
- const folders = await listDriveFolders();
- const matches = await matchFoldersToTenants(tenants, folders);
- const applied = await applyMatches(matches);
- linked = applied.linked;
- ambiguous = matches.filter((m) => m.status === 'ambiguous').length;
- }
- }
- } catch {
- // match can fail without undoing OAuth
+ const boot = await bootstrapAgencyGoogle({
+ includeGoogleAds: true,
+ syncOrganic: true,
+ organicDays: 28,
+ });
+ linked = boot.drive?.linked || 0;
+ ambiguous = boot.drive?.ambiguous || 0;
+ bootstrapMsg = boot.message;
+ } catch (e) {
+ bootstrapMsg = e instanceof Error ? e.message : 'bootstrap failed';
}
const res = NextResponse.redirect(
- `${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}`
+ `${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}` +
+ (bootstrapMsg
+ ? `&boot=${encodeURIComponent(bootstrapMsg.slice(0, 160))}`
+ : '')
);
res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
return res;
diff --git a/src/app/api/admin/google/organic/route.ts b/src/app/api/admin/google/organic/route.ts
index 70ec94f..9779c84 100644
--- a/src/app/api/admin/google/organic/route.ts
+++ b/src/app/api/admin/google/organic/route.ts
@@ -7,6 +7,7 @@ import {
userRole,
} from '@/lib/agency';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
+import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
import {
applyOrganicMatches,
listGa4Properties,
@@ -90,7 +91,8 @@ export async function GET() {
/**
* POST
- * - { action: 'match', apply?: boolean } → match + optional write integrations
+ * - { action: 'bootstrap' } → Drive + GA4 + GSC + Google Ads → fichas + sync
+ * - { action: 'match', apply? } → solo GA4/GSC
* - { action: 'sync', tenantId?, days? } → sync organic_daily
*/
export async function POST(req: NextRequest) {
@@ -101,17 +103,38 @@ export async function POST(req: NextRequest) {
};
const body = await req.json().catch(() => ({}));
- const action = body.action === 'sync' ? 'sync' : 'match';
+ const action =
+ body.action === 'sync'
+ ? 'sync'
+ : body.action === 'bootstrap'
+ ? 'bootstrap'
+ : 'match';
+
+ if (action === 'bootstrap') {
+ try {
+ const result = await bootstrapAgencyGoogle({
+ includeGoogleAds: body.includeGoogleAds !== false,
+ syncOrganic: body.syncOrganic !== false,
+ organicDays: Number(body.days) || 28,
+ });
+ return NextResponse.json(result, { status: result.ok ? 200 : 422 });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : 'bootstrap error' },
+ { status: 500 }
+ );
+ }
+ }
if (action === 'match') {
try {
const [ga4, gsc, tRes] = await Promise.all([
listGa4Properties(),
listGscSites(),
- fetch(`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, {
- headers,
- cache: 'no-store',
- }),
+ fetch(
+ `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
+ { headers, cache: 'no-store' }
+ ),
]);
if (!tRes.ok) {
return NextResponse.json(
@@ -155,7 +178,6 @@ export async function POST(req: NextRequest) {
}
}
- // sync — rolling window; default 28 on first fill / manual force
const days = Math.min(90, Math.max(3, Number(body.days) || 28));
const tenantFilter =
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
diff --git a/src/components/AdsDiscoverPanel.tsx b/src/components/AdsDiscoverPanel.tsx
index d5953bb..4844e21 100644
--- a/src/components/AdsDiscoverPanel.tsx
+++ b/src/components/AdsDiscoverPanel.tsx
@@ -86,7 +86,11 @@ export default function AdsDiscoverPanel() {
const parts = [];
if (data.meta) {
parts.push(
- `Meta: ${data.meta.linked}/${data.meta.accounts} enlazadas` +
+ `Meta Ads: ${data.meta.linked}/${data.meta.accounts}` +
+ (data.meta.pagesLinked
+ ? ` · FB ${data.meta.pagesLinked}`
+ : '') +
+ (data.meta.igLinked ? ` · IG ${data.meta.igLinked}` : '') +
(data.meta.error ? ` (${data.meta.error})` : '')
);
}
@@ -179,9 +183,9 @@ export default function AdsDiscoverPanel() {
Meta Ads + Google Ads (agencia)
- Lista cuentas accesibles y las enlaza a cada cliente por nombre.
- Google Ads ya puede emparejarse; Meta cuando tengas el token de
- App Review.
+ Lista cuentas accesibles y las enlaza a cada ficha por nombre.
+ Meta Ads + páginas FB/IG usan el token system user (API 4V2030 /
+ App Review). Google Ads con el MCC de agencia.
diff --git a/src/components/DriveConnectPanel.tsx b/src/components/DriveConnectPanel.tsx
index 679a918..e0f9f08 100644
--- a/src/components/DriveConnectPanel.tsx
+++ b/src/components/DriveConnectPanel.tsx
@@ -37,11 +37,14 @@ export default function DriveConnectPanel() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('drive') === 'ok') {
+ const boot = params.get('boot');
setMsg(
- `Google conectado. Enlazadas ${params.get('linked') || 0} carpetas Drive` +
- (params.get('ambiguous') && params.get('ambiguous') !== '0'
- ? ` · ${params.get('ambiguous')} ambiguas (revisa abajo)`
- : '')
+ boot
+ ? `Google conectado · ${boot}`
+ : `Google conectado. Enlazadas ${params.get('linked') || 0} carpetas Drive` +
+ (params.get('ambiguous') && params.get('ambiguous') !== '0'
+ ? ` · ${params.get('ambiguous')} ambiguas`
+ : '')
);
}
if (params.get('drive') === 'error') {
@@ -101,7 +104,8 @@ export default function DriveConnectPanel() {
Una sola conexión con{' '}
marketing@4ventures.es .
En el consentimiento acepta Drive, Analytics y Search Console.
- Sirve para carpetas Drive y para Orgánico (panel de abajo).
+ Sirve para carpetas Drive, GA4, GSC y (con MCC) Google Ads.
+ Tras el consentimiento se emparejan solos a las fichas.
diff --git a/src/components/GoogleOrganicPanel.tsx b/src/components/GoogleOrganicPanel.tsx
index 0944d12..43ee1f3 100644
--- a/src/components/GoogleOrganicPanel.tsx
+++ b/src/components/GoogleOrganicPanel.tsx
@@ -54,40 +54,36 @@ export default function GoogleOrganicPanel() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('drive') === 'ok') {
+ const boot = params.get('boot');
setMsg(
- 'Google reconectado. Pulsa «Descubrir y emparejar» para GA4/GSC.'
+ boot
+ ? `Google reconectado · ${boot}`
+ : 'Google reconectado. Emparejando fichas…'
);
}
void loadStatus();
}, []);
- async function discover(apply: boolean) {
+ async function bootstrapAll() {
setBusy('match');
setErr(null);
const res = await fetch('/api/admin/google/organic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'match', apply }),
+ body: JSON.stringify({ action: 'bootstrap', days: 28 }),
});
const data = await res.json();
- if (!res.ok) {
- setBusy(null);
- setErr(data.error || 'Discover falló');
+ setBusy(null);
+ if (!res.ok && !data.message) {
+ setErr(data.error || 'Emparejado falló');
return;
}
- setMatches(data.matches || []);
- setGa4Count(data.ga4Count || 0);
- setGscCount(data.gscCount || 0);
- setMsg(
- apply
- ? `Emparejado · GA4 ${data.applied?.ga4 || 0} · GSC ${data.applied?.gsc || 0}. Actualizando datos…`
- : `Vistas ${data.ga4Count} props GA4 · ${data.gscCount} sites GSC (sin escribir)`
- );
- if (apply) {
- await syncAll();
- } else {
- setBusy(null);
+ setMsg(data.message || 'Emparejado listo');
+ if (data.organic) {
+ setGa4Count(data.organic.ga4Visible || 0);
+ setGscCount(data.organic.gscVisible || 0);
}
+ await loadStatus();
}
async function syncAll() {
@@ -109,7 +105,7 @@ export default function GoogleOrganicPanel() {
0
);
setMsg(
- `Datos al día · ${data.synced || 0} clientes · ${wrote} días en Orgánico (se actualiza solo al abrir la vista y cada noche)`
+ `Datos al día · ${data.synced || 0} clientes · ${wrote} días en Orgánico`
);
}
@@ -128,12 +124,12 @@ export default function GoogleOrganicPanel() {
- 2 · Orgánico (GA4 + Search Console)
+ 2 · Orgánico + emparejado Google
- No es otra cuenta: usa la conexión del panel 1. Aquí solo
- emparejas propiedades/sites a cada cliente y actualizas datos.
- Luego se mantienen al día solos.
+ Al conectar Google (panel 1) se rellenan solos Drive, GA4, GSC y
+ Google Ads en cada ficha. Aquí puedes repetir el emparejado y
+ actualizar Orgánico.
@@ -141,7 +137,7 @@ export default function GoogleOrganicPanel() {
void discover(true)}
+ onClick={() => void bootstrapAll()}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50 disabled:opacity-40"
>
{busy === 'match' ? (
@@ -149,7 +145,7 @@ export default function GoogleOrganicPanel() {
) : (
)}
- Descubrir y emparejar
+ Emparejar todo Google
{
return [...map.values()];
}
+/** Facebook Pages (+ IG business linked) visibles al system user / token 4V2030. */
+export async function listMetaPages(): Promise<{
+ facebook: NamedAccount[];
+ instagram: NamedAccount[];
+}> {
+ const token = await metaToken();
+ const facebook: NamedAccount[] = [];
+ const instagram: NamedAccount[] = [];
+ let url: string | null =
+ `${META_GRAPH}/me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100&access_token=${encodeURIComponent(token)}`;
+ while (url) {
+ const r = await fetch(url, { cache: 'no-store' });
+ if (!r.ok) {
+ throw new Error(`Meta pages: ${(await r.text()).slice(0, 300)}`);
+ }
+ const j = (await r.json()) as {
+ data?: {
+ id: string;
+ name?: string;
+ instagram_business_account?: {
+ id?: string;
+ username?: string;
+ name?: string;
+ };
+ }[];
+ paging?: { next?: string };
+ };
+ for (const page of j.data || []) {
+ facebook.push({ id: page.id, name: page.name || page.id });
+ const ig = page.instagram_business_account;
+ if (ig?.id) {
+ instagram.push({
+ id: ig.id,
+ name: ig.username
+ ? `@${ig.username}`
+ : ig.name || page.name || ig.id,
+ });
+ }
+ }
+ url = j.paging?.next || null;
+ }
+ return { facebook, instagram };
+}
+
export async function applyProviderMatches(
- provider: 'meta_ads' | 'google_ads',
+ provider: 'meta_ads' | 'google_ads' | 'facebook' | 'instagram',
matches: AccountMatch[],
labelFallback: string
): Promise<{ linked: number }> {
@@ -352,7 +396,14 @@ export async function discoverAndMatch(opts?: {
apply?: boolean;
providers?: Array<'meta_ads' | 'google_ads'>;
}): Promise<{
- meta?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string };
+ meta?: {
+ accounts: number;
+ linked: number;
+ matches: AccountMatch[];
+ error?: string;
+ pagesLinked?: number;
+ igLinked?: number;
+ };
google?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string };
}> {
const headers = serviceHeaders();
@@ -380,15 +431,38 @@ export async function discoverAndMatch(opts?: {
linked: 0,
matches: [],
error:
- 'Meta pendiente: pega el system user token en Agencia cuando App Review lo apruebe',
+ 'Meta pendiente: pega el system user token (API 4V2030 / App Review) en Agencia',
};
} else {
const accounts = await listMetaAdAccounts();
const matches = matchAccountsToTenants(tenants, accounts);
- const linked = apply
+ let linked = apply
? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked
: 0;
- result.meta = { accounts: accounts.length, linked, matches };
+ let pagesLinked = 0;
+ let igLinked = 0;
+ try {
+ const pages = await listMetaPages();
+ const fbMatches = matchAccountsToTenants(tenants, pages.facebook);
+ const igMatches = matchAccountsToTenants(tenants, pages.instagram);
+ if (apply) {
+ pagesLinked = (
+ await applyProviderMatches('facebook', fbMatches, 'Facebook Page')
+ ).linked;
+ igLinked = (
+ await applyProviderMatches('instagram', igMatches, 'Instagram')
+ ).linked;
+ }
+ } catch {
+ // pages permission may be missing — Ads still OK
+ }
+ result.meta = {
+ accounts: accounts.length,
+ linked,
+ matches,
+ pagesLinked,
+ igLinked,
+ };
}
} catch (e) {
result.meta = {
diff --git a/src/lib/agency-google-bootstrap.ts b/src/lib/agency-google-bootstrap.ts
new file mode 100644
index 0000000..bdcc004
--- /dev/null
+++ b/src/lib/agency-google-bootstrap.ts
@@ -0,0 +1,207 @@
+/**
+ * After agency Google OAuth: match Drive + GA4 + GSC (+ Google Ads if env)
+ * into every client ficha and sync Orgánico.
+ */
+
+import { serviceHeaders, supabaseUrl } from '@/lib/agency';
+import {
+ applyMatches,
+ getAgencyOAuthStatus,
+ listDriveFolders,
+ matchFoldersToTenants,
+} from '@/lib/google-drive';
+import {
+ applyOrganicMatches,
+ listGa4Properties,
+ listGscSites,
+ matchOrganicToTenants,
+ syncAllConnectedOrganic,
+} from '@/lib/google-organic';
+import {
+ discoverAndMatch,
+ googleAdsConfigured,
+} from '@/lib/ads-discovery';
+
+export type GoogleBootstrapResult = {
+ ok: boolean;
+ email?: string | null;
+ drive?: { linked: number; folders: number; ambiguous: number; error?: string };
+ organic?: {
+ ga4Linked: number;
+ gscLinked: number;
+ ga4Visible: number;
+ gscVisible: number;
+ daysWritten: number;
+ tenantsSynced: number;
+ error?: string;
+ };
+ googleAds?: {
+ linked: number;
+ accounts: number;
+ error?: string;
+ };
+ message: string;
+};
+
+export async function bootstrapAgencyGoogle(opts?: {
+ /** Also pull Google Ads MCC accounts into fichas (needs GOOGLE_ADS_* env). */
+ includeGoogleAds?: boolean;
+ /** Sync organic_daily after matching (default true). */
+ syncOrganic?: boolean;
+ organicDays?: number;
+}): Promise {
+ const status = await getAgencyOAuthStatus();
+ if (!status.connected) {
+ return {
+ ok: false,
+ message: 'Conecta Google (marketing@) en Agencia primero.',
+ };
+ }
+
+ const headers = serviceHeaders();
+ if (!headers) {
+ return { ok: false, message: 'no service role' };
+ }
+
+ const tRes = await fetch(
+ `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
+ { headers, cache: 'no-store' }
+ );
+ if (!tRes.ok) {
+ return {
+ ok: false,
+ email: status.email,
+ message: `tenants: ${(await tRes.text()).slice(0, 160)}`,
+ };
+ }
+ const tenants = (await tRes.json()) as {
+ id: string;
+ name: string;
+ slug: string;
+ }[];
+
+ const result: GoogleBootstrapResult = {
+ ok: true,
+ email: status.email,
+ message: '',
+ };
+
+ // --- Drive ---
+ try {
+ const folders = await listDriveFolders();
+ const matches = await matchFoldersToTenants(tenants, folders);
+ const applied = await applyMatches(matches);
+ result.drive = {
+ linked: applied.linked,
+ folders: folders.length,
+ ambiguous: matches.filter((m) => m.status === 'ambiguous').length,
+ };
+ } catch (e) {
+ result.drive = {
+ linked: 0,
+ folders: 0,
+ ambiguous: 0,
+ error: e instanceof Error ? e.message : 'drive error',
+ };
+ }
+
+ // --- GA4 + GSC ---
+ try {
+ const [ga4, gsc] = await Promise.all([
+ status.hasAnalytics !== false
+ ? listGa4Properties()
+ : Promise.resolve([]),
+ status.hasSearchConsole !== false
+ ? listGscSites()
+ : Promise.resolve([]),
+ ]);
+ const matches = await matchOrganicToTenants(tenants, ga4, gsc);
+ const applied = await applyOrganicMatches(
+ matches.filter(
+ (m) =>
+ (m.ga4 && m.ga4.score >= 50) || (m.gsc && m.gsc.score >= 40)
+ )
+ );
+ let daysWritten = 0;
+ let tenantsSynced = 0;
+ if (opts?.syncOrganic !== false) {
+ const sync = await syncAllConnectedOrganic({
+ days: opts?.organicDays ?? 28,
+ });
+ daysWritten = sync.results.reduce((a, r) => a + (r.daysWritten || 0), 0);
+ tenantsSynced = sync.synced;
+ }
+ result.organic = {
+ ga4Linked: applied.ga4,
+ gscLinked: applied.gsc,
+ ga4Visible: ga4.length,
+ gscVisible: gsc.length,
+ daysWritten,
+ tenantsSynced,
+ };
+ } catch (e) {
+ result.organic = {
+ ga4Linked: 0,
+ gscLinked: 0,
+ ga4Visible: 0,
+ gscVisible: 0,
+ daysWritten: 0,
+ tenantsSynced: 0,
+ error: e instanceof Error ? e.message : 'organic error',
+ };
+ }
+
+ // --- Google Ads (separate token / MCC env) ---
+ if (opts?.includeGoogleAds !== false && googleAdsConfigured()) {
+ try {
+ const disc = await discoverAndMatch({
+ apply: true,
+ providers: ['google_ads'],
+ });
+ const g = disc.google;
+ result.googleAds = {
+ linked: g?.linked || 0,
+ accounts: g?.accounts || 0,
+ error: g?.error,
+ };
+ } catch (e) {
+ result.googleAds = {
+ linked: 0,
+ accounts: 0,
+ error: e instanceof Error ? e.message : 'gads error',
+ };
+ }
+ }
+
+ const parts: string[] = [];
+ if (result.drive) {
+ parts.push(
+ result.drive.error
+ ? `Drive: ${result.drive.error}`
+ : `Drive ${result.drive.linked}/${result.drive.folders}`
+ );
+ }
+ if (result.organic) {
+ parts.push(
+ result.organic.error
+ ? `Orgánico: ${result.organic.error}`
+ : `GA4 ${result.organic.ga4Linked} · GSC ${result.organic.gscLinked} · ${result.organic.daysWritten} días sync`
+ );
+ }
+ if (result.googleAds) {
+ parts.push(
+ result.googleAds.error
+ ? `Ads: ${result.googleAds.error}`
+ : `Google Ads ${result.googleAds.linked}/${result.googleAds.accounts}`
+ );
+ }
+ result.message = parts.join(' · ') || 'Sin cambios';
+ result.ok = !result.drive?.error || !result.organic?.error;
+ // ok if at least one channel succeeded
+ result.ok = Boolean(
+ (result.drive && !result.drive.error) ||
+ (result.organic && !result.organic.error) ||
+ (result.googleAds && !result.googleAds.error)
+ );
+ return result;
+}