feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal
Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId : undefined;
|
||||
const days =
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 90
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 1000
|
||||
? body.days
|
||||
: 30;
|
||||
|
||||
|
||||
@@ -7,14 +7,8 @@ import {
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
SUPERHUMAN_PROVIDER,
|
||||
bestDocForTenant,
|
||||
buildSeguimientoRowsForDoc,
|
||||
listDocs,
|
||||
matchDocsToTenants,
|
||||
patchSuperhumanIntegration,
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
upsertSeguimientoRows,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -51,6 +45,9 @@ export async function GET() {
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
|
||||
try {
|
||||
const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import(
|
||||
'@/lib/superhuman-docs'
|
||||
);
|
||||
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||
.headers;
|
||||
const [docs, tenantsRes] = await Promise.all([
|
||||
@@ -91,8 +88,6 @@ export async function GET() {
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||
.headers;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const onlyTenant: string | undefined = body.tenantId
|
||||
@@ -102,128 +97,14 @@ export async function POST(req: NextRequest) {
|
||||
const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
|
||||
|
||||
try {
|
||||
const docs = await listDocs();
|
||||
const tRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) {
|
||||
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
|
||||
}
|
||||
let tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
if (onlyTenant) {
|
||||
tenants = tenants.filter((t) => t.id === onlyTenant);
|
||||
}
|
||||
|
||||
const results: {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
docId: string | null;
|
||||
docName: string | null;
|
||||
upserted: number;
|
||||
status: string;
|
||||
error?: string;
|
||||
}[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
let docId: string | null = null;
|
||||
let docName: string | null = null;
|
||||
|
||||
// Prefer already-linked integration
|
||||
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
|
||||
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
|
||||
const integRows = integR.ok
|
||||
? ((await integR.json()) as {
|
||||
external_account_id?: string | null;
|
||||
meta?: { doc_name?: string };
|
||||
}[])
|
||||
: [];
|
||||
if (integRows[0]?.external_account_id) {
|
||||
docId = integRows[0].external_account_id;
|
||||
docName =
|
||||
integRows[0].meta?.doc_name ||
|
||||
docs.find((d) => d.id === docId)?.name ||
|
||||
null;
|
||||
} else {
|
||||
const hit = bestDocForTenant(tenant, docs);
|
||||
if (hit) {
|
||||
docId = hit.doc.id;
|
||||
docName = hit.doc.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!docId) {
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'disconnected',
|
||||
error_message: 'Sin doc Superhuman matched por nombre',
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId: null,
|
||||
docName: null,
|
||||
upserted: 0,
|
||||
status: 'unmatched',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await buildSeguimientoRowsForDoc({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
const upserted = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'connected',
|
||||
external_account_id: docId,
|
||||
label: docName || 'Superhuman Docs',
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: docName,
|
||||
entries: upserted,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted,
|
||||
status: 'synced',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'sync failed';
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'error',
|
||||
external_account_id: docId,
|
||||
error_message: msg.slice(0, 400),
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted: 0,
|
||||
status: 'error',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await syncAllSuperhumanTenants({
|
||||
tenantId: onlyTenant || null,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
results,
|
||||
synced: results.filter((r) => r.status === 'synced').length,
|
||||
...result,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -310,7 +310,7 @@ export async function POST(req: NextRequest) {
|
||||
`${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**`
|
||||
).join('\n\n');
|
||||
|
||||
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: Datos diarios procesados vía Windsor.ai.*`);
|
||||
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`);
|
||||
} else {
|
||||
return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.');
|
||||
}
|
||||
@@ -347,7 +347,7 @@ export async function POST(req: NextRequest) {
|
||||
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
||||
).join('\n\n');
|
||||
|
||||
return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`);
|
||||
return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
src/app/api/cron/ads-sync/route.ts
Normal file
43
src/app/api/cron/ads-sync/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily Meta + Google Ads → ad_daily.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const days = Math.min(
|
||||
90,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
);
|
||||
|
||||
try {
|
||||
const { results, days: d } = await syncAdsForTenants({ days });
|
||||
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
|
||||
const withErrors = results.filter((r) => r.errors.length).length;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
days: d,
|
||||
synced: ok,
|
||||
withErrors,
|
||||
results,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
100
src/app/api/cron/daily-sync/route.ts
Normal file
100
src/app/api/cron/daily-sync/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
import {
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Master daily job: organic + ads + superhuman.
|
||||
* Dokploy (daily ~06:00 UTC):
|
||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||
* "https://app.4ventures.es/api/cron/daily-sync"
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const days = Math.min(
|
||||
28,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
);
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
ok: true,
|
||||
days,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 1) Organic
|
||||
try {
|
||||
const oauth = await getAgencyOAuthStatus();
|
||||
if (!oauth.connected) {
|
||||
out.organic = { ok: false, warning: 'Google agencia no conectado' };
|
||||
} else {
|
||||
out.organic = {
|
||||
ok: true,
|
||||
...(await syncAllConnectedOrganic({ days })),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
out.organic = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'organic failed',
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Ads
|
||||
try {
|
||||
const ads = await syncAdsForTenants({ days });
|
||||
out.ads = {
|
||||
ok: true,
|
||||
synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length,
|
||||
withErrors: ads.results.filter((r) => r.errors.length).length,
|
||||
results: ads.results,
|
||||
};
|
||||
} catch (e) {
|
||||
out.ads = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'ads failed',
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Superhuman
|
||||
try {
|
||||
if (!superhumanConfigured()) {
|
||||
out.superhuman = {
|
||||
ok: false,
|
||||
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
|
||||
};
|
||||
} else {
|
||||
out.superhuman = {
|
||||
ok: true,
|
||||
...(await syncAllSuperhumanTenants({
|
||||
maxEntries: 80,
|
||||
exportBodies: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
out.superhuman = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'superhuman failed',
|
||||
};
|
||||
}
|
||||
|
||||
const failed = ['organic', 'ads', 'superhuman'].some(
|
||||
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
|
||||
);
|
||||
return NextResponse.json(out, { status: failed ? 207 : 200 });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
|
||||
@@ -6,20 +7,12 @@ export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily (or any schedule) organic sync for all connected tenants.
|
||||
* Daily organic sync for all connected tenants.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET
|
||||
* Dokploy cron example (daily 06:15 UTC):
|
||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET?.trim();
|
||||
const auth = req.headers.get('authorization') || '';
|
||||
const q = req.nextUrl.searchParams.get('secret') || '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : q;
|
||||
|
||||
if (!secret || token !== secret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const status = await getAgencyOAuthStatus();
|
||||
if (!status.connected) {
|
||||
@@ -29,7 +22,6 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Rolling 7 days is enough daily; full history already filled by first sync
|
||||
const days = Math.min(
|
||||
28,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
|
||||
46
src/app/api/cron/superhuman-sync/route.ts
Normal file
46
src/app/api/cron/superhuman-sync/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import {
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily Superhuman / Seguimiento sync for all tenants.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
if (!superhumanConfigured()) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncAllSuperhumanTenants({
|
||||
maxEntries: 100,
|
||||
exportBodies: true,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...result,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -18,6 +18,73 @@ type Integ = {
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function cacheSocialSnapshot(
|
||||
tenantId: string,
|
||||
provider: 'facebook' | 'instagram',
|
||||
payload: unknown
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const now = new Date().toISOString();
|
||||
// Merge into meta without wiping other keys: read-modify-write
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
let meta: Record<string, unknown> = {};
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
|
||||
meta = { ...(rows[0]?.meta || {}) };
|
||||
}
|
||||
// Keep a compact snapshot (metrics + up to 12 posts)
|
||||
const snap =
|
||||
payload && typeof payload === 'object'
|
||||
? {
|
||||
...(payload as object),
|
||||
posts: Array.isArray((payload as { posts?: unknown }).posts)
|
||||
? (payload as { posts: unknown[] }).posts.slice(0, 12)
|
||||
: undefined,
|
||||
}
|
||||
: payload;
|
||||
meta.social_cache = snap;
|
||||
meta.social_cached_at = now;
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meta,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readSocialCache(integrations: Integ[]): {
|
||||
facebook?: unknown;
|
||||
instagram?: unknown;
|
||||
at?: string;
|
||||
} {
|
||||
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
|
||||
for (const i of integrations) {
|
||||
const cache = i.meta?.social_cache;
|
||||
const at = typeof i.meta?.social_cached_at === 'string'
|
||||
? i.meta.social_cached_at
|
||||
: i.last_sync_at || undefined;
|
||||
if (!cache) continue;
|
||||
if (i.provider === 'facebook') out.facebook = cache;
|
||||
if (i.provider === 'instagram') out.instagram = cache;
|
||||
if (at && (!out.at || at > out.at)) out.at = at;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SocialPost = {
|
||||
id: string;
|
||||
platform: 'facebook' | 'instagram';
|
||||
@@ -585,25 +652,34 @@ export async function GET(req: NextRequest) {
|
||||
const posts: SocialPost[] = [];
|
||||
const tokens = await resolveMetaAccessTokens();
|
||||
|
||||
const fbInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'facebook' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
const igInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'instagram' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
|
||||
if (!tokens.length) {
|
||||
metrics.warning =
|
||||
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram) metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
metrics.warning +=
|
||||
' Mostrando último snapshot cacheado.';
|
||||
}
|
||||
} else {
|
||||
const fbInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'facebook' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
const igInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'instagram' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
|
||||
const token =
|
||||
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
|
||||
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
|
||||
@@ -621,6 +697,7 @@ export async function GET(req: NextRequest) {
|
||||
if ('posts' in fb && Array.isArray(fb.posts)) {
|
||||
posts.push(...(fb.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
|
||||
}
|
||||
if (igInteg?.external_account_id) {
|
||||
const ig = await fetchIgBundle(
|
||||
@@ -633,9 +710,18 @@ export async function GET(req: NextRequest) {
|
||||
if ('posts' in ig && Array.isArray(ig.posts)) {
|
||||
posts.push(...(ig.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.warning = e instanceof Error ? e.message : 'metrics error';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram && !metrics.instagram)
|
||||
metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,8 +110,13 @@ async function resolveTenantId(slugOrId: string | null): Promise<string | null>
|
||||
const base = supabaseUrl();
|
||||
// UUID?
|
||||
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
|
||||
// Legacy slug aliases (renames)
|
||||
const SLUG_ALIASES: Record<string, string> = {
|
||||
herencias: 'fbm-abogados',
|
||||
};
|
||||
const slug = SLUG_ALIASES[slugOrId.toLowerCase()] || slugOrId;
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slugOrId)}&select=id&limit=1`,
|
||||
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
@@ -130,7 +135,59 @@ async function upsertLead(tenantId: string, c: Json) {
|
||||
str(c.channel, 80) ||
|
||||
str(c.medium, 80) ||
|
||||
'clientify';
|
||||
const row = {
|
||||
const newStatus = (str(c.status, 40) || 'new').toLowerCase();
|
||||
const now = new Date().toISOString();
|
||||
const base = supabaseUrl();
|
||||
|
||||
// Preserve alta date across webhook re-writes (DELETE+INSERT).
|
||||
let prevCreated: string | null = null;
|
||||
let prevStatus: string | null = null;
|
||||
let prevStatusChanged: string | null = null;
|
||||
const prevR = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status,status_changed_at&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (prevR.ok) {
|
||||
const rows = (await prevR.json()) as {
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
status_changed_at?: string | null;
|
||||
}[];
|
||||
if (rows[0]) {
|
||||
prevCreated = rows[0].created_at || null;
|
||||
prevStatus = (rows[0].status || '').toLowerCase() || null;
|
||||
prevStatusChanged = rows[0].status_changed_at || null;
|
||||
}
|
||||
} else {
|
||||
// status_changed_at may not exist yet — retry without it
|
||||
const prevR2 = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (prevR2.ok) {
|
||||
const rows = (await prevR2.json()) as {
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
}[];
|
||||
if (rows[0]) {
|
||||
prevCreated = rows[0].created_at || null;
|
||||
prevStatus = (rows[0].status || '').toLowerCase() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fromCfy =
|
||||
str(c.created, 40) ||
|
||||
str(c.created_at, 40) ||
|
||||
str(c.date_created, 40);
|
||||
let createdAt = prevCreated || fromCfy || now;
|
||||
// Normalize date-only
|
||||
if (createdAt.length === 10) createdAt = `${createdAt}T12:00:00.000Z`;
|
||||
|
||||
const statusChanged =
|
||||
!prevStatus || prevStatus !== newStatus ? now : prevStatusChanged || createdAt;
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
first_name:
|
||||
@@ -142,19 +199,45 @@ async function upsertLead(tenantId: string, c: Json) {
|
||||
medium: str(c.medium, 80),
|
||||
crm: 'clientify',
|
||||
crm_contact_id: cid,
|
||||
status: (str(c.status, 40) || 'new').toLowerCase(),
|
||||
status: newStatus,
|
||||
created_at: createdAt,
|
||||
updated_at: now,
|
||||
status_changed_at: statusChanged,
|
||||
};
|
||||
// Prefer upsert on crm_contact_id if unique; else delete+insert
|
||||
const base = supabaseUrl();
|
||||
|
||||
await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
const ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
|
||||
let ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
...headers,
|
||||
Prefer: 'return=minimal',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
// Schema not migrated yet: drop timing columns and retry
|
||||
if (!ins.ok) {
|
||||
const err = await ins.text();
|
||||
if (err.includes('updated_at') || err.includes('status_changed_at')) {
|
||||
delete row.updated_at;
|
||||
delete row.status_changed_at;
|
||||
ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
Prefer: 'return=minimal',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`lead insert ${ins.status}: ${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
if (!ins.ok) {
|
||||
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
|
||||
}
|
||||
@@ -218,7 +301,7 @@ export async function GET(req: NextRequest) {
|
||||
service: 'clientify-webhook',
|
||||
tenant: tenant || null,
|
||||
hint: 'POST events from Clientify. Use ?tenant=<slug>',
|
||||
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia',
|
||||
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=<slug>&secret=…',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,7 +309,17 @@ export async function POST(req: NextRequest) {
|
||||
const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
|
||||
const secretQ = req.nextUrl.searchParams.get('secret');
|
||||
const secretH = req.headers.get('x-clientify-secret');
|
||||
if (secretEnv && secretQ !== secretEnv && secretH !== secretEnv) {
|
||||
// Obligatorio: sin secreto el webhook es inyectable desde fuera
|
||||
if (!secretEnv) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'webhook_not_configured',
|
||||
hint: 'Define CLIENTIFY_WEBHOOK_SECRET en Dokploy',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (secretQ !== secretEnv && secretH !== secretEnv) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -238,7 +331,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'missing_or_unknown_tenant',
|
||||
hint: 'Add ?tenant=agua-miia (slug) to the webhook URL',
|
||||
hint: 'Add ?tenant=<slug>&secret=… to the webhook URL',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -260,10 +353,12 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (entity === 'contact') {
|
||||
const r = await upsertLead(tenantId, payload);
|
||||
await markClientifyConnected(tenantId, tenantParam);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
if (entity === 'deal') {
|
||||
const r = await upsertDeal(tenantId, payload);
|
||||
await markClientifyConnected(tenantId, tenantParam);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
// company/activity/budget/product: acknowledge for now
|
||||
@@ -279,3 +374,52 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Webhook escribe leads pero la ficha quedaba "disconnected" — alinear status. */
|
||||
async function markClientifyConnected(
|
||||
tenantId: string,
|
||||
slugHint: string | null
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const base = supabaseUrl();
|
||||
const now = new Date().toISOString();
|
||||
const body = {
|
||||
status: 'connected',
|
||||
enabled: true,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
label: 'Clientify',
|
||||
external_account_id: slugHint || undefined,
|
||||
meta: {
|
||||
source: 'webhook',
|
||||
synced_at: now,
|
||||
},
|
||||
};
|
||||
const patch = await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.clientify`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (patch.ok) return;
|
||||
await fetch(`${base}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=ignore-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
provider: 'clientify',
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ async function fetchCrmLeads(
|
||||
includeTenantId: boolean
|
||||
): Promise<CrmLeadRow[]> {
|
||||
const select = includeTenantId
|
||||
? 'tenant_id,source,created_at,status,updated_at,status_changed_at'
|
||||
: 'source,created_at,status,updated_at,status_changed_at';
|
||||
const selectFallback = includeTenantId
|
||||
? 'tenant_id,source,created_at,status'
|
||||
: 'source,created_at,status';
|
||||
const pageSize = 1000;
|
||||
@@ -27,12 +30,20 @@ async function fetchCrmLeads(
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 80000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
`${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) break;
|
||||
if (!r.ok) {
|
||||
if (useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = (await r.json()) as CrmLeadRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
@@ -42,12 +53,24 @@ async function fetchCrmLeads(
|
||||
|
||||
const supabase = await createClient();
|
||||
for (let offset = 0; offset < 80000; offset += pageSize) {
|
||||
const { data } = await supabase
|
||||
let q = supabase
|
||||
.from('leads')
|
||||
.select(select)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
let { data, error } = await q;
|
||||
if (error && offset === 0) {
|
||||
const retry = await supabase
|
||||
.from('leads')
|
||||
.select(selectFallback)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
data = retry.data;
|
||||
error = retry.error;
|
||||
}
|
||||
if (error) break;
|
||||
const chunk = (data as CrmLeadRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildComparisons,
|
||||
mergeDealsIntoWeekly,
|
||||
resolveWeeklyRows,
|
||||
resolveWonCohort,
|
||||
sumDaily,
|
||||
sumWeekly,
|
||||
} from '@/lib/metrics';
|
||||
@@ -19,12 +20,13 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Paginate CRM leads (source + created_at) for attribution cross. */
|
||||
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
|
||||
async function fetchCrmLeads(
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<CrmLeadRow[]> {
|
||||
const select = 'source,created_at';
|
||||
const select = 'source,created_at,status,tenant_id,updated_at,status_changed_at';
|
||||
const selectFallback = 'source,created_at,status,tenant_id';
|
||||
const pageSize = 1000;
|
||||
const out: CrmLeadRow[] = [];
|
||||
|
||||
@@ -35,12 +37,20 @@ async function fetchCrmLeads(
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
`${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) break;
|
||||
if (!r.ok) {
|
||||
if (useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = (await r.json()) as CrmLeadRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
@@ -50,12 +60,23 @@ async function fetchCrmLeads(
|
||||
|
||||
const supabase = await createClient();
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const { data } = await supabase
|
||||
let { data, error } = await supabase
|
||||
.from('leads')
|
||||
.select(select)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
if (error && offset === 0) {
|
||||
const retry = await supabase
|
||||
.from('leads')
|
||||
.select(selectFallback)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
data = retry.data;
|
||||
error = retry.error;
|
||||
}
|
||||
if (error) break;
|
||||
const chunk = (data as CrmLeadRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
@@ -163,6 +184,9 @@ export default async function KpisPage() {
|
||||
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
||||
}
|
||||
|
||||
const cohort = resolveWonCohort(wonDeals, crmLeads);
|
||||
wonDeals = cohort.deals;
|
||||
|
||||
weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals);
|
||||
|
||||
const comparisons = buildComparisons(weekly);
|
||||
@@ -188,6 +212,7 @@ export default async function KpisPage() {
|
||||
dailyRows={daily}
|
||||
crmLeads={crmLeads}
|
||||
wonDeals={wonDeals}
|
||||
cohortSource={cohort.source}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user