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>
This commit is contained in:
266
src/app/api/organico/route.ts
Normal file
266
src/app/api/organico/route.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
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] });
|
||||
}
|
||||
Reference in New Issue
Block a user