Files
4ventures-client-portal/src/app/api/organico/route.ts
4Ventures f4dfc50cc9 feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes
Calendario editorial anidado en Social; Orgánico con queries/páginas GSC y landings GA4 + dashboard GEO; Biblioteca enlazada a la carpeta real del cliente bajo 03-Clientes (no carpetas homónimas); previews Ads solo en Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:12:07 +02:00

482 lines
15 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic';
import {
fetchGa4TopLandings,
fetchGscTopRows,
organicNeedsRefresh,
syncAllConnectedOrganic,
} from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { isoYesterday } from '@/lib/ad-tracker-range';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
async function loadLiveSeoDims(
integrations: { provider?: string; status?: string; external_account_id?: string | null; meta?: { property_id?: string; site_url?: string } }[]
): Promise<{
gscQueries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
gscPages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
ga4Landings: { page: string; sessions: number; users: number }[];
liveWarning?: string;
}> {
const empty = { gscQueries: [], gscPages: [], ga4Landings: [] };
try {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) return empty;
const gsc = integrations.find(
(i) => i.provider === 'search_console' && i.status === 'connected'
);
const ga4 = integrations.find(
(i) => i.provider === 'ga4' && i.status === 'connected'
);
const until = isoYesterday();
const sinceDate = new Date(`${until}T12:00:00Z`);
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
const since = sinceDate.toISOString().slice(0, 10);
const siteUrl =
gsc?.meta?.site_url ||
(gsc?.external_account_id?.includes('http') ||
gsc?.external_account_id?.startsWith('sc-domain:')
? gsc.external_account_id
: null);
const propertyId =
ga4?.meta?.property_id ||
(ga4?.external_account_id?.startsWith('properties/')
? ga4.external_account_id
: ga4?.external_account_id?.match(/^\d+$/)
? `properties/${ga4.external_account_id}`
: null);
const warnings: string[] = [];
let gscQueries: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
let gscPages: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
let ga4Landings: Awaited<ReturnType<typeof fetchGa4TopLandings>> = [];
if (siteUrl) {
try {
gscQueries = await fetchGscTopRows({
siteUrl,
startDate: since,
endDate: until,
dimension: 'query',
});
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc queries');
}
try {
gscPages = await fetchGscTopRows({
siteUrl,
startDate: since,
endDate: until,
dimension: 'page',
});
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc pages');
}
}
if (propertyId) {
try {
ga4Landings = await fetchGa4TopLandings({
propertyId,
startDate: since,
endDate: until,
});
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'ga4 landings');
}
}
return {
gscQueries,
gscPages,
ga4Landings,
liveWarning: warnings[0],
};
} catch {
return empty;
}
}
async function gate() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
return { user, ctx, agency, headers: serviceHeaders() };
}
/** GET — organic series + latest GEO snapshot + integration status for active tenant. */
export async function GET(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string | null; portfolioWide: boolean; label: string };
agency: boolean;
headers: ReturnType<typeof serviceHeaders>;
};
if (ctx.portfolioWide || !ctx.tenantId) {
return NextResponse.json({
portfolioWide: true,
organic: [] as OrganicDailyRow[],
geo: [] as GeoSnapshot[],
integrations: [],
warning: 'Selecciona un cliente en Contexto para ver Orgánico.',
});
}
const tid = ctx.tenantId;
const since = req.nextUrl.searchParams.get('since') || '';
const until = req.nextUrl.searchParams.get('until') || '';
const skipAuto = req.nextUrl.searchParams.get('skipAutoSync') === '1';
// Keep Orgánico fresh when opening the page (if Google OAuth is connected)
let autoSynced: { daysWritten: number; warning?: string } | null = null;
if (!skipAuto) {
try {
const oauth = await getAgencyOAuthStatus();
if (oauth.connected && (await organicNeedsRefresh(tid))) {
const r = await syncAllConnectedOrganic({ tenantId: tid, days: 28 });
const one = r.results.find((x) => x.tenantId === tid);
autoSynced = {
daysWritten: one?.daysWritten || 0,
warning: one?.warning,
};
}
} catch {
// non-fatal — still return cached organic rows
}
}
if (agency && headers) {
const base = supabaseUrl();
const dayFilter =
since && until
? `&day=gte.${since}&day=lte.${until}`
: '';
const [oRes, gRes, iRes, tRes] = await Promise.all([
fetch(
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/geo_snapshots?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=12`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=in.(ga4,search_console,tag_manager)&select=provider,status,enabled,external_account_id,last_sync_at,error_message,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`,
{ headers, cache: 'no-store' }
),
]);
const warningParts: string[] = [];
let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = [];
let integrations: unknown[] = [];
let workspace = {};
if (oRes.ok) organic = await oRes.json();
else {
const t = await oRes.text();
warningParts.push(
t.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: t.slice(0, 120)
);
}
if (gRes.ok) geo = await gRes.json();
else {
const t = await gRes.text();
if (t.includes('geo_snapshots')) {
warningParts.push('Aplicar sql/organic_geo.sql (GEO)');
}
}
if (iRes.ok) integrations = await iRes.json();
if (tRes.ok) {
const rows = await tRes.json();
workspace = rows[0]?.settings?.organico || {};
}
const live = await loadLiveSeoDims(
integrations as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { property_id?: string; site_url?: string };
}[]
);
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic,
geo,
integrations,
workspace,
autoSynced,
gscQueries: live.gscQueries,
gscPages: live.gscPages,
ga4Landings: live.ga4Landings,
warning:
warningParts[0] ||
autoSynced?.warning ||
live.liveWarning ||
null,
});
}
const supabase = await createClient();
let organicQ = supabase
.from('organic_daily')
.select('*')
.order('day', { ascending: true });
if (since) organicQ = organicQ.gte('day', since);
if (until) organicQ = organicQ.lte('day', until);
const [o, ge, integ, ten] = await Promise.all([
organicQ,
supabase
.from('geo_snapshots')
.select('*')
.order('created_at', { ascending: false })
.limit(12),
supabase
.from('integrations')
.select(
'provider,status,enabled,external_account_id,last_sync_at,error_message,meta'
)
.in('provider', ['ga4', 'search_console', 'tag_manager']),
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
]);
const live = await loadLiveSeoDims(
(integ.data || []) as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { property_id?: string; site_url?: string };
}[]
);
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic: o.data || [],
geo: ge.data || [],
integrations: integ.data || [],
workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {},
autoSynced,
gscQueries: live.gscQueries,
gscPages: live.gscPages,
ga4Landings: live.ga4Landings,
warning:
o.error?.message ||
ge.error?.message ||
autoSynced?.warning ||
live.liveWarning ||
null,
});
}
/** POST — agency: upsert organic day OR create GEO snapshot. */
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string | null };
agency: boolean;
headers: ReturnType<typeof serviceHeaders>;
};
if (!agency) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
if (!ctx.tenantId) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const body = await req.json().catch(() => ({}));
const kind =
body.kind === 'geo'
? 'geo'
: body.kind === 'workspace'
? 'workspace'
: 'organic';
const base = supabaseUrl();
if (kind === 'workspace') {
// Merge organico workspace into tenants.settings
const cur = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
{ headers, cache: 'no-store' }
);
let settings: Record<string, unknown> = {};
if (cur.ok) {
const rows = await cur.json();
settings =
rows[0]?.settings && typeof rows[0].settings === 'object'
? { ...rows[0].settings }
: {};
}
const prev =
settings.organico && typeof settings.organico === 'object'
? (settings.organico as Record<string, unknown>)
: {};
settings.organico = {
...prev,
...(body.workspace && typeof body.workspace === 'object'
? body.workspace
: {}),
};
const r = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ settings }),
}
);
if (!r.ok) {
return NextResponse.json(
{ error: (await r.text()).slice(0, 300) },
{ status: 500 }
);
}
return NextResponse.json({
workspace: (await r.json())[0]?.settings?.organico || settings.organico,
});
}
if (kind === 'organic') {
const day = typeof body.day === 'string' ? body.day.slice(0, 10) : '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
return NextResponse.json({ error: 'day YYYY-MM-DD requerido' }, { status: 400 });
}
const row = {
tenant_id: ctx.tenantId,
day,
gsc_clicks: Number(body.gsc_clicks) || 0,
gsc_impressions: Number(body.gsc_impressions) || 0,
gsc_ctr:
body.gsc_ctr != null
? Number(body.gsc_ctr)
: Number(body.gsc_impressions) > 0
? (100 * Number(body.gsc_clicks || 0)) /
Number(body.gsc_impressions)
: null,
gsc_avg_position:
body.gsc_avg_position != null ? Number(body.gsc_avg_position) : null,
ga4_sessions: Number(body.ga4_sessions) || 0,
ga4_users: Number(body.ga4_users) || 0,
ga4_organic_sessions: Number(body.ga4_organic_sessions) || 0,
ga4_conversions: Number(body.ga4_conversions) || 0,
source: 'manual',
};
const r = await fetch(
`${base}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=representation',
},
body: JSON.stringify(row),
}
);
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({ row: (await r.json())[0] });
}
// GEO snapshot
const snapshot = {
tenant_id: ctx.tenantId,
label:
typeof body.label === 'string' && body.label.trim()
? body.label.trim()
: 'Medición GEO',
period_start: body.period_start || null,
period_end: body.period_end || null,
share_of_model:
body.share_of_model != null ? Number(body.share_of_model) : null,
engines: body.engines && typeof body.engines === 'object' ? body.engines : {},
official_citation_pct:
body.official_citation_pct != null
? Number(body.official_citation_pct)
: null,
sentiment_score:
body.sentiment_score != null ? Number(body.sentiment_score) : null,
competitors: Array.isArray(body.competitors) ? body.competitors : [],
categories: Array.isArray(body.categories) ? body.categories : [],
top_sources: Array.isArray(body.top_sources) ? body.top_sources : [],
prompts_analyzed:
body.prompts_analyzed != null ? Number(body.prompts_analyzed) : null,
responses_analyzed:
body.responses_analyzed != null ? Number(body.responses_analyzed) : null,
study_url: typeof body.study_url === 'string' ? body.study_url : null,
notes: typeof body.notes === 'string' ? body.notes : null,
};
const r = await fetch(`${base}/rest/v1/geo_snapshots`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(snapshot),
});
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('geo_snapshots')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({ snapshot: (await r.json())[0] });
}