Limit client asset library to app-generated folders only.
Agency still sees the full Drive tree and portal uploads; clients only see Pipeline-creatividades / calendarios / brandbook-style paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string[]> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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<ReturnType<typeof gate>> & {
|
||||
const { user, ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
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<ReturnType<typeof gate>> & {
|
||||
const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user