feat: al conectar Google, auto-emparejar Drive/GA4/GSC/Ads en fichas
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
|
- `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)
|
- 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`
|
- 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:
|
- Cron diario (recomendado): env `CRON_SECRET` en Dokploy + schedule:
|
||||||
`curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync`
|
`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).
|
(actualiza últimos 7 días de todos los clientes con GA4/GSC conectados).
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
import {
|
import {
|
||||||
applyMatches,
|
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
fetchGoogleEmail,
|
fetchGoogleEmail,
|
||||||
listDriveFolders,
|
|
||||||
matchFoldersToTenants,
|
|
||||||
saveAgencyOAuth,
|
saveAgencyOAuth,
|
||||||
} from '@/lib/google-drive';
|
} from '@/lib/google-drive';
|
||||||
|
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
function appUrl(): string {
|
function appUrl(): string {
|
||||||
return (
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -56,35 +55,27 @@ export async function GET(req: NextRequest) {
|
|||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-match folders (non-fatal: tokens already saved)
|
|
||||||
let linked = 0;
|
let linked = 0;
|
||||||
let ambiguous = 0;
|
let ambiguous = 0;
|
||||||
|
let bootstrapMsg = '';
|
||||||
try {
|
try {
|
||||||
const headers = serviceHeaders();
|
const boot = await bootstrapAgencyGoogle({
|
||||||
if (headers) {
|
includeGoogleAds: true,
|
||||||
const tRes = await fetch(
|
syncOrganic: true,
|
||||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
organicDays: 28,
|
||||||
{ headers, cache: 'no-store' }
|
});
|
||||||
);
|
linked = boot.drive?.linked || 0;
|
||||||
if (tRes.ok) {
|
ambiguous = boot.drive?.ambiguous || 0;
|
||||||
const tenants = (await tRes.json()) as {
|
bootstrapMsg = boot.message;
|
||||||
id: string;
|
} catch (e) {
|
||||||
name: string;
|
bootstrapMsg = e instanceof Error ? e.message : 'bootstrap failed';
|
||||||
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 res = NextResponse.redirect(
|
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: '/' });
|
res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
userRole,
|
userRole,
|
||||||
} from '@/lib/agency';
|
} from '@/lib/agency';
|
||||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||||
|
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
|
||||||
import {
|
import {
|
||||||
applyOrganicMatches,
|
applyOrganicMatches,
|
||||||
listGa4Properties,
|
listGa4Properties,
|
||||||
@@ -90,7 +91,8 @@ export async function GET() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST
|
* 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
|
* - { action: 'sync', tenantId?, days? } → sync organic_daily
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -101,17 +103,38 @@ export async function POST(req: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
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') {
|
if (action === 'match') {
|
||||||
try {
|
try {
|
||||||
const [ga4, gsc, tRes] = await Promise.all([
|
const [ga4, gsc, tRes] = await Promise.all([
|
||||||
listGa4Properties(),
|
listGa4Properties(),
|
||||||
listGscSites(),
|
listGscSites(),
|
||||||
fetch(`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, {
|
fetch(
|
||||||
headers,
|
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||||
cache: 'no-store',
|
{ headers, cache: 'no-store' }
|
||||||
}),
|
),
|
||||||
]);
|
]);
|
||||||
if (!tRes.ok) {
|
if (!tRes.ok) {
|
||||||
return NextResponse.json(
|
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 days = Math.min(90, Math.max(3, Number(body.days) || 28));
|
||||||
const tenantFilter =
|
const tenantFilter =
|
||||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ export default function AdsDiscoverPanel() {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
if (data.meta) {
|
if (data.meta) {
|
||||||
parts.push(
|
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})` : '')
|
(data.meta.error ? ` (${data.meta.error})` : '')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -179,9 +183,9 @@ export default function AdsDiscoverPanel() {
|
|||||||
Meta Ads + Google Ads (agencia)
|
Meta Ads + Google Ads (agencia)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
||||||
Lista cuentas accesibles y las enlaza a cada cliente por nombre.
|
Lista cuentas accesibles y las enlaza a cada ficha por nombre.
|
||||||
Google Ads ya puede emparejarse; Meta cuando tengas el token de
|
Meta Ads + páginas FB/IG usan el token system user (API 4V2030 /
|
||||||
App Review.
|
App Review). Google Ads con el MCC de agencia.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ export default function DriveConnectPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('drive') === 'ok') {
|
if (params.get('drive') === 'ok') {
|
||||||
|
const boot = params.get('boot');
|
||||||
setMsg(
|
setMsg(
|
||||||
`Google conectado. Enlazadas ${params.get('linked') || 0} carpetas Drive` +
|
boot
|
||||||
(params.get('ambiguous') && params.get('ambiguous') !== '0'
|
? `Google conectado · ${boot}`
|
||||||
? ` · ${params.get('ambiguous')} ambiguas (revisa abajo)`
|
: `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') {
|
if (params.get('drive') === 'error') {
|
||||||
@@ -101,7 +104,8 @@ export default function DriveConnectPanel() {
|
|||||||
Una sola conexión con{' '}
|
Una sola conexión con{' '}
|
||||||
<strong className="text-gray-800">marketing@4ventures.es</strong>.
|
<strong className="text-gray-800">marketing@4ventures.es</strong>.
|
||||||
En el consentimiento acepta Drive, Analytics y Search Console.
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,40 +54,36 @@ export default function GoogleOrganicPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('drive') === 'ok') {
|
if (params.get('drive') === 'ok') {
|
||||||
|
const boot = params.get('boot');
|
||||||
setMsg(
|
setMsg(
|
||||||
'Google reconectado. Pulsa «Descubrir y emparejar» para GA4/GSC.'
|
boot
|
||||||
|
? `Google reconectado · ${boot}`
|
||||||
|
: 'Google reconectado. Emparejando fichas…'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
void loadStatus();
|
void loadStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function discover(apply: boolean) {
|
async function bootstrapAll() {
|
||||||
setBusy('match');
|
setBusy('match');
|
||||||
setErr(null);
|
setErr(null);
|
||||||
const res = await fetch('/api/admin/google/organic', {
|
const res = await fetch('/api/admin/google/organic', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'match', apply }),
|
body: JSON.stringify({ action: 'bootstrap', days: 28 }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
setBusy(null);
|
||||||
setBusy(null);
|
if (!res.ok && !data.message) {
|
||||||
setErr(data.error || 'Discover falló');
|
setErr(data.error || 'Emparejado falló');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMatches(data.matches || []);
|
setMsg(data.message || 'Emparejado listo');
|
||||||
setGa4Count(data.ga4Count || 0);
|
if (data.organic) {
|
||||||
setGscCount(data.gscCount || 0);
|
setGa4Count(data.organic.ga4Visible || 0);
|
||||||
setMsg(
|
setGscCount(data.organic.gscVisible || 0);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
await loadStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncAll() {
|
async function syncAll() {
|
||||||
@@ -109,7 +105,7 @@ export default function GoogleOrganicPanel() {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
setMsg(
|
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() {
|
|||||||
<Leaf className="w-5 h-5 text-lime-600 mt-0.5 shrink-0" />
|
<Leaf className="w-5 h-5 text-lime-600 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
2 · Orgánico (GA4 + Search Console)
|
2 · Orgánico + emparejado Google
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
|
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
|
||||||
No es otra cuenta: usa la conexión del panel 1. Aquí solo
|
Al conectar Google (panel 1) se rellenan solos Drive, GA4, GSC y
|
||||||
emparejas propiedades/sites a cada cliente y actualizas datos.
|
Google Ads en cada ficha. Aquí puedes repetir el emparejado y
|
||||||
Luego se mantienen al día solos.
|
actualizar Orgánico.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +137,7 @@ export default function GoogleOrganicPanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!connected || busy !== null}
|
disabled={!connected || busy !== null}
|
||||||
onClick={() => 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"
|
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' ? (
|
{busy === 'match' ? (
|
||||||
@@ -149,7 +145,7 @@ export default function GoogleOrganicPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<Search className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
Descubrir y emparejar
|
Emparejar todo Google
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -292,8 +292,52 @@ export async function listGoogleAdsCustomers(): Promise<NamedAccount[]> {
|
|||||||
return [...map.values()];
|
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(
|
export async function applyProviderMatches(
|
||||||
provider: 'meta_ads' | 'google_ads',
|
provider: 'meta_ads' | 'google_ads' | 'facebook' | 'instagram',
|
||||||
matches: AccountMatch[],
|
matches: AccountMatch[],
|
||||||
labelFallback: string
|
labelFallback: string
|
||||||
): Promise<{ linked: number }> {
|
): Promise<{ linked: number }> {
|
||||||
@@ -352,7 +396,14 @@ export async function discoverAndMatch(opts?: {
|
|||||||
apply?: boolean;
|
apply?: boolean;
|
||||||
providers?: Array<'meta_ads' | 'google_ads'>;
|
providers?: Array<'meta_ads' | 'google_ads'>;
|
||||||
}): Promise<{
|
}): 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 };
|
google?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string };
|
||||||
}> {
|
}> {
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
@@ -380,15 +431,38 @@ export async function discoverAndMatch(opts?: {
|
|||||||
linked: 0,
|
linked: 0,
|
||||||
matches: [],
|
matches: [],
|
||||||
error:
|
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 {
|
} else {
|
||||||
const accounts = await listMetaAdAccounts();
|
const accounts = await listMetaAdAccounts();
|
||||||
const matches = matchAccountsToTenants(tenants, accounts);
|
const matches = matchAccountsToTenants(tenants, accounts);
|
||||||
const linked = apply
|
let linked = apply
|
||||||
? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked
|
? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked
|
||||||
: 0;
|
: 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) {
|
} catch (e) {
|
||||||
result.meta = {
|
result.meta = {
|
||||||
|
|||||||
207
src/lib/agency-google-bootstrap.ts
Normal file
207
src/lib/agency-google-bootstrap.ts
Normal file
@@ -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<GoogleBootstrapResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user