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> = []; let gscPages: Awaited> = []; let ga4Landings: Awaited> = []; 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> & { ctx: { tenantId: string | null; portfolioWide: boolean; label: string }; agency: boolean; headers: ReturnType; }; 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> & { ctx: { tenantId: string | null }; agency: boolean; headers: ReturnType; }; 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 = {}; 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) : {}; 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] }); }