feat: Google Drive agency sync with auto folder match

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

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

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
getDriveDownloadUrl,
getTenantDriveFolderId,
listFilesInFolder,
} from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
/** GET — list Drive files for active tenant folder (or ?download=fileId). */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
// Clients only see own tenant; agency via view cookie already in ctx
const agency = isAgencyRole(userRole(user));
if (!agency) {
// resolveActiveTenant already scopes to home tenant
}
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
if (downloadId) {
try {
const url = await getDriveDownloadUrl(downloadId);
return NextResponse.json({ url });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'download failed' },
{ status: 500 }
);
}
}
const link = await getTenantDriveFolderId(ctx.tenantId);
if (!link) {
return NextResponse.json({
files: [],
linked: false,
warning:
'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match.',
});
}
try {
const files = await listFilesInFolder(link.folderId);
return NextResponse.json({
linked: true,
folderId: link.folderId,
folderName: link.folderName,
files: files.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
bytes: f.size ? Number(f.size) : null,
modifiedTime: f.modifiedTime,
webViewLink: f.webViewLink,
source: 'drive' as const,
})),
});
} catch (e) {
return NextResponse.json({
linked: true,
folderId: link.folderId,
folderName: link.folderName,
files: [],
warning: e instanceof Error ? e.message : 'Drive error',
});
}
}

View File

@@ -15,6 +15,11 @@ import {
sanitizeFolder,
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(