Admin home lists tenants, alerts, and connection health; header selector enables superadmin context; Meta/GA4/GSC/GTM/CRM/email slots live per tenant (manual status until OAuth). Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { createClient } from '@/lib/supabase/server';
|
|
import {
|
|
INTEGRATION_PROVIDERS,
|
|
isAgencyRole,
|
|
serviceHeaders,
|
|
supabaseUrl,
|
|
userRole,
|
|
} from '@/lib/agency';
|
|
|
|
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 one integration status/account (manual connect for now). */
|
|
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;
|
|
|
|
if (!tenantId || !provider || !status) {
|
|
return NextResponse.json(
|
|
{ error: 'tenantId, provider y status requeridos' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (
|
|
!['disconnected', 'pending', 'connected', 'error'].includes(status)
|
|
) {
|
|
return NextResponse.json({ error: 'status inválido' }, { status: 400 });
|
|
}
|
|
|
|
const patch: Record<string, unknown> = {
|
|
status,
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
if (externalAccountId !== undefined) {
|
|
patch.external_account_id = externalAccountId || null;
|
|
}
|
|
if (label !== undefined) patch.label = label || null;
|
|
if (errorMessage !== undefined) patch.error_message = errorMessage || null;
|
|
if (status === 'connected') {
|
|
patch.last_sync_at = new Date().toISOString();
|
|
patch.error_message = null;
|
|
}
|
|
|
|
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) {
|
|
// upsert if missing
|
|
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,
|
|
...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 });
|
|
}
|