Files
4ventures-client-portal/src/app/api/organico/route.ts
4Ventures 5c90bcf940 feat: vista Orgánico con SEO (GA4/GSC) y GEO (visibilidad IA)
Nueva sección con métricas Search Console/GA4, cimientos SEO y
Share of Model por motor (ChatGPT/Gemini/AI Overviews).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:18:26 +02:00

267 lines
8.4 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';
export const dynamic = 'force-dynamic';
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') || '';
if (agency && headers) {
const base = supabaseUrl();
const dayFilter =
since && until
? `&day=gte.${since}&day=lte.${until}`
: '';
const [oRes, gRes, iRes] = 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`,
{ headers, cache: 'no-store' }
),
]);
const warningParts: string[] = [];
let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = [];
let integrations: unknown[] = [];
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();
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic,
geo,
integrations,
warning: warningParts[0] || 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] = 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'
)
.in('provider', ['ga4', 'search_console', 'tag_manager']),
]);
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic: o.data || [],
geo: ge.data || [],
integrations: integ.data || [],
warning: o.error?.message || ge.error?.message || 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' : 'organic';
const base = supabaseUrl();
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] });
}