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>
310 lines
8.6 KiB
TypeScript
310 lines
8.6 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { createClient } from '@/lib/supabase/server';
|
|
import {
|
|
isAgencyRole,
|
|
serviceHeaders,
|
|
supabaseUrl,
|
|
userRole,
|
|
} from '@/lib/agency';
|
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
|
import {
|
|
ASSETS_BUCKET,
|
|
MAX_ASSET_BYTES,
|
|
ensureAssetsBucket,
|
|
sanitizeFileName,
|
|
sanitizeFolder,
|
|
serviceSupabase,
|
|
} from '@/lib/assets';
|
|
import {
|
|
filterPortalAssetsForClient,
|
|
isAppGeneratedPortalFolder,
|
|
} from '@/lib/assets-client-scope';
|
|
import {
|
|
ensureChildFolder,
|
|
getTenantDriveFolderId,
|
|
uploadToDrive,
|
|
} from '@/lib/google-drive';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
export const runtime = 'nodejs';
|
|
|
|
async function gate() {
|
|
const supabase = await createClient();
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
if (!user) {
|
|
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
|
}
|
|
const ctx = await resolveActiveTenant(user);
|
|
if (ctx.portfolioWide || !ctx.tenantId) {
|
|
return {
|
|
error: NextResponse.json(
|
|
{ error: 'Selecciona un cliente en Contexto antes de gestionar activos.' },
|
|
{ status: 400 }
|
|
),
|
|
};
|
|
}
|
|
return { user, ctx, agency: isAgencyRole(userRole(user)) };
|
|
}
|
|
|
|
/** GET — list approved files for active tenant */
|
|
export async function GET() {
|
|
const g = await gate();
|
|
if ('error' in g && g.error) return g.error;
|
|
const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
|
ctx: { tenantId: string };
|
|
agency: boolean;
|
|
};
|
|
|
|
const headers = serviceHeaders();
|
|
if (agency && headers) {
|
|
const r = await fetch(
|
|
`${supabaseUrl()}/rest/v1/asset_files?tenant_id=eq.${encodeURIComponent(ctx.tenantId!)}&status=eq.approved&select=*&order=created_at.desc`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!r.ok) {
|
|
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(),
|
|
agency: true,
|
|
clientScope: 'full',
|
|
});
|
|
}
|
|
|
|
const supabase = await createClient();
|
|
const { data, error } = await supabase
|
|
.from('asset_files')
|
|
.select('*')
|
|
.eq('status', 'approved')
|
|
.order('created_at', { ascending: false });
|
|
if (error) {
|
|
return NextResponse.json({
|
|
assets: [],
|
|
agency: false,
|
|
clientScope: 'app_generated',
|
|
warning: error.message,
|
|
});
|
|
}
|
|
// 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, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
|
user: { id: string };
|
|
ctx: { tenantId: string };
|
|
agency: boolean;
|
|
};
|
|
|
|
const bucketOk = await ensureAssetsBucket();
|
|
if (!bucketOk.ok) {
|
|
return NextResponse.json(
|
|
{ error: `Storage bucket: ${bucketOk.error}` },
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
|
|
const svc = serviceSupabase();
|
|
const headers = serviceHeaders();
|
|
if (!svc || !headers) {
|
|
return NextResponse.json(
|
|
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
|
|
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) {
|
|
return NextResponse.json({ error: 'Ningún archivo' }, { status: 400 });
|
|
}
|
|
|
|
const uploaded: unknown[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const file of files) {
|
|
if (file.size <= 0) {
|
|
errors.push(`${file.name}: vacío`);
|
|
continue;
|
|
}
|
|
if (file.size > MAX_ASSET_BYTES) {
|
|
errors.push(`${file.name}: supera 80 MB`);
|
|
continue;
|
|
}
|
|
|
|
const safeName = sanitizeFileName(file.name);
|
|
const id = randomUUID();
|
|
const pathParts = [ctx.tenantId!, folder, `${id}_${safeName}`].filter(
|
|
Boolean
|
|
);
|
|
const storagePath = pathParts.join('/');
|
|
const buf = Buffer.from(await file.arrayBuffer());
|
|
const sha256 = createHash('sha256').update(buf).digest('hex');
|
|
|
|
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
|
contentType: file.type || 'application/octet-stream',
|
|
upsert: false,
|
|
});
|
|
if (up.error) {
|
|
errors.push(`${file.name}: ${up.error.message}`);
|
|
continue;
|
|
}
|
|
|
|
const row = {
|
|
id,
|
|
tenant_id: ctx.tenantId,
|
|
title: safeName,
|
|
folder,
|
|
file_name: safeName,
|
|
mime_type: file.type || 'application/octet-stream',
|
|
bytes: file.size,
|
|
storage_path: storagePath,
|
|
sha256,
|
|
status: 'approved',
|
|
created_by: user.id,
|
|
};
|
|
|
|
const ins = await fetch(`${supabaseUrl()}/rest/v1/asset_files`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=representation',
|
|
},
|
|
body: JSON.stringify(row),
|
|
});
|
|
if (!ins.ok) {
|
|
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
|
errors.push(`${file.name}: ${await ins.text()}`);
|
|
continue;
|
|
}
|
|
const created = await ins.json();
|
|
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(
|
|
{ uploaded, errors },
|
|
{ status: uploaded.length ? 201 : 400 }
|
|
);
|
|
}
|
|
|
|
/** DELETE ?id= */
|
|
export async function DELETE(req: NextRequest) {
|
|
const g = await gate();
|
|
if ('error' in g && g.error) return g.error;
|
|
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 });
|
|
}
|
|
|
|
const headers = serviceHeaders();
|
|
const svc = serviceSupabase();
|
|
if (!headers || !svc) {
|
|
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
|
}
|
|
|
|
const get = await fetch(
|
|
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId!)}&select=id,storage_path`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!get.ok) {
|
|
return NextResponse.json({ error: await get.text() }, { status: 500 });
|
|
}
|
|
const rows = await get.json();
|
|
const row = rows[0];
|
|
if (!row) {
|
|
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
|
|
}
|
|
|
|
await svc.storage.from(ASSETS_BUCKET).remove([row.storage_path]);
|
|
const del = await fetch(
|
|
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}`,
|
|
{ method: 'DELETE', headers }
|
|
);
|
|
if (!del.ok) {
|
|
return NextResponse.json({ error: await del.text() }, { status: 500 });
|
|
}
|
|
return NextResponse.json({ ok: true });
|
|
}
|