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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user