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>
482 lines
15 KiB
TypeScript
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] });
|
|
}
|