feat: auditoría SEO real con histórico y avisos

Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 16:55:58 +02:00
parent d896041bed
commit 06024cf473
6 changed files with 1008 additions and 67 deletions

View File

@@ -7,7 +7,7 @@ import {
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic';
import type { GeoSnapshot, OrganicDailyRow, SeoAuditRow } from '@/lib/organic';
import {
fetchGa4TopLandings,
fetchGscTopRows,
@@ -16,6 +16,7 @@ import {
} from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { isoYesterday } from '@/lib/ad-tracker-range';
import { runSeoSiteAudit } from '@/lib/seo-audit';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
@@ -136,6 +137,7 @@ export async function GET(req: NextRequest) {
portfolioWide: true,
organic: [] as OrganicDailyRow[],
geo: [] as GeoSnapshot[],
seoAudits: [] as SeoAuditRow[],
integrations: [],
warning: 'Selecciona un cliente en Contexto para ver Orgánico.',
});
@@ -170,7 +172,7 @@ export async function GET(req: NextRequest) {
since && until
? `&day=gte.${since}&day=lte.${until}`
: '';
const [oRes, gRes, iRes, tRes] = await Promise.all([
const [oRes, gRes, iRes, tRes, aRes] = await Promise.all([
fetch(
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
{ headers, cache: 'no-store' }
@@ -187,13 +189,18 @@ export async function GET(req: NextRequest) {
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/seo_audits?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=20`,
{ headers, cache: 'no-store' }
),
]);
const warningParts: string[] = [];
let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = [];
let integrations: unknown[] = [];
let workspace = {};
let workspace: Record<string, unknown> = {};
let seoAudits: SeoAuditRow[] = [];
if (oRes.ok) organic = await oRes.json();
else {
@@ -216,6 +223,17 @@ export async function GET(req: NextRequest) {
const rows = await tRes.json();
workspace = rows[0]?.settings?.organico || {};
}
if (aRes.ok) {
seoAudits = await aRes.json();
} else {
const t = await aRes.text();
if (t.includes('seo_audits')) {
const fallback = Array.isArray(workspace.seoAudits)
? (workspace.seoAudits as SeoAuditRow[])
: [];
seoAudits = fallback;
}
}
const live = await loadLiveSeoDims(
integrations as {
@@ -232,6 +250,7 @@ export async function GET(req: NextRequest) {
tenantId: tid,
organic,
geo,
seoAudits,
integrations,
workspace,
autoSynced,
@@ -253,7 +272,7 @@ export async function GET(req: NextRequest) {
.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([
const [o, ge, integ, ten, aud] = await Promise.all([
organicQ,
supabase
.from('geo_snapshots')
@@ -267,8 +286,21 @@ export async function GET(req: NextRequest) {
)
.in('provider', ['ga4', 'search_console', 'tag_manager']),
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
supabase
.from('seo_audits')
.select('*')
.order('created_at', { ascending: false })
.limit(20),
]);
const workspace =
(ten.data?.settings as { organico?: Record<string, unknown> } | null)
?.organico || {};
let seoAudits: SeoAuditRow[] = (aud.data as SeoAuditRow[]) || [];
if (aud.error && Array.isArray(workspace.seoAudits)) {
seoAudits = workspace.seoAudits as SeoAuditRow[];
}
const live = await loadLiveSeoDims(
(integ.data || []) as {
provider?: string;
@@ -284,8 +316,9 @@ export async function GET(req: NextRequest) {
tenantId: tid,
organic: o.data || [],
geo: ge.data || [],
seoAudits,
integrations: integ.data || [],
workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {},
workspace,
autoSynced,
gscQueries: live.gscQueries,
gscPages: live.gscPages,
@@ -327,9 +360,147 @@ export async function POST(req: NextRequest) {
? 'geo'
: body.kind === 'workspace'
? 'workspace'
: 'organic';
: body.kind === 'seo-audit'
? 'seo-audit'
: 'organic';
const base = supabaseUrl();
if (kind === 'seo-audit') {
const iRes = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&provider=in.(ga4,search_console)&select=provider,status,external_account_id,meta`,
{ headers, cache: 'no-store' }
);
const integrations = iRes.ok
? ((await iRes.json()) as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { site_url?: string; property_id?: string };
}[])
: [];
const gsc = integrations.find(
(i) => i.provider === 'search_console' && i.status === 'connected'
);
const ga4 = integrations.find(
(i) => i.provider === 'ga4' && i.status === 'connected'
);
const gscSiteUrl =
gsc?.meta?.site_url ||
(gsc?.external_account_id?.includes('http') ||
gsc?.external_account_id?.startsWith('sc-domain:')
? gsc.external_account_id
: null);
let samplePages: string[] = [];
if (gscSiteUrl) {
try {
const until = isoYesterday();
const sinceDate = new Date(`${until}T12:00:00Z`);
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
const since = sinceDate.toISOString().slice(0, 10);
const pages = await fetchGscTopRows({
siteUrl: gscSiteUrl,
startDate: since,
endDate: until,
dimension: 'page',
});
samplePages = pages.map((p) => p.key).slice(0, 3);
} catch {
// non-fatal
}
}
const result = await runSeoSiteAudit({
gscSiteUrl,
ga4Connected: ga4?.status === 'connected',
gscConnected: gsc?.status === 'connected',
samplePages,
});
const row = {
tenant_id: ctx.tenantId,
site_url: result.siteUrl,
score: result.score,
grade: result.grade,
warning_count: result.warningCount,
critical_count: result.criticalCount,
checks: result.checks,
summary: result.summary,
};
const r = await fetch(`${base}/rest/v1/seo_audits`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (r.ok) {
const saved = (await r.json())[0] as SeoAuditRow;
return NextResponse.json({ audit: saved });
}
// Fallback: persist last 20 audits in tenants.settings.organico.seoAudits
const msg = await r.text();
if (!msg.includes('seo_audits')) {
return NextResponse.json({ error: msg.slice(0, 300) }, { status: 500 });
}
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 prevOrg =
settings.organico && typeof settings.organico === 'object'
? (settings.organico as Record<string, unknown>)
: {};
const prevAudits = Array.isArray(prevOrg.seoAudits)
? (prevOrg.seoAudits as SeoAuditRow[])
: [];
const audit: SeoAuditRow = {
id: `ws-${Date.now()}`,
...row,
created_at: result.createdAt,
};
settings.organico = {
...prevOrg,
seoAudits: [audit, ...prevAudits].slice(0, 20),
};
const patch = 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 (!patch.ok) {
return NextResponse.json(
{ error: (await patch.text()).slice(0, 300) },
{ status: 500 }
);
}
return NextResponse.json({
audit,
warning: 'seo_audits table missing — histórico en settings (aplicar sql/seo_audits.sql)',
});
}
if (kind === 'workspace') {
// Merge organico workspace into tenants.settings
const cur = await fetch(