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>
243 lines
7.6 KiB
TypeScript
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 });
|
|
}
|