feat: Google Drive agency sync with auto folder match

Connect marketing@ once, match Drive folders to tenants by name,
list Drive in Activos, and mirror portal uploads to the linked folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 16:27:54 +02:00
parent 31ca77558e
commit 1eb02dac4d
17 changed files with 1629 additions and 173 deletions

View File

@@ -8,3 +8,10 @@ ANTHROPIC_API_KEY=replace-with-anthropic-key
OPS_ALERT_EMAIL= OPS_ALERT_EMAIL=
CRON_SECRET= CRON_SECRET=
N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es 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

View File

@@ -8,6 +8,7 @@
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21) - `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) - 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` - 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 ## Login

View File

@@ -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())

42
sql/google_drive.sql Normal file
View File

@@ -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;

View File

@@ -54,6 +54,7 @@ export async function POST(req: NextRequest) {
['clientify', 'Clientify'], ['clientify', 'Clientify'],
['klaviyo', 'Klaviyo'], ['klaviyo', 'Klaviyo'],
['mailchimp', 'Mailchimp'], ['mailchimp', 'Mailchimp'],
['google_drive', 'Google Drive'],
]; ];
await fetch(`${url}/rest/v1/integrations`, { await fetch(`${url}/rest/v1/integrations`, {
method: 'POST', method: 'POST',

View File

@@ -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))}`
);
}
}

View File

@@ -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;
}

View File

@@ -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 }
);
}
}

View File

@@ -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',
});
}
}

View File

@@ -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',
});
}
}

View File

@@ -15,6 +15,11 @@ import {
sanitizeFolder, sanitizeFolder,
serviceSupabase, serviceSupabase,
} from '@/lib/assets'; } from '@/lib/assets';
import {
ensureChildFolder,
getTenantDriveFolderId,
uploadToDrive,
} from '@/lib/google-drive';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -176,7 +181,47 @@ export async function POST(req: NextRequest) {
continue; continue;
} }
const created = await ins.json(); 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( return NextResponse.json(

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import DriveConnectPanel from '@/components/DriveConnectPanel';
import { import {
AlertTriangle, AlertTriangle,
Bell, Bell,
@@ -106,6 +107,8 @@ export default function AgencyPortfolioBoard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<DriveConnectPanel />
{warning && ( {warning && (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2"> <p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
{warning} {warning}
@@ -166,7 +169,7 @@ export default function AgencyPortfolioBoard() {
Portafolio · estado de conexiones Portafolio · estado de conexiones
</h3> </h3>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Meta · Google Ads · GA4 · GSC · GTM · CRM · Email Meta · Google Ads · GA4 · GSC · GTM · CRM · Email · Drive
</p> </p>
</div> </div>

View File

@@ -4,8 +4,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
CheckCircle2, CheckCircle2,
Download, Download,
ExternalLink,
FileText, FileText,
FolderOpen, FolderOpen,
HardDrive,
Loader2, Loader2,
Trash2, Trash2,
Upload, Upload,
@@ -21,6 +23,15 @@ type Asset = {
created_at?: string; created_at?: string;
}; };
type DriveFile = {
id: string;
name: string;
mimeType?: string;
bytes?: number | null;
modifiedTime?: string;
webViewLink?: string;
};
function formatBytes(n?: number | null) { function formatBytes(n?: number | null) {
if (!n) return '—'; if (!n) return '—';
if (n < 1024) return `${n} B`; if (n < 1024) return `${n} B`;
@@ -32,6 +43,7 @@ function iconHint(mime?: string | null) {
if (!mime) return 'FILE'; if (!mime) return 'FILE';
if (mime.startsWith('image/')) return 'IMG'; if (mime.startsWith('image/')) return 'IMG';
if (mime.includes('pdf')) return 'PDF'; if (mime.includes('pdf')) return 'PDF';
if (mime.includes('folder')) return 'DIR';
if (mime.includes('sheet') || mime.includes('csv') || mime.includes('excel')) if (mime.includes('sheet') || mime.includes('csv') || mime.includes('excel'))
return 'XLS'; return 'XLS';
if (mime.includes('zip') || mime.includes('rar')) return 'ZIP'; 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 }) { export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) {
const [tab, setTab] = useState<'portal' | 'drive'>('portal');
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
const [driveFolder, setDriveFolder] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null); const [warning, setWarning] = useState<string | null>(null);
const [folder, setFolder] = useState(''); const [folder, setFolder] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -47,15 +62,30 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [drag, setDrag] = useState(false); const [drag, setDrag] = useState(false);
const load = useCallback(async () => { const loadPortal = useCallback(async () => {
setLoading(true);
const res = await fetch('/api/assets'); const res = await fetch('/api/assets');
const data = await res.json(); const data = await res.json();
setAssets(data.assets || []); setAssets(data.assets || []);
setWarning(data.warning || null); if (data.warning) setWarning(data.warning);
setLoading(false);
}, []); }, []);
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(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -92,7 +122,8 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
} else if (data.errors?.length) { } else if (data.errors?.length) {
setErr(data.errors.join(' · ')); setErr(data.errors.join(' · '));
} }
await load(); setTab('portal');
await loadPortal();
setUploading(false); setUploading(false);
} }
@@ -106,6 +137,22 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
window.open(data.url, '_blank', 'noopener,noreferrer'); 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) { async function remove(id: string) {
if (!confirm('¿Eliminar este archivo de la biblioteca?')) return; if (!confirm('¿Eliminar este archivo de la biblioteca?')) return;
const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, { 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(() => ({})); const data = await res.json().catch(() => ({}));
setErr(data.error || 'No se pudo borrar'); setErr(data.error || 'No se pudo borrar');
} }
await load(); await loadPortal();
} }
return ( return (
@@ -125,12 +172,39 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
Biblioteca de activos Biblioteca de activos
</h2> </h2>
<p className="text-sm text-gray-400 mt-1 max-w-2xl"> <p className="text-sm text-gray-400 mt-1 max-w-2xl">
Archivo oficial de <strong className="text-gray-300">{tenantLabel}</strong>: Archivo oficial de <strong className="text-gray-300">{tenantLabel}</strong>.
sube PDF, Excel, CSV, imágenes, ZIP u otros archivos ya validados. Subidas al portal se copian a la carpeta Drive del cliente si está
Opcional: carpeta lógica (p. ej. <code>briefs/2026</code>). enlazada.
</p> </p>
</div> </div>
<div className="flex gap-2">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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'
}`}
>
<HardDrive className="w-3.5 h-3.5" />
Google Drive
{driveFolder ? ` · ${driveFolder}` : ''}
</button>
</div>
{warning && ( {warning && (
<p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2"> <p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2">
{warning} {warning}
@@ -138,9 +212,13 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
)} )}
{err && <p className="text-xs text-red-400">{err}</p>} {err && <p className="text-xs text-red-400">{err}</p>}
{tab === 'portal' && (
<>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end"> <div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div className="flex-1"> <div className="flex-1">
<label className="text-[11px] uppercase text-gray-500">Carpeta</label> <label className="text-[11px] uppercase text-gray-500">
Carpeta
</label>
<input <input
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white" className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
placeholder="ej. creatividades/meta o vacío = raíz" placeholder="ej. creatividades/meta o vacío = raíz"
@@ -177,11 +255,9 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
type="file" type="file"
className="hidden" className="hidden"
multiple multiple
// Folder picker (Chromium/Safari)
{...{ webkitdirectory: '', directory: '' }} {...{ webkitdirectory: '', directory: '' }}
onChange={(e) => { onChange={(e) => {
if (e.target.files?.length) { if (e.target.files?.length) {
// Use first file relative path as folder hint if empty
const rel = const rel =
(e.target.files[0] as File & { webkitRelativePath?: string }) (e.target.files[0] as File & { webkitRelativePath?: string })
.webkitRelativePath || ''; .webkitRelativePath || '';
@@ -220,7 +296,8 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
Arrastra aquí cualquier archivo (máx. 80 MB c/u) Arrastra aquí cualquier archivo (máx. 80 MB c/u)
</p> </p>
<p className="text-[11px] text-gray-500 mt-1"> <p className="text-[11px] text-gray-500 mt-1">
PDF · Excel · CSV · imágenes · ZIP · vídeo · etc. Se guarda en portal y se refleja en Drive si el cliente está
enlazado
</p> </p>
</div> </div>
@@ -308,6 +385,58 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
))} ))}
</ul> </ul>
)} )}
</>
)}
{tab === 'drive' && (
<>
{loading ? (
<p className="text-sm text-gray-500">Cargando Drive…</p>
) : driveFiles.length === 0 ? (
<div className="bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
<HardDrive className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-300">
Sin archivos en Drive
</p>
<p className="text-xs text-gray-500 mt-2">
Conecta marketing@ en Agencia y ejecuta auto-match por nombre.
</p>
</div>
) : (
<ul className="space-y-2">
{driveFiles.map((f) => (
<li
key={f.id}
className="flex items-center gap-3 rounded-xl border border-gray-800 bg-[#111827] px-4 py-3"
>
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
{iconHint(f.mimeType)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium truncate">
{f.name}
</p>
<p className="text-[11px] text-gray-500">
{formatBytes(f.bytes)}
{f.modifiedTime
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
: ''}
</p>
</div>
<button
type="button"
onClick={() => void openDrive(f)}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
title="Abrir en Drive"
>
<ExternalLink className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</>
)}
</div> </div>
); );
} }

View File

@@ -78,6 +78,7 @@ export default function ClientIntegrationsPanel({
{ id: 'analytics', title: 'Analítica y medición' }, { id: 'analytics', title: 'Analítica y medición' },
{ id: 'crm', title: 'CRM' }, { id: 'crm', title: 'CRM' },
{ id: 'email', title: 'Email / lifecycle' }, { id: 'email', title: 'Email / lifecycle' },
{ id: 'files', title: 'Archivos' },
]; ];
return ( return (

View File

@@ -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<string | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
const [matches, setMatches] = useState<MatchRow[] | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(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 (
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Drive…
</p>
);
}
return (
<section className="rounded-xl border border-gray-800 bg-[#111827] p-5 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-start gap-3">
<HardDrive className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
<div>
<h3 className="text-sm font-semibold text-white">
Google Drive (agencia)
</h3>
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
Conecta <strong className="text-gray-300">marketing@4ventures.es</strong>.
Emparejamos carpetas con clientes por nombre automáticamente.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{connected ? (
<button
type="button"
onClick={() => 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 ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Re-emparejar
</button>
) : null}
{configured ? (
<a
href="/api/admin/drive/connect"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-brand-500/90 hover:bg-brand-500 text-white"
>
{connected ? 'Reconectar' : 'Conectar Drive'}
</a>
) : (
<span className="text-[11px] text-amber-400 px-2 py-1.5">
Falta OAuth en Dokploy (GOOGLE_OAUTH_*)
</span>
)}
</div>
</div>
{connected && (
<p className="text-xs text-emerald-400">
Conectado{email ? ` · ${email}` : ''}
</p>
)}
{msg && <p className="text-xs text-gray-300">{msg}</p>}
{err && <p className="text-xs text-red-400">{err}</p>}
{matches && matches.length > 0 && (
<ul className="space-y-2 max-h-72 overflow-y-auto">
{matches.map((m) => (
<li
key={m.tenantId}
className="flex flex-col sm:flex-row sm:items-center gap-2 text-xs border border-gray-800 rounded-lg px-3 py-2"
>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{m.tenantName}</p>
<p className="text-gray-500 truncate">
{m.status === 'matched'
? `→ ${m.folderName} (${m.score})`
: m.status === 'ambiguous'
? 'Ambiguo — elige carpeta'
: 'Sin carpeta clara'}
</p>
</div>
{m.status === 'ambiguous' && m.candidates && (
<div className="flex flex-wrap gap-1">
{m.candidates.map((c) => (
<button
key={c.id}
type="button"
onClick={() => 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}
</button>
))}
</div>
)}
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -9,6 +9,7 @@ export const INTEGRATION_PROVIDERS = [
{ id: 'clientify', label: 'Clientify', group: 'crm' }, { id: 'clientify', label: 'Clientify', group: 'crm' },
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' }, { id: 'klaviyo', label: 'Klaviyo', group: 'email' },
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' }, { id: 'mailchimp', label: 'Mailchimp', group: 'email' },
{ id: 'google_drive', label: 'Google Drive', group: 'files' },
] as const; ] as const;
export type IntegrationProviderId = export type IntegrationProviderId =

594
src/lib/google-drive.ts Normal file
View File

@@ -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<string | null> {
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<void> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
const row: Record<string, unknown> = {
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<string> {
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<DriveFolder[]> {
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<MatchResult[]> {
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<string>();
const usedTenants = new Set<string>();
const assigned = new Map<string, { folder: DriveFolder; score: number }>();
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<string> {
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<string> {
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`;
}