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> & { 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') || ''; 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> & { 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' : '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] }); }