diff --git a/src/app/api/assets/drive/route.ts b/src/app/api/assets/drive/route.ts index f814ef5..8e9bc99 100644 --- a/src/app/api/assets/drive/route.ts +++ b/src/app/api/assets/drive/route.ts @@ -3,8 +3,11 @@ import { createClient } from '@/lib/supabase/server'; import { isAgencyRole, userRole } from '@/lib/agency'; import { resolveActiveTenant } from '@/lib/tenant-context'; import { + findClientesRootFolder, getDriveDownloadUrl, + getDriveFileMeta, getTenantDriveFolderId, + isUnderDriveFolder, listFilesInFolder, } from '@/lib/google-drive'; @@ -12,7 +15,7 @@ export const dynamic = 'force-dynamic'; const FOLDER_MIME = 'application/vnd.google-apps.folder'; -/** GET — list Drive files for tenant folder (browse with ?folderId=). */ +/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */ export async function GET(req: NextRequest) { const supabase = await createClient(); const { @@ -35,6 +38,20 @@ export async function GET(req: NextRequest) { const downloadId = req.nextUrl.searchParams.get('download')?.trim(); if (downloadId) { try { + const link = await getTenantDriveFolderId(ctx.tenantId); + if (!link) { + return NextResponse.json( + { error: 'Sin carpeta Drive del cliente' }, + { status: 400 } + ); + } + const ok = await isUnderDriveFolder(downloadId, link.folderId); + if (!ok) { + return NextResponse.json( + { error: 'Archivo fuera de la carpeta del cliente' }, + { status: 403 } + ); + } const url = await getDriveDownloadUrl(downloadId); return NextResponse.json({ url }); } catch (e) { @@ -51,14 +68,45 @@ export async function GET(req: NextRequest) { files: [], linked: false, warning: - 'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match bajo «Clientes».', + 'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».', }); } + // Soft check: linked folder should live under «Clientes» + let scopeWarning: string | null = null; + try { + const clientes = await findClientesRootFolder(); + if (clientes) { + const ok = await isUnderDriveFolder(link.folderId, clientes.id); + if (!ok) { + scopeWarning = `La carpeta enlazada («${link.folderName || link.folderId}») no está dentro de «Clientes». Rematch en Agencia.`; + } + } + } catch { + /* ignore */ + } + const requested = req.nextUrl.searchParams.get('folderId')?.trim(); - const folderId = requested || link.folderId; + let folderId = link.folderId; + + if (requested && requested !== link.folderId) { + const allowed = await isUnderDriveFolder(requested, link.folderId); + if (!allowed) { + return NextResponse.json( + { + error: + 'Carpeta fuera del árbol del cliente. Solo se permite navegar dentro de su carpeta en Clientes.', + rootFolderId: link.folderId, + folderName: link.folderName, + }, + { status: 403 } + ); + } + folderId = requested; + } try { + const currentMeta = await getDriveFileMeta(folderId); const files = await listFilesInFolder(folderId); const mapped = files .map((f) => ({ @@ -79,22 +127,22 @@ export async function GET(req: NextRequest) { return NextResponse.json({ linked: true, rootFolderId: link.folderId, + rootFolderName: link.folderName, folderId, - folderName: link.folderName, + folderName: currentMeta?.name || link.folderName, driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`, - embedPossible: false, - embedHint: - 'Réplica vía API (misma carpeta Clientes del cliente). Embed iframe de Drive exige carpeta pública; usamos listado nativo + Abrir en Drive.', files: mapped, + warning: scopeWarning, }); } catch (e) { return NextResponse.json({ linked: true, rootFolderId: link.folderId, + rootFolderName: link.folderName, folderId, folderName: link.folderName, files: [], - warning: e instanceof Error ? e.message : 'Drive error', + warning: scopeWarning || (e instanceof Error ? e.message : 'Drive error'), }); } } diff --git a/src/app/api/organico/route.ts b/src/app/api/organico/route.ts index 5e3089d..84c0d9a 100644 --- a/src/app/api/organico/route.ts +++ b/src/app/api/organico/route.ts @@ -9,14 +9,105 @@ import { 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 { @@ -89,7 +180,7 @@ export async function GET(req: NextRequest) { { 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`, + `${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( @@ -126,6 +217,15 @@ export async function GET(req: NextRequest) { 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, @@ -135,7 +235,14 @@ export async function GET(req: NextRequest) { integrations, workspace, autoSynced, - warning: warningParts[0] || autoSynced?.warning || null, + gscQueries: live.gscQueries, + gscPages: live.gscPages, + ga4Landings: live.ga4Landings, + warning: + warningParts[0] || + autoSynced?.warning || + live.liveWarning || + null, }); } @@ -156,12 +263,21 @@ export async function GET(req: NextRequest) { supabase .from('integrations') .select( - 'provider,status,enabled,external_account_id,last_sync_at,error_message' + '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, @@ -171,7 +287,15 @@ export async function GET(req: NextRequest) { integrations: integ.data || [], workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {}, autoSynced, - warning: o.error?.message || ge.error?.message || autoSynced?.warning || null, + gscQueries: live.gscQueries, + gscPages: live.gscPages, + ga4Landings: live.ga4Landings, + warning: + o.error?.message || + ge.error?.message || + autoSynced?.warning || + live.liveWarning || + null, }); } diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 115a406..9ddb6aa 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -328,13 +328,18 @@ export default function AdTrackerClient({ ]); useEffect(() => { + if (tab === 'resumen') { + setCreatives([]); + setCreativesWarning(null); + setCreativesLoading(false); + return; + } let cancelled = false; async function load() { setCreativesLoading(true); setCreativesWarning(null); try { - const platform = - tab === 'meta' ? 'meta' : tab === 'google' ? 'google' : 'all'; + const platform = tab === 'meta' ? 'meta' : 'google'; const qs = new URLSearchParams({ since: range.since, until: range.until, @@ -532,21 +537,6 @@ export default function AdTrackerClient({ - - -
diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index 4191bde..301f5f7 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -256,34 +256,34 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
- +
{warning && ( diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index e0cee42..859595d 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -64,13 +64,6 @@ const NAV_ITEMS: NavItem[] = [ iconClass: 'text-emerald-500', moduleId: 'estrategia', }, - { - href: '/dashboard/calendario', - label: 'Calendario Editorial', - icon: CalendarDays, - iconClass: 'text-pink-500', - moduleId: 'calendario', - }, { href: '/dashboard/ad-tracker', label: 'Ads', @@ -174,6 +167,7 @@ export default function DashboardShell({ const [open, setOpen] = useState(false); const close = () => setOpen(false); const dateRange = useDateRange(); + const pathname = usePathname(); const visibleNav = NAV_ITEMS.filter((item) => { if (isAgency) return true; @@ -230,9 +224,32 @@ export default function DashboardShell({