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.

+
+ + +
+ {warning && (

{warning} @@ -138,175 +212,230 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) )} {err &&

{err}

} -
-
- - setFolder(e.target.value)} - list="asset-folders" - /> - - {folders.map((f) => ( - -
-
+ + {folders.length > 0 && ( +
+ + {folders.map((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 + + + +
  • + ))} +
+ )} + )} - {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 - - - -
  • - ))} -
+ {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')}` + : ''} +

    +
    + +
  • + ))} +
+ )} + )} ); 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 ? ( + + ) : 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) => ( + + ))} +
    + )} +
  • + ))} +
+ )} +
+ ); +} 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`; +}