Activos now supports multi-file/folder upload to private Storage (PDF, Excel, CSV, images, ZIP, etc.) with per-tenant folders. Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
6.4 KiB
TypeScript
230 lines
6.4 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 { 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: [],
|
|
warning: msg.includes('does not exist')
|
|
? 'Aplicar sql/asset_files.sql'
|
|
: msg.slice(0, 200),
|
|
});
|
|
}
|
|
return NextResponse.json({ assets: await r.json() });
|
|
}
|
|
|
|
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: [],
|
|
warning: error.message,
|
|
});
|
|
}
|
|
return NextResponse.json({ assets: data ?? [] });
|
|
}
|
|
|
|
/** 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>> & {
|
|
user: { id: string };
|
|
ctx: { tenantId: string };
|
|
};
|
|
|
|
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') || ''));
|
|
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();
|
|
uploaded.push(Array.isArray(created) ? created[0] : created);
|
|
}
|
|
|
|
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 } = g as Awaited<ReturnType<typeof gate>> & {
|
|
ctx: { tenantId: string };
|
|
};
|
|
|
|
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 });
|
|
}
|