feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes
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>
This commit is contained in:
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user