feat: agency control panel for per-client connections

Matrix view of Meta/Google/Drive/Clientify, follow/archive clients,
and per-channel ON/OFF with account IDs in the client hub.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 18:47:29 +02:00
parent 1eb02dac4d
commit c4c527072f
10 changed files with 637 additions and 140 deletions

View File

@@ -111,32 +111,43 @@ export async function PATCH(req: NextRequest) {
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 || !status) {
if (!tenantId || !provider) {
return NextResponse.json(
{ error: 'tenantId, provider y status requeridos' },
{ error: 'tenantId y provider requeridos' },
{ status: 400 }
);
}
if (
!['disconnected', 'pending', 'connected', 'error'].includes(status)
) {
if (status && !['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 (status) patch.status = status;
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 (enabled !== undefined) patch.enabled = enabled;
if (status === 'connected') {
patch.last_sync_at = new Date().toISOString();
patch.error_message = null;
}
if (status === 'disconnected') {
// keep external id for re-link unless cleared
}
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)}`;
@@ -162,6 +173,7 @@ export async function PATCH(req: NextRequest) {
body: JSON.stringify({
tenant_id: tenantId,
provider,
status: status || 'disconnected',
...patch,
}),
});

View File

@@ -33,12 +33,15 @@ export async function GET() {
const base = supabaseUrl();
const [tenantsRes, integRes, alertsRes] = await Promise.all([
fetch(`${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`, {
headers,
cache: 'no-store',
}),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message`,
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
@@ -47,17 +50,35 @@ export async function GET() {
),
]);
const tenants = tenantsRes.ok ? await tenantsRes.json() : [];
const integrations = integRes.ok ? await integRes.json() : [];
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
// Fallback if agency_followed column missing
if (!tenantsRes.ok) {
const again = await fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`,
{ headers, cache: 'no-store' }
);
tenants = again.ok ? await again.json() : [];
}
let integrations: unknown[] = [];
let integMissing = false;
if (integRes.ok) {
integrations = await integRes.json();
} else {
const msg = await integRes.text();
integMissing = msg.toLowerCase().includes('does not exist');
}
const alerts = alertsRes.ok ? await alertsRes.json() : [];
const integMissing =
!integRes.ok &&
(await integRes.text().catch(() => '')).toLowerCase().includes('does not exist');
type Integ = {
tenant_id: string;
provider: string;
status: string;
label?: string | null;
external_account_id?: string | null;
enabled?: boolean;
error_message?: string | null;
meta?: Record<string, unknown> | null;
};
const byTenant = new Map<string, Integ[]>();
for (const row of integrations as Integ[]) {
@@ -66,32 +87,47 @@ export async function GET() {
byTenant.set(row.tenant_id, list);
}
const portfolio = (tenants as { id: string; name: string; slug: string; created_at: string }[]).map(
(t) => {
const rows = byTenant.get(t.id) || [];
const connected = rows.filter((r) => r.status === 'connected').length;
const errors = rows.filter((r) => r.status === 'error').length;
const pending = rows.filter((r) => r.status === 'pending').length;
return {
...t,
integrations: rows,
stats: {
connected,
errors,
pending,
totalProviders: INTEGRATION_PROVIDERS.length,
health:
errors > 0
? 'critical'
: connected === 0
? 'empty'
: pending > 0
? 'warning'
: 'ok',
},
};
const portfolio = (
tenants as {
id: string;
name: string;
slug: string;
created_at: string;
agency_followed?: boolean;
agency_notes?: string | null;
}[]
).map((t) => {
const rows = byTenant.get(t.id) || [];
const active = rows.filter((r) => r.enabled !== false);
const connected = active.filter((r) => r.status === 'connected').length;
const errors = active.filter((r) => r.status === 'error').length;
const pending = active.filter((r) => r.status === 'pending').length;
const byProvider: Record<string, Integ | undefined> = {};
for (const p of INTEGRATION_PROVIDERS) {
byProvider[p.id] = rows.find((r) => r.provider === p.id);
}
);
return {
...t,
agency_followed: t.agency_followed !== false,
integrations: rows,
byProvider,
stats: {
connected,
errors,
pending,
enabledChannels: active.length,
totalProviders: INTEGRATION_PROVIDERS.length,
health:
errors > 0
? 'critical'
: connected === 0
? 'empty'
: pending > 0
? 'warning'
: 'ok',
},
};
});
return NextResponse.json({
portfolio,

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
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: 'no service role' }, { status: 503 }),
};
}
return { headers };
}
/** PATCH — follow/unfollow client or set notes. */
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() : '';
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const patch: Record<string, unknown> = {};
if (typeof body.followed === 'boolean') {
patch.agency_followed = body.followed;
}
if (typeof body.notes === 'string') {
patch.agency_notes = body.notes.trim() || null;
}
if (!Object.keys(patch).length) {
return NextResponse.json(
{ error: 'followed o notes requeridos' },
{ status: 400 }
);
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
}
);
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('agency_followed')
? 'Aplicar sql/agency_control.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
const rows = await r.json();
return NextResponse.json({ tenant: rows[0] || rows });
}