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:
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
src/app/api/admin/tenants/route.ts
Normal file
86
src/app/api/admin/tenants/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user