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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
92
scripts/dokploy_apply_google_drive.py
Normal file
92
scripts/dokploy_apply_google_drive.py
Normal 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
42
sql/google_drive.sql
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
93
src/app/api/admin/drive/callback/route.ts
Normal file
93
src/app/api/admin/drive/callback/route.ts
Normal 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))}`
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/drive/connect/route.ts
Normal file
42
src/app/api/admin/drive/connect/route.ts
Normal 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;
|
||||
}
|
||||
109
src/app/api/admin/drive/match/route.ts
Normal file
109
src/app/api/admin/drive/match/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/admin/drive/status/route.ts
Normal file
31
src/app/api/admin/drive/status/route.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
85
src/app/api/assets/drive/route.ts
Normal file
85
src/app/api/assets/drive/route.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<DriveConnectPanel />
|
||||
|
||||
{warning && (
|
||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
{warning}
|
||||
@@ -166,7 +169,7 @@ export default function AgencyPortfolioBoard() {
|
||||
Portafolio · estado de conexiones
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<Asset[]>([]);
|
||||
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
|
||||
const [driveFolder, setDriveFolder] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(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<string | null>(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
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
|
||||
Archivo oficial de <strong className="text-gray-300">{tenantLabel}</strong>:
|
||||
sube PDF, Excel, CSV, imágenes, ZIP u otros archivos ya validados.
|
||||
Opcional: carpeta lógica (p. ej. <code>briefs/2026</code>).
|
||||
Archivo oficial de <strong className="text-gray-300">{tenantLabel}</strong>.
|
||||
Subidas al portal se copian a la carpeta Drive del cliente si está
|
||||
enlazada.
|
||||
</p>
|
||||
</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 && (
|
||||
<p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
{warning}
|
||||
@@ -138,175 +212,230 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
)}
|
||||
{err && <p className="text-xs text-red-400">{err}</p>}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] uppercase text-gray-500">Carpeta</label>
|
||||
<input
|
||||
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"
|
||||
value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)}
|
||||
list="asset-folders"
|
||||
/>
|
||||
<datalist id="asset-folders">
|
||||
{folders.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<label className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-medium text-white cursor-pointer">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
Subir archivos
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) void uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
{tab === 'portal' && (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] uppercase text-gray-500">
|
||||
Carpeta
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)}
|
||||
list="asset-folders"
|
||||
/>
|
||||
<datalist id="asset-folders">
|
||||
{folders.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<label className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-medium text-white cursor-pointer">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
Subir archivos
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) void uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-700 px-4 py-2.5 text-sm text-gray-300 hover:text-white cursor-pointer">
|
||||
Subir carpeta
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
{...{ webkitdirectory: '', directory: '' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
const rel =
|
||||
(e.target.files[0] as File & { webkitRelativePath?: string })
|
||||
.webkitRelativePath || '';
|
||||
const top = rel.split('/')[0];
|
||||
if (top && !folder) setFolder(top);
|
||||
void uploadFiles(e.target.files);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-700 px-4 py-2.5 text-sm text-gray-300 hover:text-white cursor-pointer">
|
||||
Subir carpeta
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
// Folder picker (Chromium/Safari)
|
||||
{...{ webkitdirectory: '', directory: '' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
// Use first file relative path as folder hint if empty
|
||||
const rel =
|
||||
(e.target.files[0] as File & { webkitRelativePath?: string })
|
||||
.webkitRelativePath || '';
|
||||
const top = rel.split('/')[0];
|
||||
if (top && !folder) setFolder(top);
|
||||
void uploadFiles(e.target.files);
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
if (e.dataTransfer.files?.length) {
|
||||
void uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
if (e.dataTransfer.files?.length) {
|
||||
void uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
|
||||
drag
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-gray-700 bg-[#111827]'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-300">
|
||||
Arrastra aquí cualquier archivo (máx. 80 MB c/u)
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
PDF · Excel · CSV · imágenes · ZIP · vídeo · etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{folders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolder('')}
|
||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
||||
!folder
|
||||
? 'border-brand-500/40 text-brand-400'
|
||||
: 'border-gray-700 text-gray-400'
|
||||
className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
|
||||
drag
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-gray-700 bg-[#111827]'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFolder(f)}
|
||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
||||
folder === f
|
||||
? 'border-brand-500/40 text-brand-400'
|
||||
: 'border-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-300">
|
||||
Arrastra aquí cualquier archivo (máx. 80 MB c/u)
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
Se guarda en portal y se refleja en Drive si el cliente está
|
||||
enlazado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{folders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolder('')}
|
||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
||||
!folder
|
||||
? 'border-brand-500/40 text-brand-400'
|
||||
: 'border-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFolder(f)}
|
||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
||||
folder === f
|
||||
? 'border-brand-500/40 text-brand-400'
|
||||
: 'border-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando…</p>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-base font-medium text-gray-300">
|
||||
Sin archivos en esta vista
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{visible.map((a) => (
|
||||
<li
|
||||
key={a.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(a.mime_type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white font-medium truncate">
|
||||
{a.file_name}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{a.folder ? `${a.folder}/` : ''}
|
||||
{formatBytes(a.bytes)}
|
||||
{a.created_at
|
||||
? ` · ${new Date(a.created_at).toLocaleString('es-ES')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden sm:inline-flex items-center gap-1 text-[10px] text-emerald-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> Validado
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(a.id)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
|
||||
title="Descargar"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a.id)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando…</p>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-base font-medium text-gray-300">
|
||||
Sin archivos en esta vista
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{visible.map((a) => (
|
||||
<li
|
||||
key={a.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(a.mime_type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white font-medium truncate">
|
||||
{a.file_name}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{a.folder ? `${a.folder}/` : ''}
|
||||
{formatBytes(a.bytes)}
|
||||
{a.created_at
|
||||
? ` · ${new Date(a.created_at).toLocaleString('es-ES')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden sm:inline-flex items-center gap-1 text-[10px] text-emerald-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> Validado
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(a.id)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
|
||||
title="Descargar"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a.id)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
180
src/components/DriveConnectPanel.tsx
Normal file
180
src/components/DriveConnectPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
594
src/lib/google-drive.ts
Normal file
594
src/lib/google-drive.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user