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:
@@ -7,6 +7,11 @@ import {
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
activateGoogleFromFicha,
|
||||
LIVE_GOOGLE_PROVIDERS,
|
||||
normalizeDriveFolderId,
|
||||
} from '@/lib/integration-activate';
|
||||
|
||||
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) {
|
||||
const gate = await requireAgency();
|
||||
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 });
|
||||
}
|
||||
|
||||
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> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (status) patch.status = status;
|
||||
if (externalAccountId !== undefined) {
|
||||
let id = externalAccountId || null;
|
||||
// Drive: accept full folder URL → store folder id only
|
||||
if (provider === 'google_drive' && id) {
|
||||
const m = id.match(/\/folders\/([a-zA-Z0-9_-]+)/);
|
||||
if (m) id = m[1];
|
||||
else if (id.includes('http')) {
|
||||
const q = id.match(/[?&]id=([a-zA-Z0-9_-]+)/);
|
||||
if (q) id = q[1];
|
||||
}
|
||||
id = normalizeDriveFolderId(id) || id;
|
||||
}
|
||||
patch.external_account_id = id;
|
||||
}
|
||||
if (label !== undefined) patch.label = label || null;
|
||||
if (errorMessage !== undefined) patch.error_message = errorMessage || null;
|
||||
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') {
|
||||
patch.error_message = null;
|
||||
}
|
||||
if (status === 'disconnected') {
|
||||
// keep external id for re-link unless cleared
|
||||
}
|
||||
|
||||
if (!status && enabled === undefined && externalAccountId === undefined) {
|
||||
return NextResponse.json(
|
||||
@@ -173,7 +213,6 @@ export async function PATCH(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
// upsert if missing
|
||||
const ins = await fetch(`${base}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -27,7 +27,7 @@ const PLACEHOLDERS: Record<string, string> = {
|
||||
clientify: 'ID / workspace Clientify',
|
||||
klaviyo: 'Account / list 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',
|
||||
prestashop: 'https://tienda.com + API key',
|
||||
shopify: 'mitienda.myshopify.com + Admin API token',
|
||||
@@ -37,6 +37,8 @@ const PLACEHOLDERS: Record<string, string> = {
|
||||
linkedin: 'Company Page ID / linkedin.com/company/…',
|
||||
};
|
||||
|
||||
const LIVE_FROM_FICHA = new Set(['ga4', 'search_console', 'google_drive']);
|
||||
|
||||
export default function ClientIntegrationsPanel({
|
||||
tenantId,
|
||||
tenantName,
|
||||
@@ -52,6 +54,7 @@ export default function ClientIntegrationsPanel({
|
||||
const [integrations, setIntegrations] = useState<Integ[]>([]);
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [accountDraft, setAccountDraft] = useState<Record<string, string>>({});
|
||||
const [followed, setFollowed] = useState(true);
|
||||
@@ -87,11 +90,18 @@ export default function ClientIntegrationsPanel({
|
||||
|
||||
async function patch(provider: string, body: Record<string, unknown>) {
|
||||
setSaving(provider);
|
||||
await fetch('/api/admin/integrations', {
|
||||
setFlash(null);
|
||||
const res = await fetch('/api/admin/integrations', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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();
|
||||
onChanged?.();
|
||||
setSaving(null);
|
||||
@@ -168,6 +178,16 @@ export default function ClientIntegrationsPanel({
|
||||
{warning}
|
||||
</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) => {
|
||||
const list = providers.filter((p) => p.group === g.id);
|
||||
@@ -275,8 +295,11 @@ export default function ClientIntegrationsPanel({
|
||||
disabled={saving === p.id || !enabled}
|
||||
onClick={() =>
|
||||
void patch(p.id, {
|
||||
status:
|
||||
status === 'disconnected' ? 'pending' : status,
|
||||
status: LIVE_FROM_FICHA.has(p.id)
|
||||
? 'connected'
|
||||
: status === 'disconnected'
|
||||
? 'pending'
|
||||
: status,
|
||||
externalAccountId: accountDraft[p.id] || '',
|
||||
})
|
||||
}
|
||||
@@ -284,6 +307,8 @@ export default function ClientIntegrationsPanel({
|
||||
>
|
||||
{saving === p.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : LIVE_FROM_FICHA.has(p.id) ? (
|
||||
'Guardar y conectar'
|
||||
) : (
|
||||
'Guardar ID'
|
||||
)}
|
||||
@@ -291,9 +316,15 @@ export default function ClientIntegrationsPanel({
|
||||
</div>
|
||||
{p.id === 'google_drive' && (
|
||||
<p className="text-[10px] text-gray-500">
|
||||
Preferible: Agencia → Conectar Drive → Re-emparejar
|
||||
(asigna carpeta por nombre). Aquí puedes pegar un Folder
|
||||
ID manual.
|
||||
Pega la URL completa o solo el id (
|
||||
…/folders/<strong>ID</strong>). Da igual: se normaliza y
|
||||
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.id === 'wordpress' ||
|
||||
|
||||
296
src/lib/integration-activate.ts
Normal file
296
src/lib/integration-activate.ts
Normal 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',
|
||||
]);
|
||||
Reference in New Issue
Block a user