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,
|
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: {
|
||||||
|
|||||||
@@ -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' ||
|
||||||
|
|||||||
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