feat: ficha por cliente en portafolio (conexiones, accesos, logo, módulos)

Cada tenant tiene una ficha con plataformas, invitaciones por email,
logo de marca y toggles de módulos visibles en el portal cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 10:51:21 +02:00
parent 21051afda9
commit dd0e867108
13 changed files with 1612 additions and 84 deletions

View File

@@ -6,6 +6,7 @@ import {
supabaseUrl,
userRole,
} from '@/lib/agency';
import type { TenantSettings } from '@/lib/tenant-modules';
export const dynamic = 'force-dynamic';
@@ -29,7 +30,74 @@ async function requireAgency() {
return { headers };
}
/** PATCH — follow/unfollow client or set notes. */
/** GET ?tenantId= — full ficha payload */
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')?.trim() || '';
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const base = supabaseUrl();
const [tRes, iRes] = await Promise.all([
fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug,logo_url,settings,agency_followed,agency_notes,created_at`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,enabled,external_account_id,label,last_sync_at,error_message,meta`,
{ headers, cache: 'no-store' }
),
]);
if (!tRes.ok) {
const msg = await tRes.text();
return NextResponse.json(
{
error: msg.includes('logo_url') || msg.includes('settings')
? 'Aplicar sql/tenant_ficha.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
const tenants = await tRes.json();
const tenant = tenants[0];
if (!tenant) {
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
}
let integrations: unknown[] = [];
let integWarning: string | null = null;
if (iRes.ok) {
integrations = await iRes.json();
} else {
integWarning = (await iRes.text()).slice(0, 200);
}
return NextResponse.json({
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
logoUrl: tenant.logo_url || null,
settings: (tenant.settings || {}) as TenantSettings,
followed: tenant.agency_followed !== false,
notes: tenant.agency_notes || '',
createdAt: tenant.created_at,
},
integrations,
warning: integWarning,
});
}
/** PATCH — follow/notes + ficha fields (name, logoUrl, settings). */
export async function PATCH(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
@@ -51,9 +119,22 @@ export async function PATCH(req: NextRequest) {
if (typeof body.notes === 'string') {
patch.agency_notes = body.notes.trim() || null;
}
if (typeof body.name === 'string' && body.name.trim()) {
patch.name = body.name.trim();
}
if (typeof body.logoUrl === 'string') {
patch.logo_url = body.logoUrl.trim() || null;
}
if (body.logoUrl === null) {
patch.logo_url = null;
}
if (body.settings && typeof body.settings === 'object') {
patch.settings = body.settings as TenantSettings;
}
if (!Object.keys(patch).length) {
return NextResponse.json(
{ error: 'followed o notes requeridos' },
{ error: 'Nada que actualizar' },
{ status: 400 }
);
}
@@ -74,9 +155,12 @@ export async function PATCH(req: NextRequest) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('agency_followed')
? 'Aplicar sql/agency_control.sql'
: msg.slice(0, 300),
error:
msg.includes('logo_url') || msg.includes('settings')
? 'Aplicar sql/tenant_ficha.sql en Supabase'
: msg.includes('agency_followed')
? 'Aplicar sql/agency_control.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);