Files
4ventures-client-portal/src/app/api/admin/integrations/route.ts
4Ventures 99895fe918 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>
2026-09-24 12:27:15 +02:00

243 lines
7.6 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
INTEGRATION_PROVIDERS,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
activateGoogleFromFicha,
LIVE_GOOGLE_PROVIDERS,
normalizeDriveFolderId,
} from '@/lib/integration-activate';
export const dynamic = 'force-dynamic';
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (!isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json(
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
{ status: 503 }
),
};
}
return { user, headers };
}
/** GET ?tenantId= — integrations for one tenant (seed missing providers). */
export async function GET(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as { headers: { apikey: string; Authorization: string } };
const tenantId = req.nextUrl.searchParams.get('tenantId');
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const base = supabaseUrl();
const listRes = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`,
{ headers, cache: 'no-store' }
);
if (!listRes.ok) {
const msg = await listRes.text();
return NextResponse.json(
{
integrations: [],
providers: INTEGRATION_PROVIDERS,
warning: msg.includes('does not exist')
? 'Aplicar sql/agency_portfolio.sql'
: msg.slice(0, 200),
},
{ status: 200 }
);
}
let integrations = await listRes.json();
const have = new Set(
(integrations as { provider: string }[]).map((i) => i.provider)
);
const missing = INTEGRATION_PROVIDERS.filter((p) => !have.has(p.id));
if (missing.length) {
const rows = missing.map((p) => ({
tenant_id: tenantId,
provider: p.id,
status: 'disconnected',
label: p.label,
}));
await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify(rows),
});
const again = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`,
{ headers, cache: 'no-store' }
);
if (again.ok) integrations = await again.json();
}
return NextResponse.json({
integrations,
providers: INTEGRATION_PROVIDERS,
});
}
/** 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;
const { headers } = gate as { headers: { apikey: string; Authorization: string } };
const body = await req.json().catch(() => ({}));
const tenantId = typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
const status = typeof body.status === 'string' ? body.status.trim() : '';
const externalAccountId =
typeof body.externalAccountId === 'string'
? body.externalAccountId.trim()
: undefined;
const label =
typeof body.label === 'string' ? body.label.trim() : undefined;
const errorMessage =
typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined;
const enabled =
typeof body.enabled === 'boolean' ? body.enabled : undefined;
if (!tenantId || !provider) {
return NextResponse.json(
{ error: 'tenantId y provider requeridos' },
{ status: 400 }
);
}
if (status && !['disconnected', 'pending', 'connected', 'error'].includes(status)) {
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;
if (provider === 'google_drive' && id) {
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;
if (status === 'connected') {
patch.error_message = null;
}
if (!status && enabled === undefined && externalAccountId === undefined) {
return NextResponse.json(
{ error: 'status, enabled o externalAccountId requeridos' },
{ status: 400 }
);
}
const base = supabaseUrl();
const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`;
const r = await fetch(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
});
if (!r.ok) {
const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation,resolution=merge-duplicates',
},
body: JSON.stringify({
tenant_id: tenantId,
provider,
status: status || 'disconnected',
...patch,
}),
});
if (!ins.ok) {
return NextResponse.json(
{ error: await ins.text() },
{ status: 500 }
);
}
const created = await ins.json();
return NextResponse.json({ integration: created[0] || created });
}
const updated = await r.json();
return NextResponse.json({ integration: updated[0] || updated });
}