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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user