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=
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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'],
|
['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',
|
||||||
|
|||||||
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,
|
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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,175 +212,230 @@ 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>}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
{tab === 'portal' && (
|
||||||
<div className="flex-1">
|
<>
|
||||||
<label className="text-[11px] uppercase text-gray-500">Carpeta</label>
|
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
||||||
<input
|
<div className="flex-1">
|
||||||
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
|
<label className="text-[11px] uppercase text-gray-500">
|
||||||
placeholder="ej. creatividades/meta o vacío = raíz"
|
Carpeta
|
||||||
value={folder}
|
</label>
|
||||||
onChange={(e) => setFolder(e.target.value)}
|
<input
|
||||||
list="asset-folders"
|
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"
|
||||||
<datalist id="asset-folders">
|
value={folder}
|
||||||
{folders.map((f) => (
|
onChange={(e) => setFolder(e.target.value)}
|
||||||
<option key={f} value={f} />
|
list="asset-folders"
|
||||||
))}
|
/>
|
||||||
</datalist>
|
<datalist id="asset-folders">
|
||||||
</div>
|
{folders.map((f) => (
|
||||||
<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">
|
<option key={f} value={f} />
|
||||||
{uploading ? (
|
))}
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
</datalist>
|
||||||
) : (
|
</div>
|
||||||
<Upload className="w-4 h-4" />
|
<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 ? (
|
||||||
Subir archivos
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<input
|
) : (
|
||||||
type="file"
|
<Upload className="w-4 h-4" />
|
||||||
className="hidden"
|
)}
|
||||||
multiple
|
Subir archivos
|
||||||
onChange={(e) => {
|
<input
|
||||||
if (e.target.files) void uploadFiles(e.target.files);
|
type="file"
|
||||||
e.target.value = '';
|
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);
|
||||||
}}
|
}}
|
||||||
/>
|
onDragOver={(e) => e.preventDefault()}
|
||||||
</label>
|
onDragLeave={() => setDrag(false)}
|
||||||
<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">
|
onDrop={(e) => {
|
||||||
Subir carpeta
|
e.preventDefault();
|
||||||
<input
|
setDrag(false);
|
||||||
type="file"
|
if (e.dataTransfer.files?.length) {
|
||||||
className="hidden"
|
void uploadFiles(e.dataTransfer.files);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
|
||||||
}}
|
}}
|
||||||
/>
|
className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
|
||||||
</label>
|
drag
|
||||||
</div>
|
? 'border-brand-500 bg-brand-500/10'
|
||||||
|
: 'border-gray-700 bg-[#111827]'
|
||||||
<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'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Todas
|
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
|
||||||
</button>
|
<p className="text-sm text-gray-300">
|
||||||
{folders.map((f) => (
|
Arrastra aquí cualquier archivo (máx. 80 MB c/u)
|
||||||
<button
|
</p>
|
||||||
key={f}
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
type="button"
|
Se guarda en portal y se refleja en Drive si el cliente está
|
||||||
onClick={() => setFolder(f)}
|
enlazado
|
||||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
</p>
|
||||||
folder === f
|
</div>
|
||||||
? 'border-brand-500/40 text-brand-400'
|
|
||||||
: 'border-gray-700 text-gray-400'
|
{folders.length > 0 && (
|
||||||
}`}
|
<div className="flex flex-wrap gap-2">
|
||||||
>
|
<button
|
||||||
{f}
|
type="button"
|
||||||
</button>
|
onClick={() => setFolder('')}
|
||||||
))}
|
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
||||||
</div>
|
!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 ? (
|
{tab === 'drive' && (
|
||||||
<p className="text-sm text-gray-500">Cargando…</p>
|
<>
|
||||||
) : visible.length === 0 ? (
|
{loading ? (
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
<p className="text-sm text-gray-500">Cargando Drive…</p>
|
||||||
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
) : driveFiles.length === 0 ? (
|
||||||
<p className="text-base font-medium text-gray-300">
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||||
Sin archivos en esta vista
|
<HardDrive className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||||
</p>
|
<p className="text-base font-medium text-gray-300">
|
||||||
</div>
|
Sin archivos en Drive
|
||||||
) : (
|
</p>
|
||||||
<ul className="space-y-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
{visible.map((a) => (
|
Conecta marketing@ en Agencia y ejecuta auto-match por nombre.
|
||||||
<li
|
</p>
|
||||||
key={a.id}
|
</div>
|
||||||
className="flex items-center gap-3 rounded-xl border border-gray-800 bg-[#111827] px-4 py-3"
|
) : (
|
||||||
>
|
<ul className="space-y-2">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
|
{driveFiles.map((f) => (
|
||||||
{iconHint(a.mime_type)}
|
<li
|
||||||
</span>
|
key={f.id}
|
||||||
<div className="flex-1 min-w-0">
|
className="flex items-center gap-3 rounded-xl border border-gray-800 bg-[#111827] px-4 py-3"
|
||||||
<p className="text-sm text-white font-medium truncate">
|
>
|
||||||
{a.file_name}
|
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
|
||||||
</p>
|
{iconHint(f.mimeType)}
|
||||||
<p className="text-[11px] text-gray-500">
|
</span>
|
||||||
{a.folder ? `${a.folder}/` : ''}
|
<div className="flex-1 min-w-0">
|
||||||
{formatBytes(a.bytes)}
|
<p className="text-sm text-white font-medium truncate">
|
||||||
{a.created_at
|
{f.name}
|
||||||
? ` · ${new Date(a.created_at).toLocaleString('es-ES')}`
|
</p>
|
||||||
: ''}
|
<p className="text-[11px] text-gray-500">
|
||||||
</p>
|
{formatBytes(f.bytes)}
|
||||||
</div>
|
{f.modifiedTime
|
||||||
<span className="hidden sm:inline-flex items-center gap-1 text-[10px] text-emerald-400">
|
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
|
||||||
<CheckCircle2 className="w-3 h-3" /> Validado
|
: ''}
|
||||||
</span>
|
</p>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => download(a.id)}
|
type="button"
|
||||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
|
onClick={() => void openDrive(f)}
|
||||||
title="Descargar"
|
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
|
||||||
>
|
title="Abrir en Drive"
|
||||||
<Download className="w-4 h-4" />
|
>
|
||||||
</button>
|
<ExternalLink className="w-4 h-4" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</li>
|
||||||
onClick={() => remove(a.id)}
|
))}
|
||||||
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
</ul>
|
||||||
title="Eliminar"
|
)}
|
||||||
>
|
</>
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
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: '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
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