feat: agency portfolio with per-client integrations hub

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>
This commit is contained in:
4Ventures
2026-09-22 01:31:47 +02:00
parent 9d1609c88b
commit 791976a3d8
19 changed files with 1503 additions and 20 deletions

View File

@@ -0,0 +1,180 @@
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 });
}