diff --git a/src/app/api/assets/drive/route.ts b/src/app/api/assets/drive/route.ts index 8e9bc99..da58b88 100644 --- a/src/app/api/assets/drive/route.ts +++ b/src/app/api/assets/drive/route.ts @@ -2,6 +2,10 @@ 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 { + filterDriveEntriesForClientRoot, + isAppGeneratedDriveFolderName, +} from '@/lib/assets-client-scope'; import { findClientesRootFolder, getDriveDownloadUrl, @@ -15,7 +19,33 @@ export const dynamic = 'force-dynamic'; const FOLDER_MIME = 'application/vnd.google-apps.folder'; -/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */ +async function listAppGeneratedRootFolderIds( + clientRootId: string +): Promise { + const rootFiles = await listFilesInFolder(clientRootId); + return rootFiles + .filter( + (f) => + f.mimeType === FOLDER_MIME && isAppGeneratedDriveFolderName(f.name) + ) + .map((f) => f.id); +} + +async function isUnderAppGeneratedTree( + fileOrFolderId: string, + clientRootId: string, + appRootIds?: string[] +): Promise { + const roots = + appRootIds ?? (await listAppGeneratedRootFolderIds(clientRootId)); + for (const appId of roots) { + if (fileOrFolderId === appId) return true; + if (await isUnderDriveFolder(fileOrFolderId, appId)) return true; + } + return false; +} + +/** GET — Drive tree for active tenant. Agency: full tree. Client: app folders only. */ export async function GET(req: NextRequest) { const supabase = await createClient(); const { @@ -33,7 +63,7 @@ export async function GET(req: NextRequest) { ); } - void isAgencyRole(userRole(user)); + const agency = isAgencyRole(userRole(user)); const downloadId = req.nextUrl.searchParams.get('download')?.trim(); if (downloadId) { @@ -52,6 +82,15 @@ export async function GET(req: NextRequest) { { status: 403 } ); } + if (!agency) { + const inApp = await isUnderAppGeneratedTree(downloadId, link.folderId); + if (!inApp) { + return NextResponse.json( + { error: 'Archivo fuera del alcance del cliente' }, + { status: 403 } + ); + } + } const url = await getDriveDownloadUrl(downloadId); return NextResponse.json({ url }); } catch (e) { @@ -67,6 +106,8 @@ export async function GET(req: NextRequest) { return NextResponse.json({ files: [], linked: false, + agency, + clientScope: agency ? 'full' : 'app_generated', warning: 'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».', }); @@ -88,6 +129,7 @@ export async function GET(req: NextRequest) { const requested = req.nextUrl.searchParams.get('folderId')?.trim(); let folderId = link.folderId; + let appRootIds: string[] | undefined; if (requested && requested !== link.folderId) { const allowed = await isUnderDriveFolder(requested, link.folderId); @@ -102,13 +144,32 @@ export async function GET(req: NextRequest) { { status: 403 } ); } + if (!agency) { + appRootIds = await listAppGeneratedRootFolderIds(link.folderId); + const inApp = await isUnderAppGeneratedTree( + requested, + link.folderId, + appRootIds + ); + if (!inApp) { + return NextResponse.json( + { + error: + 'Solo puedes abrir carpetas generadas por el Centro de Mando (creatividades, calendarios…).', + rootFolderId: link.folderId, + folderName: link.folderName, + }, + { status: 403 } + ); + } + } folderId = requested; } try { const currentMeta = await getDriveFileMeta(folderId); const files = await listFilesInFolder(folderId); - const mapped = files + let mapped = files .map((f) => ({ id: f.id, name: f.name, @@ -124,19 +185,34 @@ export async function GET(req: NextRequest) { return a.name.localeCompare(b.name, 'es'); }); + const atClientRoot = folderId === link.folderId; + if (!agency && atClientRoot) { + mapped = filterDriveEntriesForClientRoot(mapped); + if (!mapped.length && !scopeWarning) { + scopeWarning = + 'Aún no hay carpetas generadas por la app (p. ej. Pipeline-creatividades). El resto del Drive solo lo ve la agencia.'; + } + } + return NextResponse.json({ linked: true, + agency, + clientScope: agency ? 'full' : 'app_generated', rootFolderId: link.folderId, rootFolderName: link.folderName, folderId, folderName: currentMeta?.name || link.folderName, - driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`, + driveOpenUrl: agency + ? `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}` + : null, files: mapped, warning: scopeWarning, }); } catch (e) { return NextResponse.json({ linked: true, + agency, + clientScope: agency ? 'full' : 'app_generated', rootFolderId: link.folderId, rootFolderName: link.folderName, folderId, diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 206fc0f..3a31b8e 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -15,6 +15,10 @@ import { sanitizeFolder, serviceSupabase, } from '@/lib/assets'; +import { + filterPortalAssetsForClient, + isAppGeneratedPortalFolder, +} from '@/lib/assets-client-scope'; import { ensureChildFolder, getTenantDriveFolderId, @@ -64,12 +68,18 @@ export async function GET() { const msg = await r.text(); return NextResponse.json({ assets: [], + agency: true, + clientScope: 'full', warning: msg.includes('does not exist') ? 'Aplicar sql/asset_files.sql' : msg.slice(0, 200), }); } - return NextResponse.json({ assets: await r.json() }); + return NextResponse.json({ + assets: await r.json(), + agency: true, + clientScope: 'full', + }); } const supabase = await createClient(); @@ -81,19 +91,27 @@ export async function GET() { if (error) { return NextResponse.json({ assets: [], + agency: false, + clientScope: 'app_generated', warning: error.message, }); } - return NextResponse.json({ assets: data ?? [] }); + // Client JWT path: still filter to app-generated folders only + return NextResponse.json({ + assets: filterPortalAssetsForClient(data ?? []), + agency: false, + clientScope: 'app_generated', + }); } /** POST multipart: files[] + optional folder */ export async function POST(req: NextRequest) { const g = await gate(); if ('error' in g && g.error) return g.error; - const { user, ctx } = g as Awaited> & { + const { user, ctx, agency } = g as Awaited> & { user: { id: string }; ctx: { tenantId: string }; + agency: boolean; }; const bucketOk = await ensureAssetsBucket(); @@ -115,6 +133,15 @@ export async function POST(req: NextRequest) { const form = await req.formData(); const folder = sanitizeFolder(String(form.get('folder') || '')); + if (!agency && !isAppGeneratedPortalFolder(folder)) { + return NextResponse.json( + { + error: + 'Como cliente solo puedes usar carpetas generadas por la app (creatividades, calendarios, brandbook…).', + }, + { status: 403 } + ); + } const files = form.getAll('files').filter((f): f is File => f instanceof File); if (!files.length) { @@ -234,10 +261,18 @@ export async function POST(req: NextRequest) { export async function DELETE(req: NextRequest) { const g = await gate(); if ('error' in g && g.error) return g.error; - const { ctx } = g as Awaited> & { + const { ctx, agency } = g as Awaited> & { ctx: { tenantId: string }; + agency: boolean; }; + if (!agency) { + return NextResponse.json( + { error: 'Solo la agencia puede eliminar archivos de la biblioteca' }, + { status: 403 } + ); + } + const id = req.nextUrl.searchParams.get('id')?.trim(); if (!id) { return NextResponse.json({ error: 'id requerido' }, { status: 400 }); diff --git a/src/app/dashboard/activos/page.tsx b/src/app/dashboard/activos/page.tsx index 53e86e7..d4d5cf0 100644 --- a/src/app/dashboard/activos/page.tsx +++ b/src/app/dashboard/activos/page.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { resolveActiveTenant } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; import AssetsLibrary from '@/components/AssetsLibrary'; import { EmptyState, PageHeader } from '@/components/ui'; @@ -15,6 +16,7 @@ export default async function ActivosPage() { if (!user) redirect('/login'); const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); if (ctx.portfolioWide) { return ( @@ -32,5 +34,5 @@ export default async function ActivosPage() { ); } - return ; + return ; } diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index d61407c..cd4967c 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -86,7 +86,13 @@ function iconHint(mime?: string | null) { return 'FILE'; } -export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) { +export default function AssetsLibrary({ + tenantLabel, + isAgency = false, +}: { + tenantLabel: string; + isAgency?: boolean; +}) { const [tab, setTab] = useState<'portal' | 'drive'>('drive'); const [assets, setAssets] = useState([]); const [driveFiles, setDriveFiles] = useState([]); @@ -257,8 +263,14 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) } + description={ + isAgency + ? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal.` + : `Archivos generados por el Centro de Mando para «${tenantLabel}»: creatividades, calendarios y carpetas de la app. El resto del Drive solo lo ve la agencia.` + } + actions={ + isAgency ? : undefined + } />
@@ -272,7 +284,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) }`} > - Google Drive + {isAgency ? 'Google Drive' : 'Generados por la app (Drive)'} {driveFolder ? ` · ${driveFolder}` : ''}
@@ -297,92 +309,105 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) {tab === 'portal' && ( <> -
-
- - setFolder(e.target.value)} - list="asset-folders" - /> - - {folders.map((f) => ( - -
- - -
+ {isAgency ? ( + <> +
+
+ + setFolder(e.target.value)} + list="asset-folders" + /> + + {folders.map((f) => ( + +
+ + +
-
{ - 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-200 bg-white' - }`} - > - -

- Arrastra aquí cualquier archivo (máx. 80 MB c/u) +

{ + 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-200 bg-white' + }`} + > + +

+ Arrastra aquí cualquier archivo (máx. 80 MB c/u) +

+

+ Se guarda en portal y se refleja en Drive si el cliente está + enlazado +

+
+ + ) : ( +

+ Solo archivos del portal en carpetas de la app (creatividades, + calendarios, brandbook…). Las subidas manuales las gestiona la + agencia.

-

- Se guarda en portal y se refleja en Drive si el cliente está - enlazado -

-
+ )} {folders.length > 0 && (
@@ -481,14 +506,16 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) > - + {isAgency ? ( + + ) : null} ))} @@ -518,7 +545,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) ))} - {driveOpenUrl && ( + {isAgency && driveOpenUrl && (

- Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}). - Embed iframe no aplica a carpetas privadas; listamos el contenido vía API. + {isAgency + ? `Misma carpeta enlazada en Agencia (Clientes → ${driveFolder || '…'}).` + : 'Solo carpetas creadas por la app (p. ej. Pipeline-creatividades). El resto del Drive del cliente no se muestra.'}

{loading ? (

Cargando Drive…

diff --git a/src/lib/assets-client-scope.ts b/src/lib/assets-client-scope.ts new file mode 100644 index 0000000..80aa868 --- /dev/null +++ b/src/lib/assets-client-scope.ts @@ -0,0 +1,60 @@ +/** + * What clients may see in Biblioteca de Activos. + * Agency sees the full Drive tree + all portal uploads. + * Clients only see folders/files produced by Centro de Mando. + */ + +/** Drive folder names created (or reserved) by the app under the client root. */ +const APP_DRIVE_FOLDER_RES = + /^(Pipeline-creatividades_|Calendarios?[_\s-]?|Creatividades[_\s-]?|Ads[_\s-]?creatividades|Centro[_\s-]?de[_\s-]?Mando|4Ventures[_\s-]?Portal|Portal[_\s-]?app)/i; + +/** Portal asset_files.folder prefixes written by the app (not free-form agency dumps). */ +const APP_PORTAL_FOLDER_PREFIXES = [ + 'brandbook', + 'creatividades', + 'calendarios', + 'calendario', + 'pipeline', + 'pipeline-creatividades', + 'ads', + 'meta', + 'centro-de-mando', + 'kpis', +] as const; + +export function isAppGeneratedDriveFolderName(name: string): boolean { + const n = (name || '').trim(); + if (!n) return false; + return APP_DRIVE_FOLDER_RES.test(n); +} + +export function isAppGeneratedPortalFolder(folder: string | null | undefined): boolean { + const f = String(folder || '') + .trim() + .replace(/^\/+|\/+$/g, '') + .toLowerCase(); + if (!f) return false; + return APP_PORTAL_FOLDER_PREFIXES.some( + (p) => f === p || f.startsWith(`${p}/`) + ); +} + +export function filterPortalAssetsForClient( + assets: T[] +): T[] { + return assets.filter((a) => isAppGeneratedPortalFolder(a.folder)); +} + +export function filterDriveEntriesForClientRoot< + T extends { name: string; isFolder?: boolean; mimeType?: string }, +>(files: T[]): T[] { + return files.filter((f) => { + const isFolder = + f.isFolder || f.mimeType === 'application/vnd.google-apps.folder'; + if (!isFolder) { + // Loose files at client root are agency/manual — hide from client + return false; + } + return isAppGeneratedDriveFolderName(f.name); + }); +}