feat: conectar GA4/GSC/Drive desde la ficha (verificar + sync real)

Al guardar ID o URL en el cliente se valida con OAuth de agencia;
Drive acepta URL o folder id; Orgánico se rellena si hay acceso.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 12:27:15 +02:00
parent 81ba5df37d
commit 99895fe918
3 changed files with 387 additions and 21 deletions

View File

@@ -7,6 +7,11 @@ import {
supabaseUrl, supabaseUrl,
userRole, userRole,
} from '@/lib/agency'; } from '@/lib/agency';
import {
activateGoogleFromFicha,
LIVE_GOOGLE_PROVIDERS,
normalizeDriveFolderId,
} from '@/lib/integration-activate';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -93,7 +98,7 @@ export async function GET(req: NextRequest) {
}); });
} }
/** PATCH — update one integration status/account (manual connect for now). */ /** PATCH — update integration; GA4/GSC/Drive verify + sync via agency Google OAuth. */
export async function PATCH(req: NextRequest) { export async function PATCH(req: NextRequest) {
const gate = await requireAgency(); const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error; if ('error' in gate && gate.error) return gate.error;
@@ -124,34 +129,69 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: 'status inválido' }, { status: 400 }); return NextResponse.json({ error: 'status inválido' }, { status: 400 });
} }
const wantsLiveConnect =
LIVE_GOOGLE_PROVIDERS.has(provider) &&
(status === 'connected' ||
(status === 'pending' && Boolean(externalAccountId)));
if (wantsLiveConnect) {
let rawId = externalAccountId || '';
if (!rawId) {
const cur = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=external_account_id`,
{ headers, cache: 'no-store' }
);
if (cur.ok) {
const rows = (await cur.json()) as { external_account_id?: string }[];
rawId = rows[0]?.external_account_id || '';
}
}
if (!rawId) {
return NextResponse.json(
{
error: 'Indica ID o URL antes de conectar',
activation: { ok: false, message: 'Falta ID' },
},
{ status: 400 }
);
}
const activation = await activateGoogleFromFicha({
tenantId,
provider: provider as 'ga4' | 'search_console' | 'google_drive',
rawId,
});
const list = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=*`,
{ headers, cache: 'no-store' }
);
const rows = list.ok ? await list.json() : [];
return NextResponse.json(
{
integration: rows[0] || null,
activation,
error: activation.ok ? undefined : activation.errorMessage || activation.message,
},
{ status: activation.ok ? 200 : 422 }
);
}
const patch: Record<string, unknown> = { const patch: Record<string, unknown> = {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };
if (status) patch.status = status; if (status) patch.status = status;
if (externalAccountId !== undefined) { if (externalAccountId !== undefined) {
let id = externalAccountId || null; let id = externalAccountId || null;
// Drive: accept full folder URL → store folder id only
if (provider === 'google_drive' && id) { if (provider === 'google_drive' && id) {
const m = id.match(/\/folders\/([a-zA-Z0-9_-]+)/); id = normalizeDriveFolderId(id) || id;
if (m) id = m[1];
else if (id.includes('http')) {
const q = id.match(/[?&]id=([a-zA-Z0-9_-]+)/);
if (q) id = q[1];
}
} }
patch.external_account_id = id; patch.external_account_id = id;
} }
if (label !== undefined) patch.label = label || null; if (label !== undefined) patch.label = label || null;
if (errorMessage !== undefined) patch.error_message = errorMessage || null; if (errorMessage !== undefined) patch.error_message = errorMessage || null;
if (enabled !== undefined) patch.enabled = enabled; if (enabled !== undefined) patch.enabled = enabled;
// Manual «Conectar» only marks the reference — do not fake a data sync timestamp.
// Real syncs (Ads discover, Orgánico, Superhuman…) set last_sync_at themselves.
if (status === 'connected') { if (status === 'connected') {
patch.error_message = null; patch.error_message = null;
} }
if (status === 'disconnected') {
// keep external id for re-link unless cleared
}
if (!status && enabled === undefined && externalAccountId === undefined) { if (!status && enabled === undefined && externalAccountId === undefined) {
return NextResponse.json( return NextResponse.json(
@@ -173,7 +213,6 @@ export async function PATCH(req: NextRequest) {
}); });
if (!r.ok) { if (!r.ok) {
// upsert if missing
const ins = await fetch(`${base}/rest/v1/integrations`, { const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@@ -27,7 +27,7 @@ const PLACEHOLDERS: Record<string, string> = {
clientify: 'ID / workspace Clientify', clientify: 'ID / workspace Clientify',
klaviyo: 'Account / list ID', klaviyo: 'Account / list ID',
mailchimp: 'Audience ID', mailchimp: 'Audience ID',
google_drive: 'Folder ID (auto-match o pegar)', google_drive: 'URL de carpeta Drive o folder id',
wordpress: 'https://sitio.com + Application Password', wordpress: 'https://sitio.com + Application Password',
prestashop: 'https://tienda.com + API key', prestashop: 'https://tienda.com + API key',
shopify: 'mitienda.myshopify.com + Admin API token', shopify: 'mitienda.myshopify.com + Admin API token',
@@ -37,6 +37,8 @@ const PLACEHOLDERS: Record<string, string> = {
linkedin: 'Company Page ID / linkedin.com/company/…', linkedin: 'Company Page ID / linkedin.com/company/…',
}; };
const LIVE_FROM_FICHA = new Set(['ga4', 'search_console', 'google_drive']);
export default function ClientIntegrationsPanel({ export default function ClientIntegrationsPanel({
tenantId, tenantId,
tenantName, tenantName,
@@ -52,6 +54,7 @@ export default function ClientIntegrationsPanel({
const [integrations, setIntegrations] = useState<Integ[]>([]); const [integrations, setIntegrations] = useState<Integ[]>([]);
const [providers, setProviders] = useState<Provider[]>([]); const [providers, setProviders] = useState<Provider[]>([]);
const [warning, setWarning] = useState<string | null>(null); const [warning, setWarning] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null); const [saving, setSaving] = useState<string | null>(null);
const [accountDraft, setAccountDraft] = useState<Record<string, string>>({}); const [accountDraft, setAccountDraft] = useState<Record<string, string>>({});
const [followed, setFollowed] = useState(true); const [followed, setFollowed] = useState(true);
@@ -87,11 +90,18 @@ export default function ClientIntegrationsPanel({
async function patch(provider: string, body: Record<string, unknown>) { async function patch(provider: string, body: Record<string, unknown>) {
setSaving(provider); setSaving(provider);
await fetch('/api/admin/integrations', { setFlash(null);
const res = await fetch('/api/admin/integrations', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantId, provider, ...body }), body: JSON.stringify({ tenantId, provider, ...body }),
}); });
const data = await res.json().catch(() => ({}));
if (data.activation?.message) setFlash(data.activation.message);
else if (data.error) setFlash(String(data.error));
else if (LIVE_FROM_FICHA.has(provider) && body.status === 'connected') {
setFlash(res.ok ? 'Conexión verificada' : 'No se pudo activar');
}
await load(); await load();
onChanged?.(); onChanged?.();
setSaving(null); setSaving(null);
@@ -168,6 +178,16 @@ export default function ClientIntegrationsPanel({
{warning} {warning}
</p> </p>
)} )}
{flash && (
<p className="text-xs text-gray-800 border border-gray-200 bg-gray-50 rounded-lg px-3 py-2">
{flash}
</p>
)}
<p className="text-[11px] text-gray-600">
GA4, Search Console y Drive: al guardar/conectar se verifica con el
Google de agencia (marketing@) y se sincronizan datos. URL de carpeta =
mismo que folder id. Primero conecta Google en Agencia si aún no.
</p>
{groups.map((g) => { {groups.map((g) => {
const list = providers.filter((p) => p.group === g.id); const list = providers.filter((p) => p.group === g.id);
@@ -275,8 +295,11 @@ export default function ClientIntegrationsPanel({
disabled={saving === p.id || !enabled} disabled={saving === p.id || !enabled}
onClick={() => onClick={() =>
void patch(p.id, { void patch(p.id, {
status: status: LIVE_FROM_FICHA.has(p.id)
status === 'disconnected' ? 'pending' : status, ? 'connected'
: status === 'disconnected'
? 'pending'
: status,
externalAccountId: accountDraft[p.id] || '', externalAccountId: accountDraft[p.id] || '',
}) })
} }
@@ -284,6 +307,8 @@ export default function ClientIntegrationsPanel({
> >
{saving === p.id ? ( {saving === p.id ? (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
) : LIVE_FROM_FICHA.has(p.id) ? (
'Guardar y conectar'
) : ( ) : (
'Guardar ID' 'Guardar ID'
)} )}
@@ -291,9 +316,15 @@ export default function ClientIntegrationsPanel({
</div> </div>
{p.id === 'google_drive' && ( {p.id === 'google_drive' && (
<p className="text-[10px] text-gray-500"> <p className="text-[10px] text-gray-500">
Preferible: Agencia → Conectar Drive → Re-emparejar Pega la URL completa o solo el id (
(asigna carpeta por nombre). Aquí puedes pegar un Folder …/folders/<strong>ID</strong>). Da igual: se normaliza y
ID manual. se comprueba acceso con marketing@.
</p>
)}
{(p.id === 'ga4' || p.id === 'search_console') && (
<p className="text-[10px] text-gray-500">
Al conectar se verifica que marketing@ vea la propiedad /
site y se cargan datos en Orgánico.
</p> </p>
)} )}
{(p.id === 'wordpress' || {(p.id === 'wordpress' ||

View File

@@ -0,0 +1,296 @@
/**
* Activate a client-ficha Google integration for real:
* normalize ID → verify via agency OAuth → write meta → optional organic sync.
*/
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import {
getAgencyOAuthStatus,
getValidAccessToken,
} from '@/lib/google-drive';
import {
listGa4Properties,
listGscSites,
syncAllConnectedOrganic,
} from '@/lib/google-organic';
export function normalizeDriveFolderId(raw: string): string | null {
const s = raw.trim();
if (!s) return null;
const folder = s.match(/\/folders\/([a-zA-Z0-9_-]+)/);
if (folder) return folder[1];
const q = s.match(/[?&]id=([a-zA-Z0-9_-]+)/);
if (q) return q[1];
if (/^[a-zA-Z0-9_-]{10,}$/.test(s)) return s;
return null;
}
export async function fetchDriveFolderMeta(
folderId: string
): Promise<{ id: string; name: string }> {
const token = await getValidAccessToken();
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType,trashed&supportsAllDrives=true`,
{ headers: { Authorization: `Bearer ${token}` }, cache: 'no-store' }
);
if (!r.ok) {
throw new Error(
`Drive: no acceso a la carpeta (${(await r.text()).slice(0, 120)})`
);
}
const j = (await r.json()) as {
id?: string;
name?: string;
mimeType?: string;
trashed?: boolean;
};
if (j.trashed) throw new Error('Drive: la carpeta está en la papelera');
if (j.mimeType && j.mimeType !== 'application/vnd.google-apps.folder') {
throw new Error('Drive: el id no es una carpeta');
}
return { id: j.id || folderId, name: j.name || folderId };
}
export type ActivateResult = {
ok: boolean;
externalAccountId: string | null;
meta?: Record<string, unknown>;
status: 'connected' | 'error' | 'disconnected';
errorMessage?: string | null;
lastSyncAt?: string | null;
message?: string;
daysWritten?: number;
};
async function patchIntegration(
tenantId: string,
provider: string,
body: Record<string, unknown>
): Promise<void> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ ...body, updated_at: new Date().toISOString() }),
}
);
if (!r.ok) throw new Error(`integrations patch: ${(await r.text()).slice(0, 200)}`);
}
/**
* Called when agency marks ga4 / search_console / google_drive as connected
* from the client ficha (ID or URL pasted).
*/
export async function activateGoogleFromFicha(opts: {
tenantId: string;
provider: 'ga4' | 'search_console' | 'google_drive';
rawId: string;
}): Promise<ActivateResult> {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
return {
ok: false,
externalAccountId: opts.rawId || null,
status: 'error',
errorMessage:
'Primero Conectar Google en Agencia (panel 1) con marketing@.',
message: 'Falta OAuth de agencia',
};
}
try {
if (opts.provider === 'google_drive') {
const folderId = normalizeDriveFolderId(opts.rawId);
if (!folderId) {
return {
ok: false,
externalAccountId: opts.rawId || null,
status: 'error',
errorMessage: 'Pega la URL de la carpeta o el folder id.',
};
}
const folder = await fetchDriveFolderMeta(folderId);
const now = new Date().toISOString();
const meta = {
folder_name: folder.name,
source: 'ficha',
verified_at: now,
};
await patchIntegration(opts.tenantId, 'google_drive', {
status: 'connected',
external_account_id: folder.id,
meta,
last_sync_at: now,
error_message: null,
});
return {
ok: true,
externalAccountId: folder.id,
meta,
status: 'connected',
lastSyncAt: now,
message: `Drive OK · carpeta «${folder.name}»`,
};
}
if (opts.provider === 'ga4') {
const raw = opts.rawId.trim();
if (!raw) {
return {
ok: false,
externalAccountId: null,
status: 'error',
errorMessage: 'Indica G-… o properties/…',
};
}
const catalog = await listGa4Properties();
let hit = catalog.find(
(p) =>
p.measurementId === raw ||
p.propertyId === raw ||
p.propertyId === `properties/${raw}` ||
p.propertyId.replace(/^properties\//, '') === raw
);
if (!hit && raw.startsWith('G-')) {
return {
ok: false,
externalAccountId: raw,
status: 'error',
errorMessage: `GA4 ${raw} no visible para marketing@. Revisa acceso en Analytics.`,
};
}
if (!hit && raw.startsWith('properties/')) {
hit = {
propertyId: raw,
displayName: raw,
accountName: '',
measurementId: null,
};
}
if (!hit) {
return {
ok: false,
externalAccountId: raw,
status: 'error',
errorMessage: 'No encontré esa propiedad GA4 con la cuenta de agencia.',
};
}
const accountId = hit.measurementId || hit.propertyId;
const now = new Date().toISOString();
const meta = {
property_id: hit.propertyId,
measurement_id: hit.measurementId,
display_name: hit.displayName,
source: 'ficha',
verified_at: now,
};
await patchIntegration(opts.tenantId, 'ga4', {
status: 'connected',
external_account_id: accountId,
meta,
error_message: null,
});
const sync = await syncAllConnectedOrganic({
tenantId: opts.tenantId,
days: 28,
});
const days = sync.results[0]?.daysWritten ?? 0;
const warn = sync.results[0]?.warning;
return {
ok: true,
externalAccountId: accountId,
meta,
status: 'connected',
lastSyncAt: now,
daysWritten: days,
message: warn
? `GA4 enlazado · aviso: ${warn}`
: `GA4 OK · ${days} días en Orgánico`,
errorMessage: warn || null,
};
}
// search_console
const siteRaw = opts.rawId.trim().replace(/\/$/, '');
if (!siteRaw) {
return {
ok: false,
externalAccountId: null,
status: 'error',
errorMessage: 'Indica sc-domain:… o https://…',
};
}
const sites = await listGscSites();
const hit = sites.find(
(s) =>
s.siteUrl === siteRaw ||
s.siteUrl.replace(/\/$/, '') === siteRaw ||
s.siteUrl.includes(siteRaw.replace(/^sc-domain:/, ''))
);
if (!hit) {
return {
ok: false,
externalAccountId: siteRaw,
status: 'error',
errorMessage: `GSC «${siteRaw}» no visible para marketing@.`,
};
}
const now = new Date().toISOString();
const meta = {
site_url: hit.siteUrl,
source: 'ficha',
verified_at: now,
};
await patchIntegration(opts.tenantId, 'search_console', {
status: 'connected',
external_account_id: hit.siteUrl,
meta,
error_message: null,
});
const sync = await syncAllConnectedOrganic({
tenantId: opts.tenantId,
days: 28,
});
const days = sync.results[0]?.daysWritten ?? 0;
const warn = sync.results[0]?.warning;
return {
ok: true,
externalAccountId: hit.siteUrl,
meta,
status: 'connected',
lastSyncAt: now,
daysWritten: days,
message: warn
? `GSC enlazado · aviso: ${warn}`
: `GSC OK · ${days} días en Orgánico`,
errorMessage: warn || null,
};
} catch (e) {
const msg = e instanceof Error ? e.message : 'activate failed';
await patchIntegration(opts.tenantId, opts.provider, {
status: 'error',
error_message: msg.slice(0, 280),
external_account_id: opts.rawId || null,
}).catch(() => {});
return {
ok: false,
externalAccountId: opts.rawId || null,
status: 'error',
errorMessage: msg,
message: msg,
};
}
}
export const LIVE_GOOGLE_PROVIDERS = new Set([
'ga4',
'search_console',
'google_drive',
]);