diff --git a/.env.example b/.env.example
index a61afbf..d686a27 100644
--- a/.env.example
+++ b/.env.example
@@ -8,3 +8,10 @@ ANTHROPIC_API_KEY=replace-with-anthropic-key
OPS_ALERT_EMAIL=
CRON_SECRET=
N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es
+
+# Google Drive (agency marketing@) — OAuth Web client
+NEXT_PUBLIC_APP_URL=https://app.4ventures.es
+GOOGLE_OAUTH_CLIENT_ID=
+GOOGLE_OAUTH_CLIENT_SECRET=
+# Optional override; default: {APP_URL}/api/admin/drive/callback
+# GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback
diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md
index c97eece..6c908d8 100644
--- a/docs/OPERATOR.md
+++ b/docs/OPERATOR.md
@@ -8,6 +8,7 @@
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente)
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
+- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → auto-match carpetas por nombre de tenant. Env Dokploy: `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `NEXT_PUBLIC_APP_URL`. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Drive.
## Login
diff --git a/scripts/dokploy_apply_google_drive.py b/scripts/dokploy_apply_google_drive.py
new file mode 100644
index 0000000..796ad56
--- /dev/null
+++ b/scripts/dokploy_apply_google_drive.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Apply sql/google_drive.sql via Dokploy supabase-db terminal."""
+from __future__ import annotations
+
+import base64
+import os
+import re
+import sys
+from pathlib import Path
+
+from playwright.sync_api import sync_playwright
+
+ROOT = Path("/Users/4ventures/4ventures-client-portal")
+BPS = Path("/Users/4ventures/Projects/bps-facturacion")
+SQL = ROOT / "sql" / "google_drive.sql"
+BASE = "https://dokploy.4ventures.es"
+
+
+def env(key: str) -> str:
+ v = os.environ.get(key, "").strip()
+ if v:
+ return v
+ p = BPS / ".env"
+ if not p.is_file():
+ return ""
+ for line in p.read_text().splitlines():
+ if line.startswith(f"{key}="):
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
+ return ""
+
+
+def main() -> int:
+ email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
+ password = env("DOKPLOY_PASSWORD")
+ b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
+ chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ page = browser.new_page(viewport={"width": 1400, "height": 1000})
+ page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
+ page.wait_for_timeout(800)
+ if "/dashboard" not in page.url:
+ page.locator('input[type="email"], input[name="email"]').first.fill(email)
+ page.locator('input[type="password"]').first.fill(password)
+ page.locator('button[type="submit"]').first.click()
+ page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
+ print("login ok", flush=True)
+ page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
+ page.wait_for_timeout(800)
+ page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
+ "supabase-db"
+ )
+ page.wait_for_timeout(1000)
+ page.keyboard.press("Escape")
+ page.wait_for_timeout(200)
+ page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
+ page.wait_for_timeout(400)
+ page.get_by_text("Terminal", exact=True).click()
+ page.wait_for_timeout(1000)
+ if page.locator("button", has_text="Bash").count():
+ page.locator("button", has_text="Bash").first.click()
+ page.wait_for_timeout(2800)
+ if page.locator("textarea.xterm-helper-textarea").count():
+ page.locator("textarea.xterm-helper-textarea").first.click()
+
+ def run(cmd: str, wait: int = 800) -> None:
+ page.keyboard.type(cmd, delay=1)
+ page.keyboard.press("Enter")
+ page.wait_for_timeout(wait)
+
+ run("rm -f /tmp/gd.b64 /tmp/gd.sql; echo READY", 1200)
+ for i, ch in enumerate(chunks):
+ op = ">" if i == 0 else ">>"
+ run(f"printf '%s' '{ch}' {op} /tmp/gd.b64", 400)
+ run(
+ "base64 -d /tmp/gd.b64 > /tmp/gd.sql && "
+ "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/gd.sql; echo EXIT:$?",
+ 12000,
+ )
+ text = page.evaluate(
+ "() => (document.querySelector('.xterm-rows')||{}).innerText||''"
+ )
+ browser.close()
+ ok = "EXIT:0" in text
+ print("SQL_OK", ok, flush=True)
+ print(text[-600:], flush=True)
+ return 0 if ok else 6
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/sql/google_drive.sql b/sql/google_drive.sql
new file mode 100644
index 0000000..9e5c718
--- /dev/null
+++ b/sql/google_drive.sql
@@ -0,0 +1,42 @@
+-- Google Drive agency OAuth + per-tenant folder links
+-- Idempotent.
+
+-- Agency-wide OAuth tokens (service role only; no client policies)
+create table if not exists public.agency_oauth (
+ provider text primary key,
+ account_email text,
+ access_token text not null,
+ refresh_token text,
+ expires_at timestamptz,
+ scopes text,
+ meta jsonb not null default '{}'::jsonb,
+ updated_at timestamptz not null default now()
+);
+
+alter table public.agency_oauth enable row level security;
+
+-- Widen integrations.provider check to include google_drive
+alter table public.integrations drop constraint if exists integrations_provider_check;
+alter table public.integrations
+ add constraint integrations_provider_check
+ check (provider in (
+ 'meta_ads',
+ 'google_ads',
+ 'ga4',
+ 'search_console',
+ 'tag_manager',
+ 'clientify',
+ 'klaviyo',
+ 'mailchimp',
+ 'google_drive'
+ ));
+
+-- Seed google_drive slots for every tenant
+insert into public.integrations (tenant_id, provider, status, label)
+select t.id, 'google_drive', 'disconnected', 'Google Drive'
+from public.tenants t
+on conflict (tenant_id, provider) do nothing;
+
+-- Optional Drive file id on portal assets (mirror)
+alter table public.asset_files
+ add column if not exists drive_file_id text;
diff --git a/src/app/api/admin/create-client/route.ts b/src/app/api/admin/create-client/route.ts
index 23bc8e1..7bbe59c 100644
--- a/src/app/api/admin/create-client/route.ts
+++ b/src/app/api/admin/create-client/route.ts
@@ -54,6 +54,7 @@ export async function POST(req: NextRequest) {
['clientify', 'Clientify'],
['klaviyo', 'Klaviyo'],
['mailchimp', 'Mailchimp'],
+ ['google_drive', 'Google Drive'],
];
await fetch(`${url}/rest/v1/integrations`, {
method: 'POST',
diff --git a/src/app/api/admin/drive/callback/route.ts b/src/app/api/admin/drive/callback/route.ts
new file mode 100644
index 0000000..d2b76b3
--- /dev/null
+++ b/src/app/api/admin/drive/callback/route.ts
@@ -0,0 +1,93 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
+import {
+ applyMatches,
+ exchangeCode,
+ fetchGoogleEmail,
+ listDriveFolders,
+ matchFoldersToTenants,
+ saveAgencyOAuth,
+} from '@/lib/google-drive';
+
+export const dynamic = 'force-dynamic';
+
+function appUrl(): string {
+ return (
+ process.env.NEXT_PUBLIC_APP_URL?.trim() ||
+ 'https://app.4ventures.es'
+ );
+}
+
+/** GET — OAuth callback: save tokens + auto-match folders → tenants. */
+export async function GET(req: NextRequest) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user || !isAgencyRole(userRole(user))) {
+ return NextResponse.redirect(`${appUrl()}/login`);
+ }
+
+ const code = req.nextUrl.searchParams.get('code');
+ const state = req.nextUrl.searchParams.get('state');
+ const err = req.nextUrl.searchParams.get('error');
+ const cookieState = req.cookies.get('4v_drive_oauth_state')?.value;
+
+ if (err) {
+ return NextResponse.redirect(
+ `${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(err)}`
+ );
+ }
+ if (!code || !state || !cookieState || state !== cookieState) {
+ return NextResponse.redirect(
+ `${appUrl()}/dashboard/agencia?drive=error&msg=state`
+ );
+ }
+
+ try {
+ const tokens = await exchangeCode(code);
+ const email = await fetchGoogleEmail(tokens.access_token);
+ await saveAgencyOAuth({
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token || null,
+ expiresIn: tokens.expires_in,
+ scopes: tokens.scope,
+ email,
+ });
+
+ // Auto-match
+ const headers = serviceHeaders();
+ let linked = 0;
+ let ambiguous = 0;
+ if (headers) {
+ const tRes = await fetch(
+ `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
+ { headers, cache: 'no-store' }
+ );
+ if (tRes.ok) {
+ const tenants = (await tRes.json()) as {
+ id: string;
+ name: string;
+ slug: string;
+ }[];
+ const folders = await listDriveFolders();
+ const matches = await matchFoldersToTenants(tenants, folders);
+ const applied = await applyMatches(matches);
+ linked = applied.linked;
+ ambiguous = matches.filter((m) => m.status === 'ambiguous').length;
+ }
+ }
+
+ const res = NextResponse.redirect(
+ `${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}`
+ );
+ res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
+ return res;
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'oauth_failed';
+ return NextResponse.redirect(
+ `${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(msg.slice(0, 120))}`
+ );
+ }
+}
diff --git a/src/app/api/admin/drive/connect/route.ts b/src/app/api/admin/drive/connect/route.ts
new file mode 100644
index 0000000..d675d2f
--- /dev/null
+++ b/src/app/api/admin/drive/connect/route.ts
@@ -0,0 +1,42 @@
+import { NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+import { isAgencyRole, userRole } from '@/lib/agency';
+import {
+ driveAuthUrl,
+ driveConfigured,
+} from '@/lib/google-drive';
+import { randomBytes } from 'crypto';
+
+export const dynamic = 'force-dynamic';
+
+/** GET — start Google OAuth for agency Drive (marketing@). */
+export async function GET() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user || !isAgencyRole(userRole(user))) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ if (!driveConfigured()) {
+ return NextResponse.json(
+ {
+ error:
+ 'Faltan GOOGLE_OAUTH_CLIENT_ID y GOOGLE_OAUTH_CLIENT_SECRET en el entorno.',
+ },
+ { status: 503 }
+ );
+ }
+
+ const state = randomBytes(24).toString('hex');
+ const res = NextResponse.redirect(driveAuthUrl(state));
+ res.cookies.set('4v_drive_oauth_state', state, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 600,
+ });
+ return res;
+}
diff --git a/src/app/api/admin/drive/match/route.ts b/src/app/api/admin/drive/match/route.ts
new file mode 100644
index 0000000..1f57cad
--- /dev/null
+++ b/src/app/api/admin/drive/match/route.ts
@@ -0,0 +1,109 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+import {
+ isAgencyRole,
+ serviceHeaders,
+ supabaseUrl,
+ userRole,
+} from '@/lib/agency';
+import {
+ DRIVE_PROVIDER,
+ applyMatches,
+ listDriveFolders,
+ matchFoldersToTenants,
+} from '@/lib/google-drive';
+
+export const dynamic = 'force-dynamic';
+
+async function requireAgency() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user || !isAgencyRole(userRole(user))) {
+ return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
+ }
+ const headers = serviceHeaders();
+ if (!headers) {
+ return {
+ error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
+ };
+ }
+ return { headers };
+}
+
+/** POST — re-run auto-match (or body.assign for manual pick). */
+export async function POST(req: NextRequest) {
+ const gate = await requireAgency();
+ if ('error' in gate && gate.error) return gate.error;
+ const { headers } = gate as {
+ headers: { apikey: string; Authorization: string };
+ };
+
+ const body = await req.json().catch(() => ({}));
+
+ // Manual assign: { tenantId, folderId, folderName }
+ if (body.tenantId && body.folderId) {
+ const patch = {
+ status: 'connected',
+ external_account_id: String(body.folderId),
+ label: String(body.folderName || 'Google Drive'),
+ last_sync_at: new Date().toISOString(),
+ error_message: null,
+ meta: {
+ folder_name: body.folderName || null,
+ match_score: 100,
+ matched_at: new Date().toISOString(),
+ manual: true,
+ },
+ updated_at: new Date().toISOString(),
+ };
+ const url = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(body.tenantId)}&provider=eq.${DRIVE_PROVIDER}`;
+ const r = await fetch(url, {
+ method: 'PATCH',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=representation',
+ },
+ body: JSON.stringify(patch),
+ });
+ if (!r.ok) {
+ return NextResponse.json({ error: await r.text() }, { status: 500 });
+ }
+ const rows = await r.json();
+ return NextResponse.json({ integration: rows[0] || rows });
+ }
+
+ try {
+ const tRes = await fetch(
+ `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
+ { headers, cache: 'no-store' }
+ );
+ if (!tRes.ok) {
+ return NextResponse.json({ error: await tRes.text() }, { status: 500 });
+ }
+ const tenants = (await tRes.json()) as {
+ id: string;
+ name: string;
+ slug: string;
+ }[];
+ const folders = await listDriveFolders();
+ const matches = await matchFoldersToTenants(tenants, folders);
+ const apply = body.apply !== false;
+ let linked = 0;
+ if (apply) {
+ linked = (await applyMatches(matches)).linked;
+ }
+ return NextResponse.json({
+ linked,
+ folders: folders.length,
+ matches,
+ });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : 'match failed' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/admin/drive/status/route.ts b/src/app/api/admin/drive/status/route.ts
new file mode 100644
index 0000000..e6d9d93
--- /dev/null
+++ b/src/app/api/admin/drive/status/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+import { isAgencyRole, userRole } from '@/lib/agency';
+import { driveConfigured, getAgencyOAuthStatus } from '@/lib/google-drive';
+
+export const dynamic = 'force-dynamic';
+
+/** GET — agency Drive connection status (no tokens). */
+export async function GET() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user || !isAgencyRole(userRole(user))) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const status = await getAgencyOAuthStatus();
+ return NextResponse.json({
+ ...status,
+ configured: driveConfigured(),
+ });
+ } catch (e) {
+ return NextResponse.json({
+ connected: false,
+ configured: driveConfigured(),
+ warning: e instanceof Error ? e.message : 'error',
+ });
+ }
+}
diff --git a/src/app/api/assets/drive/route.ts b/src/app/api/assets/drive/route.ts
new file mode 100644
index 0000000..1d20886
--- /dev/null
+++ b/src/app/api/assets/drive/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+import { isAgencyRole, userRole } from '@/lib/agency';
+import { resolveActiveTenant } from '@/lib/tenant-context';
+import {
+ getDriveDownloadUrl,
+ getTenantDriveFolderId,
+ listFilesInFolder,
+} from '@/lib/google-drive';
+
+export const dynamic = 'force-dynamic';
+
+/** GET — list Drive files for active tenant folder (or ?download=fileId). */
+export async function GET(req: NextRequest) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const ctx = await resolveActiveTenant(user);
+ if (!ctx.tenantId || ctx.portfolioWide) {
+ return NextResponse.json(
+ { error: 'Selecciona un cliente en Contexto' },
+ { status: 400 }
+ );
+ }
+
+ // Clients only see own tenant; agency via view cookie already in ctx
+ const agency = isAgencyRole(userRole(user));
+ if (!agency) {
+ // resolveActiveTenant already scopes to home tenant
+ }
+
+ const downloadId = req.nextUrl.searchParams.get('download')?.trim();
+ if (downloadId) {
+ try {
+ const url = await getDriveDownloadUrl(downloadId);
+ return NextResponse.json({ url });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : 'download failed' },
+ { status: 500 }
+ );
+ }
+ }
+
+ const link = await getTenantDriveFolderId(ctx.tenantId);
+ if (!link) {
+ return NextResponse.json({
+ files: [],
+ linked: false,
+ warning:
+ 'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match.',
+ });
+ }
+
+ try {
+ const files = await listFilesInFolder(link.folderId);
+ return NextResponse.json({
+ linked: true,
+ folderId: link.folderId,
+ folderName: link.folderName,
+ files: files.map((f) => ({
+ id: f.id,
+ name: f.name,
+ mimeType: f.mimeType,
+ bytes: f.size ? Number(f.size) : null,
+ modifiedTime: f.modifiedTime,
+ webViewLink: f.webViewLink,
+ source: 'drive' as const,
+ })),
+ });
+ } catch (e) {
+ return NextResponse.json({
+ linked: true,
+ folderId: link.folderId,
+ folderName: link.folderName,
+ files: [],
+ warning: e instanceof Error ? e.message : 'Drive error',
+ });
+ }
+}
diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts
index 8016f8d..206fc0f 100644
--- a/src/app/api/assets/route.ts
+++ b/src/app/api/assets/route.ts
@@ -15,6 +15,11 @@ import {
sanitizeFolder,
serviceSupabase,
} from '@/lib/assets';
+import {
+ ensureChildFolder,
+ getTenantDriveFolderId,
+ uploadToDrive,
+} from '@/lib/google-drive';
import { createHash, randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
@@ -176,7 +181,47 @@ export async function POST(req: NextRequest) {
continue;
}
const created = await ins.json();
- uploaded.push(Array.isArray(created) ? created[0] : created);
+ const assetRow = Array.isArray(created) ? created[0] : created;
+
+ // Best-effort mirror to linked Google Drive folder
+ try {
+ const link = await getTenantDriveFolderId(ctx.tenantId!);
+ if (link) {
+ let parentId = link.folderId;
+ if (folder) {
+ const parts = folder.split('/').filter(Boolean);
+ for (const part of parts) {
+ parentId = await ensureChildFolder(parentId, part);
+ }
+ }
+ const driveFile = await uploadToDrive({
+ parentFolderId: parentId,
+ fileName: safeName,
+ mimeType: file.type || 'application/octet-stream',
+ buffer: buf,
+ });
+ if (driveFile.id && assetRow?.id) {
+ await fetch(
+ `${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetRow.id)}`,
+ {
+ method: 'PATCH',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ drive_file_id: driveFile.id }),
+ }
+ );
+ assetRow.drive_file_id = driveFile.id;
+ }
+ }
+ } catch (e) {
+ errors.push(
+ `${file.name}: portal ok, Drive: ${e instanceof Error ? e.message : 'error'}`
+ );
+ }
+
+ uploaded.push(assetRow);
}
return NextResponse.json(
diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx
index 3bdffb9..6936471 100644
--- a/src/components/AgencyPortfolioBoard.tsx
+++ b/src/components/AgencyPortfolioBoard.tsx
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
+import DriveConnectPanel from '@/components/DriveConnectPanel';
import {
AlertTriangle,
Bell,
@@ -106,6 +107,8 @@ export default function AgencyPortfolioBoard() {
return (
+
+
{warning && (
{warning}
@@ -166,7 +169,7 @@ export default function AgencyPortfolioBoard() {
Portafolio · estado de conexiones
- Meta · Google Ads · GA4 · GSC · GTM · CRM · Email
+ Meta · Google Ads · GA4 · GSC · GTM · CRM · Email · Drive
diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx
index 2b0b4fc..60b7837 100644
--- a/src/components/AssetsLibrary.tsx
+++ b/src/components/AssetsLibrary.tsx
@@ -4,8 +4,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CheckCircle2,
Download,
+ ExternalLink,
FileText,
FolderOpen,
+ HardDrive,
Loader2,
Trash2,
Upload,
@@ -21,6 +23,15 @@ type Asset = {
created_at?: string;
};
+type DriveFile = {
+ id: string;
+ name: string;
+ mimeType?: string;
+ bytes?: number | null;
+ modifiedTime?: string;
+ webViewLink?: string;
+};
+
function formatBytes(n?: number | null) {
if (!n) return '—';
if (n < 1024) return `${n} B`;
@@ -32,6 +43,7 @@ function iconHint(mime?: string | null) {
if (!mime) return 'FILE';
if (mime.startsWith('image/')) return 'IMG';
if (mime.includes('pdf')) return 'PDF';
+ if (mime.includes('folder')) return 'DIR';
if (mime.includes('sheet') || mime.includes('csv') || mime.includes('excel'))
return 'XLS';
if (mime.includes('zip') || mime.includes('rar')) return 'ZIP';
@@ -39,7 +51,10 @@ function iconHint(mime?: string | null) {
}
export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) {
+ const [tab, setTab] = useState<'portal' | 'drive'>('portal');
const [assets, setAssets] = useState([]);
+ const [driveFiles, setDriveFiles] = useState([]);
+ const [driveFolder, setDriveFolder] = useState(null);
const [warning, setWarning] = useState(null);
const [folder, setFolder] = useState('');
const [loading, setLoading] = useState(true);
@@ -47,15 +62,30 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
const [err, setErr] = useState(null);
const [drag, setDrag] = useState(false);
- const load = useCallback(async () => {
- setLoading(true);
+ const loadPortal = useCallback(async () => {
const res = await fetch('/api/assets');
const data = await res.json();
setAssets(data.assets || []);
- setWarning(data.warning || null);
- setLoading(false);
+ if (data.warning) setWarning(data.warning);
}, []);
+ const loadDrive = useCallback(async () => {
+ const res = await fetch('/api/assets/drive');
+ const data = await res.json();
+ setDriveFiles(data.files || []);
+ setDriveFolder(data.folderName || null);
+ if (data.warning) setWarning(data.warning);
+ else if (data.linked) setWarning(null);
+ }, []);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setErr(null);
+ if (tab === 'portal') await loadPortal();
+ else await loadDrive();
+ setLoading(false);
+ }, [tab, loadPortal, loadDrive]);
+
useEffect(() => {
void load();
}, [load]);
@@ -92,7 +122,8 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
} else if (data.errors?.length) {
setErr(data.errors.join(' · '));
}
- await load();
+ setTab('portal');
+ await loadPortal();
setUploading(false);
}
@@ -106,6 +137,22 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
window.open(data.url, '_blank', 'noopener,noreferrer');
}
+ async function openDrive(file: DriveFile) {
+ if (file.webViewLink) {
+ window.open(file.webViewLink, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ const res = await fetch(
+ `/api/assets/drive?download=${encodeURIComponent(file.id)}`
+ );
+ const data = await res.json();
+ if (!res.ok || !data.url) {
+ setErr(data.error || 'No se pudo abrir en Drive');
+ return;
+ }
+ window.open(data.url, '_blank', 'noopener,noreferrer');
+ }
+
async function remove(id: string) {
if (!confirm('¿Eliminar este archivo de la biblioteca?')) return;
const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, {
@@ -115,7 +162,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
const data = await res.json().catch(() => ({}));
setErr(data.error || 'No se pudo borrar');
}
- await load();
+ await loadPortal();
}
return (
@@ -125,12 +172,39 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
Biblioteca de activos
- Archivo oficial de {tenantLabel} :
- sube PDF, Excel, CSV, imágenes, ZIP u otros archivos ya validados.
- Opcional: carpeta lógica (p. ej. briefs/2026).
+ Archivo oficial de {tenantLabel} .
+ Subidas al portal se copian a la carpeta Drive del cliente si está
+ enlazada.
+
+ setTab('portal')}
+ className={`text-xs px-3 py-1.5 rounded-lg border ${
+ tab === 'portal'
+ ? 'border-brand-500/40 text-brand-400'
+ : 'border-gray-700 text-gray-400'
+ }`}
+ >
+ Portal
+
+ setTab('drive')}
+ className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
+ tab === 'drive'
+ ? 'border-brand-500/40 text-brand-400'
+ : 'border-gray-700 text-gray-400'
+ }`}
+ >
+
+ Google Drive
+ {driveFolder ? ` · ${driveFolder}` : ''}
+
+
+
{warning && (
{warning}
@@ -138,175 +212,230 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
)}
{err &&
{err}
}
-
-
- Carpeta
- setFolder(e.target.value)}
- list="asset-folders"
- />
-
- {folders.map((f) => (
-
- ))}
-
-
-
- {uploading ? (
-
- ) : (
-
- )}
- Subir archivos
- {
- if (e.target.files) void uploadFiles(e.target.files);
- e.target.value = '';
+ {tab === 'portal' && (
+ <>
+
+
+ {
+ e.preventDefault();
+ setDrag(true);
}}
- />
-
-
- Subir carpeta
- {
- if (e.target.files?.length) {
- // Use first file relative path as folder hint if empty
- const rel =
- (e.target.files[0] as File & { webkitRelativePath?: string })
- .webkitRelativePath || '';
- const top = rel.split('/')[0];
- if (top && !folder) setFolder(top);
- void uploadFiles(e.target.files);
+ onDragOver={(e) => e.preventDefault()}
+ onDragLeave={() => setDrag(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDrag(false);
+ if (e.dataTransfer.files?.length) {
+ void uploadFiles(e.dataTransfer.files);
}
- e.target.value = '';
}}
- />
-
-
-
- {
- e.preventDefault();
- setDrag(true);
- }}
- onDragOver={(e) => e.preventDefault()}
- onDragLeave={() => setDrag(false)}
- onDrop={(e) => {
- e.preventDefault();
- setDrag(false);
- if (e.dataTransfer.files?.length) {
- void uploadFiles(e.dataTransfer.files);
- }
- }}
- className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
- drag
- ? 'border-brand-500 bg-brand-500/10'
- : 'border-gray-700 bg-[#111827]'
- }`}
- >
-
-
- Arrastra aquí cualquier archivo (máx. 80 MB c/u)
-
-
- PDF · Excel · CSV · imágenes · ZIP · vídeo · etc.
-
-
-
- {folders.length > 0 && (
-
- setFolder('')}
- className={`text-[11px] px-2.5 py-1 rounded-md border ${
- !folder
- ? 'border-brand-500/40 text-brand-400'
- : 'border-gray-700 text-gray-400'
+ className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
+ drag
+ ? 'border-brand-500 bg-brand-500/10'
+ : 'border-gray-700 bg-[#111827]'
}`}
>
- Todas
-
- {folders.map((f) => (
- setFolder(f)}
- className={`text-[11px] px-2.5 py-1 rounded-md border ${
- folder === f
- ? 'border-brand-500/40 text-brand-400'
- : 'border-gray-700 text-gray-400'
- }`}
- >
- {f}
-
- ))}
-
+
+
+ Arrastra aquí cualquier archivo (máx. 80 MB c/u)
+
+
+ Se guarda en portal y se refleja en Drive si el cliente está
+ enlazado
+
+
+
+ {folders.length > 0 && (
+
+ setFolder('')}
+ className={`text-[11px] px-2.5 py-1 rounded-md border ${
+ !folder
+ ? 'border-brand-500/40 text-brand-400'
+ : 'border-gray-700 text-gray-400'
+ }`}
+ >
+ Todas
+
+ {folders.map((f) => (
+ setFolder(f)}
+ className={`text-[11px] px-2.5 py-1 rounded-md border ${
+ folder === f
+ ? 'border-brand-500/40 text-brand-400'
+ : 'border-gray-700 text-gray-400'
+ }`}
+ >
+ {f}
+
+ ))}
+
+ )}
+
+ {loading ? (
+ Cargando…
+ ) : visible.length === 0 ? (
+
+
+
+ Sin archivos en esta vista
+
+
+ ) : (
+
+ {visible.map((a) => (
+
+
+ {iconHint(a.mime_type)}
+
+
+
+ {a.file_name}
+
+
+ {a.folder ? `${a.folder}/` : ''}
+ {formatBytes(a.bytes)}
+ {a.created_at
+ ? ` · ${new Date(a.created_at).toLocaleString('es-ES')}`
+ : ''}
+
+
+
+ Validado
+
+ download(a.id)}
+ className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
+ title="Descargar"
+ >
+
+
+ remove(a.id)}
+ className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
+ title="Eliminar"
+ >
+
+
+
+ ))}
+
+ )}
+ >
)}
- {loading ? (
- Cargando…
- ) : visible.length === 0 ? (
-
-
-
- Sin archivos en esta vista
-
-
- ) : (
-
- {visible.map((a) => (
-
-
- {iconHint(a.mime_type)}
-
-
-
- {a.file_name}
-
-
- {a.folder ? `${a.folder}/` : ''}
- {formatBytes(a.bytes)}
- {a.created_at
- ? ` · ${new Date(a.created_at).toLocaleString('es-ES')}`
- : ''}
-
-
-
- Validado
-
- download(a.id)}
- className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
- title="Descargar"
- >
-
-
- remove(a.id)}
- className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
- title="Eliminar"
- >
-
-
-
- ))}
-
+ {tab === 'drive' && (
+ <>
+ {loading ? (
+ Cargando Drive…
+ ) : driveFiles.length === 0 ? (
+
+
+
+ Sin archivos en Drive
+
+
+ Conecta marketing@ en Agencia y ejecuta auto-match por nombre.
+
+
+ ) : (
+
+ {driveFiles.map((f) => (
+
+
+ {iconHint(f.mimeType)}
+
+
+
+ {f.name}
+
+
+ {formatBytes(f.bytes)}
+ {f.modifiedTime
+ ? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
+ : ''}
+
+
+ void openDrive(f)}
+ className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
+ title="Abrir en Drive"
+ >
+
+
+
+ ))}
+
+ )}
+ >
)}
);
diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx
index 5b4d549..d9a4e1c 100644
--- a/src/components/ClientIntegrationsPanel.tsx
+++ b/src/components/ClientIntegrationsPanel.tsx
@@ -78,6 +78,7 @@ export default function ClientIntegrationsPanel({
{ id: 'analytics', title: 'Analítica y medición' },
{ id: 'crm', title: 'CRM' },
{ id: 'email', title: 'Email / lifecycle' },
+ { id: 'files', title: 'Archivos' },
];
return (
diff --git a/src/components/DriveConnectPanel.tsx b/src/components/DriveConnectPanel.tsx
new file mode 100644
index 0000000..6e54f7a
--- /dev/null
+++ b/src/components/DriveConnectPanel.tsx
@@ -0,0 +1,180 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { HardDrive, Loader2, RefreshCw } from 'lucide-react';
+
+type MatchRow = {
+ tenantId: string;
+ tenantName: string;
+ tenantSlug: string;
+ folderId: string | null;
+ folderName: string | null;
+ score: number;
+ status: string;
+ candidates?: { id: string; name: string; score: number }[];
+};
+
+export default function DriveConnectPanel() {
+ const [configured, setConfigured] = useState(false);
+ const [connected, setConnected] = useState(false);
+ const [email, setEmail] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [matching, setMatching] = useState(false);
+ const [matches, setMatches] = useState(null);
+ const [msg, setMsg] = useState(null);
+ const [err, setErr] = useState(null);
+
+ async function loadStatus() {
+ setLoading(true);
+ const res = await fetch('/api/admin/drive/status');
+ const data = await res.json();
+ setConfigured(Boolean(data.configured));
+ setConnected(Boolean(data.connected));
+ setEmail(data.email || null);
+ setLoading(false);
+ }
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ if (params.get('drive') === 'ok') {
+ setMsg(
+ `Drive conectado. Enlazadas ${params.get('linked') || 0} carpetas` +
+ (params.get('ambiguous') && params.get('ambiguous') !== '0'
+ ? ` · ${params.get('ambiguous')} ambiguas (revisa abajo)`
+ : '')
+ );
+ }
+ if (params.get('drive') === 'error') {
+ setErr(params.get('msg') || 'Error OAuth Drive');
+ }
+ void loadStatus();
+ }, []);
+
+ async function rematch() {
+ setMatching(true);
+ setErr(null);
+ const res = await fetch('/api/admin/drive/match', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ apply: true }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setErr(data.error || 'Match falló');
+ } else {
+ setMatches(data.matches || []);
+ setMsg(`Auto-match: ${data.linked} enlazadas · ${data.folders} carpetas vistas`);
+ }
+ setMatching(false);
+ }
+
+ async function assign(tenantId: string, folderId: string, folderName: string) {
+ await fetch('/api/admin/drive/match', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tenantId, folderId, folderName }),
+ });
+ await rematch();
+ }
+
+ if (loading) {
+ return (
+
+ Drive…
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Google Drive (agencia)
+
+
+ Conecta marketing@4ventures.es .
+ Emparejamos carpetas con clientes por nombre automáticamente.
+
+
+
+
+ {connected ? (
+
void rematch()}
+ disabled={matching}
+ className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-700 text-gray-200 hover:border-gray-500"
+ >
+ {matching ? (
+
+ ) : (
+
+ )}
+ Re-emparejar
+
+ ) : null}
+ {configured ? (
+
+ {connected ? 'Reconectar' : 'Conectar Drive'}
+
+ ) : (
+
+ Falta OAuth en Dokploy (GOOGLE_OAUTH_*)
+
+ )}
+
+
+
+ {connected && (
+
+ Conectado{email ? ` · ${email}` : ''}
+
+ )}
+ {msg && {msg}
}
+ {err && {err}
}
+
+ {matches && matches.length > 0 && (
+
+ {matches.map((m) => (
+
+
+
{m.tenantName}
+
+ {m.status === 'matched'
+ ? `→ ${m.folderName} (${m.score})`
+ : m.status === 'ambiguous'
+ ? 'Ambiguo — elige carpeta'
+ : 'Sin carpeta clara'}
+
+
+ {m.status === 'ambiguous' && m.candidates && (
+
+ {m.candidates.map((c) => (
+ void assign(m.tenantId, c.id, c.name)}
+ className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:text-white hover:border-gray-500"
+ title={`score ${c.score}`}
+ >
+ {c.name}
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/lib/agency.ts b/src/lib/agency.ts
index 3643e5d..5f22511 100644
--- a/src/lib/agency.ts
+++ b/src/lib/agency.ts
@@ -9,6 +9,7 @@ export const INTEGRATION_PROVIDERS = [
{ id: 'clientify', label: 'Clientify', group: 'crm' },
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' },
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' },
+ { id: 'google_drive', label: 'Google Drive', group: 'files' },
] as const;
export type IntegrationProviderId =
diff --git a/src/lib/google-drive.ts b/src/lib/google-drive.ts
new file mode 100644
index 0000000..6c81b8a
--- /dev/null
+++ b/src/lib/google-drive.ts
@@ -0,0 +1,594 @@
+/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */
+
+import { serviceHeaders, supabaseUrl } from '@/lib/agency';
+
+export const DRIVE_PROVIDER = 'google_drive';
+export const DRIVE_SCOPES = [
+ 'https://www.googleapis.com/auth/drive',
+ 'openid',
+ 'email',
+ 'profile',
+].join(' ');
+
+export type DriveFolder = { id: string; name: string };
+
+export type MatchResult = {
+ tenantId: string;
+ tenantName: string;
+ tenantSlug: string;
+ folderId: string | null;
+ folderName: string | null;
+ score: number;
+ status: 'matched' | 'ambiguous' | 'unmatched';
+ candidates?: { id: string; name: string; score: number }[];
+};
+
+function appUrl(): string {
+ return (
+ process.env.NEXT_PUBLIC_APP_URL?.trim() ||
+ process.env.APP_URL?.trim() ||
+ 'https://app.4ventures.es'
+ );
+}
+
+export function driveConfigured(): boolean {
+ return Boolean(
+ process.env.GOOGLE_OAUTH_CLIENT_ID?.trim() &&
+ process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim()
+ );
+}
+
+export function driveRedirectUri(): string {
+ return (
+ process.env.GOOGLE_OAUTH_REDIRECT_URI?.trim() ||
+ `${appUrl()}/api/admin/drive/callback`
+ );
+}
+
+export function driveAuthUrl(state: string): string {
+ const params = new URLSearchParams({
+ client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
+ redirect_uri: driveRedirectUri(),
+ response_type: 'code',
+ scope: DRIVE_SCOPES,
+ access_type: 'offline',
+ prompt: 'consent',
+ state,
+ });
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
+}
+
+export async function exchangeCode(code: string): Promise<{
+ access_token: string;
+ refresh_token?: string;
+ expires_in: number;
+ scope?: string;
+}> {
+ const body = new URLSearchParams({
+ code,
+ client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
+ client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!.trim(),
+ redirect_uri: driveRedirectUri(),
+ grant_type: 'authorization_code',
+ });
+ const r = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ if (!r.ok) throw new Error(`token exchange: ${(await r.text()).slice(0, 300)}`);
+ return r.json();
+}
+
+async function refreshAccessToken(refreshToken: string): Promise<{
+ access_token: string;
+ expires_in: number;
+}> {
+ const body = new URLSearchParams({
+ refresh_token: refreshToken,
+ client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
+ client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!.trim(),
+ grant_type: 'refresh_token',
+ });
+ const r = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ if (!r.ok) throw new Error(`token refresh: ${(await r.text()).slice(0, 300)}`);
+ return r.json();
+}
+
+export async function fetchGoogleEmail(accessToken: string): Promise {
+ const r = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ cache: 'no-store',
+ });
+ if (!r.ok) return null;
+ const j = (await r.json()) as { email?: string };
+ return j.email || null;
+}
+
+export async function saveAgencyOAuth(opts: {
+ accessToken: string;
+ refreshToken?: string | null;
+ expiresIn: number;
+ scopes?: string;
+ email?: string | null;
+}): Promise {
+ const headers = serviceHeaders();
+ if (!headers) throw new Error('no service role');
+ const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
+ const row: Record = {
+ provider: DRIVE_PROVIDER,
+ access_token: opts.accessToken,
+ expires_at: expiresAt,
+ scopes: opts.scopes || DRIVE_SCOPES,
+ account_email: opts.email || null,
+ updated_at: new Date().toISOString(),
+ };
+ if (opts.refreshToken) row.refresh_token = opts.refreshToken;
+
+ // Upsert: try PATCH then POST
+ const base = `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}`;
+ const patch = await fetch(base, {
+ method: 'PATCH',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=minimal',
+ },
+ body: JSON.stringify(row),
+ });
+ if (patch.ok) return;
+
+ const ins = await fetch(`${supabaseUrl()}/rest/v1/agency_oauth`, {
+ method: 'POST',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'resolution=merge-duplicates',
+ },
+ body: JSON.stringify(row),
+ });
+ if (!ins.ok) throw new Error(`save oauth: ${(await ins.text()).slice(0, 300)}`);
+}
+
+type OAuthRow = {
+ access_token: string;
+ refresh_token?: string | null;
+ expires_at?: string | null;
+ account_email?: string | null;
+};
+
+export async function getAgencyOAuthStatus(): Promise<{
+ connected: boolean;
+ email?: string | null;
+ expiresAt?: string | null;
+ configured: boolean;
+}> {
+ const configured = driveConfigured();
+ const headers = serviceHeaders();
+ if (!headers) return { connected: false, configured };
+ const r = await fetch(
+ `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token`,
+ { headers, cache: 'no-store' }
+ );
+ if (!r.ok) {
+ return {
+ connected: false,
+ configured,
+ };
+ }
+ const rows = (await r.json()) as OAuthRow[];
+ const row = rows[0];
+ if (!row?.access_token && !row?.refresh_token) {
+ return { connected: false, configured };
+ }
+ return {
+ connected: true,
+ email: row.account_email,
+ expiresAt: row.expires_at,
+ configured,
+ };
+}
+
+/** Valid access token (refresh if needed). */
+export async function getValidAccessToken(): Promise {
+ const headers = serviceHeaders();
+ if (!headers) throw new Error('no service role');
+ if (!driveConfigured()) throw new Error('GOOGLE_OAUTH_CLIENT_ID/SECRET no configurados');
+
+ const r = await fetch(
+ `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=*`,
+ { headers, cache: 'no-store' }
+ );
+ if (!r.ok) throw new Error(`agency_oauth: ${(await r.text()).slice(0, 200)}`);
+ const rows = (await r.json()) as OAuthRow[];
+ const row = rows[0];
+ if (!row) throw new Error('Drive no conectado. Conecta marketing@ en Agencia.');
+
+ const expires = row.expires_at ? new Date(row.expires_at).getTime() : 0;
+ if (row.access_token && expires > Date.now() + 60_000) {
+ return row.access_token;
+ }
+ if (!row.refresh_token) {
+ if (row.access_token) return row.access_token;
+ throw new Error('Sin refresh_token; vuelve a conectar Drive');
+ }
+ const refreshed = await refreshAccessToken(row.refresh_token);
+ await saveAgencyOAuth({
+ accessToken: refreshed.access_token,
+ refreshToken: row.refresh_token,
+ expiresIn: refreshed.expires_in,
+ email: row.account_email,
+ });
+ return refreshed.access_token;
+}
+
+export function normalizeName(raw: string): string {
+ return raw
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, ' ')
+ .trim()
+ .replace(/\s+/g, ' ');
+}
+
+export function scoreFolderToTenant(
+ folderName: string,
+ tenantName: string,
+ tenantSlug: string
+): number {
+ const f = normalizeName(folderName);
+ const n = normalizeName(tenantName);
+ const s = normalizeName(tenantSlug.replace(/-/g, ' '));
+ if (!f || (!n && !s)) return 0;
+ if (f === n || f === s) return 100;
+ if (n && (f.includes(n) || n.includes(f))) return 85;
+ if (s && (f.includes(s) || s.includes(f))) return 80;
+ // token overlap
+ const ft = new Set(f.split(' ').filter((t) => t.length > 2));
+ const nt = new Set(
+ [...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
+ );
+ if (!ft.size || !nt.size) return 0;
+ let hit = 0;
+ for (const t of nt) if (ft.has(t)) hit += 1;
+ const ratio = hit / nt.size;
+ if (ratio >= 0.8) return 70;
+ if (ratio >= 0.5) return 55;
+ return Math.round(ratio * 50);
+}
+
+export async function listDriveFolders(): Promise {
+ const token = await getValidAccessToken();
+ const out: DriveFolder[] = [];
+ let pageToken: string | undefined;
+ do {
+ const params = new URLSearchParams({
+ q: "mimeType='application/vnd.google-apps.folder' and trashed=false",
+ fields: 'nextPageToken,files(id,name)',
+ pageSize: '1000',
+ corpora: 'allDrives',
+ includeItemsFromAllDrives: 'true',
+ supportsAllDrives: 'true',
+ });
+ if (pageToken) params.set('pageToken', pageToken);
+ const r = await fetch(
+ `https://www.googleapis.com/drive/v3/files?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: 'no-store',
+ }
+ );
+ if (!r.ok) throw new Error(`Drive list: ${(await r.text()).slice(0, 300)}`);
+ const j = (await r.json()) as {
+ files?: DriveFolder[];
+ nextPageToken?: string;
+ };
+ out.push(...(j.files || []));
+ pageToken = j.nextPageToken;
+ } while (pageToken);
+ return out;
+}
+
+export async function matchFoldersToTenants(
+ tenants: { id: string; name: string; slug: string }[],
+ folders: DriveFolder[]
+): Promise {
+ const THRESHOLD = 55;
+ const AMBIGUOUS_GAP = 8;
+
+ type Scored = { folder: DriveFolder; tenantId: string; score: number };
+ const all: Scored[] = [];
+ for (const t of tenants) {
+ for (const f of folders) {
+ const score = scoreFolderToTenant(f.name, t.name, t.slug);
+ if (score >= THRESHOLD) {
+ all.push({ folder: f, tenantId: t.id, score });
+ }
+ }
+ }
+ all.sort((a, b) => b.score - a.score);
+
+ const usedFolders = new Set();
+ const usedTenants = new Set();
+ const assigned = new Map();
+
+ for (const row of all) {
+ if (usedFolders.has(row.folder.id) || usedTenants.has(row.tenantId)) continue;
+ // check ambiguity: another folder for same tenant within gap
+ const rivals = all.filter(
+ (x) =>
+ x.tenantId === row.tenantId &&
+ x.folder.id !== row.folder.id &&
+ !usedFolders.has(x.folder.id) &&
+ row.score - x.score < AMBIGUOUS_GAP &&
+ x.score >= THRESHOLD
+ );
+ if (rivals.length) continue; // leave for ambiguous review
+ assigned.set(row.tenantId, { folder: row.folder, score: row.score });
+ usedFolders.add(row.folder.id);
+ usedTenants.add(row.tenantId);
+ }
+
+ return tenants.map((t) => {
+ const hit = assigned.get(t.id);
+ if (hit) {
+ return {
+ tenantId: t.id,
+ tenantName: t.name,
+ tenantSlug: t.slug,
+ folderId: hit.folder.id,
+ folderName: hit.folder.name,
+ score: hit.score,
+ status: 'matched' as const,
+ };
+ }
+ const cands = all
+ .filter((x) => x.tenantId === t.id)
+ .slice(0, 5)
+ .map((x) => ({ id: x.folder.id, name: x.folder.name, score: x.score }));
+ if (cands.length) {
+ return {
+ tenantId: t.id,
+ tenantName: t.name,
+ tenantSlug: t.slug,
+ folderId: null,
+ folderName: null,
+ score: cands[0].score,
+ status: 'ambiguous' as const,
+ candidates: cands,
+ };
+ }
+ return {
+ tenantId: t.id,
+ tenantName: t.name,
+ tenantSlug: t.slug,
+ folderId: null,
+ folderName: null,
+ score: 0,
+ status: 'unmatched' as const,
+ };
+ });
+}
+
+export async function applyMatches(
+ matches: MatchResult[]
+): Promise<{ linked: number }> {
+ const headers = serviceHeaders();
+ if (!headers) throw new Error('no service role');
+ const base = supabaseUrl();
+ let linked = 0;
+ for (const m of matches) {
+ if (m.status !== 'matched' || !m.folderId) continue;
+ const patch = {
+ status: 'connected',
+ external_account_id: m.folderId,
+ label: m.folderName || 'Google Drive',
+ last_sync_at: new Date().toISOString(),
+ error_message: null,
+ meta: {
+ folder_name: m.folderName,
+ match_score: m.score,
+ matched_at: new Date().toISOString(),
+ },
+ updated_at: new Date().toISOString(),
+ };
+ const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(m.tenantId)}&provider=eq.${DRIVE_PROVIDER}`;
+ let r = await fetch(url, {
+ method: 'PATCH',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=minimal',
+ },
+ body: JSON.stringify(patch),
+ });
+ if (!r.ok) {
+ r = await fetch(`${base}/rest/v1/integrations`, {
+ method: 'POST',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'resolution=merge-duplicates',
+ },
+ body: JSON.stringify({
+ tenant_id: m.tenantId,
+ provider: DRIVE_PROVIDER,
+ ...patch,
+ }),
+ });
+ }
+ if (r.ok) linked += 1;
+ }
+ return { linked };
+}
+
+export async function getTenantDriveFolderId(
+ tenantId: string
+): Promise<{ folderId: string; folderName?: string } | null> {
+ const headers = serviceHeaders();
+ if (!headers) return null;
+ const r = await fetch(
+ `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${DRIVE_PROVIDER}&select=status,external_account_id,label,meta`,
+ { headers, cache: 'no-store' }
+ );
+ if (!r.ok) return null;
+ const rows = await r.json();
+ const row = rows[0];
+ if (!row || row.status !== 'connected' || !row.external_account_id) return null;
+ return {
+ folderId: row.external_account_id,
+ folderName: row.meta?.folder_name || row.label || undefined,
+ };
+}
+
+export async function listFilesInFolder(folderId: string): Promise<
+ {
+ id: string;
+ name: string;
+ mimeType?: string;
+ size?: string;
+ modifiedTime?: string;
+ webViewLink?: string;
+ }[]
+> {
+ const token = await getValidAccessToken();
+ const out: {
+ id: string;
+ name: string;
+ mimeType?: string;
+ size?: string;
+ modifiedTime?: string;
+ webViewLink?: string;
+ }[] = [];
+ let pageToken: string | undefined;
+ do {
+ const params = new URLSearchParams({
+ q: `'${folderId}' in parents and trashed=false`,
+ fields:
+ 'nextPageToken,files(id,name,mimeType,size,modifiedTime,webViewLink)',
+ pageSize: '200',
+ supportsAllDrives: 'true',
+ includeItemsFromAllDrives: 'true',
+ });
+ if (pageToken) params.set('pageToken', pageToken);
+ const r = await fetch(
+ `https://www.googleapis.com/drive/v3/files?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: 'no-store',
+ }
+ );
+ if (!r.ok) throw new Error(`Drive children: ${(await r.text()).slice(0, 300)}`);
+ const j = await r.json();
+ out.push(...(j.files || []));
+ pageToken = j.nextPageToken;
+ } while (pageToken);
+ return out;
+}
+
+/** Find or create a child folder by name under parent. */
+export async function ensureChildFolder(
+ parentId: string,
+ name: string
+): Promise {
+ const token = await getValidAccessToken();
+ const safe = name.replace(/'/g, "\\'");
+ const q = `name='${safe}' and mimeType='application/vnd.google-apps.folder' and '${parentId}' in parents and trashed=false`;
+ const params = new URLSearchParams({
+ q,
+ fields: 'files(id,name)',
+ pageSize: '1',
+ supportsAllDrives: 'true',
+ includeItemsFromAllDrives: 'true',
+ });
+ const find = await fetch(
+ `https://www.googleapis.com/drive/v3/files?${params}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: 'no-store',
+ }
+ );
+ if (find.ok) {
+ const j = await find.json();
+ if (j.files?.[0]?.id) return j.files[0].id;
+ }
+ const create = await fetch(
+ 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name,
+ mimeType: 'application/vnd.google-apps.folder',
+ parents: [parentId],
+ }),
+ }
+ );
+ if (!create.ok) {
+ throw new Error(`create folder: ${(await create.text()).slice(0, 300)}`);
+ }
+ const created = await create.json();
+ return created.id as string;
+}
+
+export async function uploadToDrive(opts: {
+ parentFolderId: string;
+ fileName: string;
+ mimeType: string;
+ buffer: Buffer;
+}): Promise<{ id: string; webViewLink?: string }> {
+ const token = await getValidAccessToken();
+ const metadata = {
+ name: opts.fileName,
+ parents: [opts.parentFolderId],
+ };
+ const boundary = `4v_${Date.now()}`;
+ const metaPart = JSON.stringify(metadata);
+ const body = Buffer.concat([
+ Buffer.from(
+ `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metaPart}\r\n`
+ ),
+ Buffer.from(
+ `--${boundary}\r\nContent-Type: ${opts.mimeType}\r\n\r\n`
+ ),
+ opts.buffer,
+ Buffer.from(`\r\n--${boundary}--`),
+ ]);
+
+ const r = await fetch(
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,webViewLink',
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': `multipart/related; boundary=${boundary}`,
+ },
+ body,
+ }
+ );
+ if (!r.ok) throw new Error(`Drive upload: ${(await r.text()).slice(0, 300)}`);
+ return r.json();
+}
+
+export async function getDriveDownloadUrl(fileId: string): Promise {
+ const token = await getValidAccessToken();
+ // Prefer webContentLink via metadata; else use alt=media with token (short-lived via redirect proxy)
+ const r = await fetch(
+ `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=webViewLink,webContentLink,mimeType&supportsAllDrives=true`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ cache: 'no-store',
+ }
+ );
+ if (!r.ok) throw new Error(`Drive file: ${(await r.text()).slice(0, 200)}`);
+ const j = await r.json();
+ return j.webContentLink || j.webViewLink || `https://drive.google.com/file/d/${fileId}/view`;
+}