feat: upload any validated file to the asset library

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>
This commit is contained in:
4Ventures
2026-09-22 15:26:33 +02:00
parent 8182402d96
commit ce9498e63c
9 changed files with 931 additions and 63 deletions

View File

@@ -7,6 +7,7 @@
- Login verificado en https://app.4ventures.es/login → `/dashboard/*` y `/dashboard/agencia` - Login verificado en https://app.4ventures.es/login → `/dashboard/*` y `/dashboard/agencia`
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21) - `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente) - Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente)
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
## Login ## Login
@@ -36,7 +37,8 @@ Nunca pegar keys en PDFs ni `.env.example`.
## Pendiente ops ## Pendiente ops
- `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM - `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM
- IP allowlist Traefik para `git` / `n8n` — ver `.ops/HARDEN_SURFACES.md` - OAuth live Meta / Google Ads / GA4 / GSC (hoy: estado manual por tenant en Agencia)
- IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml`
## Verificación ## Verificación

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Apply sql/asset_files.sql + ensure storage bucket via Dokploy / API."""
from __future__ import annotations
import base64
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "asset_files.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/af.b64 /tmp/af.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/af.b64", 400)
run(
"base64 -d /tmp/af.b64 > /tmp/af.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/af.sql; echo EXIT:$?",
9000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
# Create bucket via service role from .env.local
vals = {}
for ln in (ROOT / ".env.local").read_text().splitlines():
if "=" in ln and not ln.strip().startswith("#"):
k, v = ln.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
url = vals["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
key = vals["SUPABASE_SERVICE_ROLE_KEY"]
req = urllib.request.Request(
url + "/storage/v1/bucket",
data=json.dumps(
{
"id": "tenant-assets",
"name": "tenant-assets",
"public": False,
"file_size_limit": 83886080,
}
).encode(),
headers={
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
print("BUCKET", r.status, flush=True)
except urllib.error.HTTPError as e:
body = e.read().decode()[:200]
print("BUCKET", e.code, body, flush=True)
if e.code not in (409, 400) and "exist" not in body.lower():
return 5 if not ok else 0
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

64
sql/asset_files.sql Normal file
View File

@@ -0,0 +1,64 @@
-- File assets library (validated uploads per tenant)
-- Idempotent.
create table if not exists public.asset_files (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
title text not null,
folder text not null default '',
file_name text not null,
mime_type text,
bytes bigint,
storage_path text not null,
sha256 text,
status text not null default 'approved'
check (status in ('approved', 'archived')),
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists asset_files_tenant_folder_idx
on public.asset_files (tenant_id, folder, created_at desc);
alter table public.asset_files enable row level security;
drop policy if exists asset_files_tenant_select on public.asset_files;
drop policy if exists asset_files_tenant_insert on public.asset_files;
drop policy if exists asset_files_tenant_update on public.asset_files;
drop policy if exists asset_files_tenant_delete on public.asset_files;
create policy asset_files_tenant_select on public.asset_files
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_insert on public.asset_files
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_update on public.asset_files
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_delete on public.asset_files
for delete using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Private bucket (created via API if missing). Policies for storage.objects:
-- path layout: {tenant_id}/{optional/folder/}{uuid}_{filename}

View File

@@ -0,0 +1,77 @@
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, serviceSupabase } from '@/lib/assets';
export const dynamic = 'force-dynamic';
/** GET ?id= — signed download URL (60s) */
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) {
return NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 });
}
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 });
}
// Agency can download any tenant's file when in context; verify tenant match
const r = await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}&select=id,tenant_id,storage_path,file_name,mime_type`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const rows = await r.json();
const row = rows[0];
if (!row) {
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
}
const agency = isAgencyRole(userRole(user));
if (!agency && row.tenant_id !== ctx.tenantId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (agency && row.tenant_id !== ctx.tenantId) {
return NextResponse.json({ error: 'Cambia al tenant del archivo' }, { status: 403 });
}
const signed = await svc.storage
.from(ASSETS_BUCKET)
.createSignedUrl(row.storage_path, 120);
if (signed.error || !signed.data?.signedUrl) {
return NextResponse.json(
{ error: signed.error?.message || 'No se pudo firmar URL' },
{ status: 500 }
);
}
return NextResponse.json({
url: signed.data.signedUrl,
fileName: row.file_name,
mimeType: row.mime_type,
});
}

229
src/app/api/assets/route.ts Normal file
View File

@@ -0,0 +1,229 @@
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 });
}

View File

@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { FileText, CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency'; import { isAgencyRole, userRole } from '@/lib/agency';
import AssetsLibrary from '@/components/AssetsLibrary';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -28,15 +29,13 @@ export default async function ActivosPage() {
const agency = isAgencyRole(userRole(user)); const agency = isAgencyRole(userRole(user));
let documents: Doc[] = []; let documents: Doc[] = [];
if (ctx.portfolioWide) { if (!ctx.portfolioWide && agency && ctx.viaView && ctx.tenantId) {
documents = [];
} else if (agency && ctx.viaView && ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId); const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet<Doc[]>( const r = await restGet<Doc[]>(
`/rest/v1/documents?tenant_id=eq.${tid}&status=eq.approved&select=id,type,title,content,version,created_at,status&order=created_at.desc` `/rest/v1/documents?tenant_id=eq.${tid}&status=eq.approved&select=id,type,title,content,version,created_at,status&order=created_at.desc`
); );
documents = r.data || []; documents = r.data || [];
} else { } else if (!ctx.portfolioWide) {
const { data, error } = await supabase const { data, error } = await supabase
.from('documents') .from('documents')
.select('id,type,title,content,version,created_at,status') .select('id,type,title,content,version,created_at,status')
@@ -46,43 +45,45 @@ export default async function ActivosPage() {
else documents = data ?? []; else documents = data ?? [];
} }
if (ctx.portfolioWide) {
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-white"> <h2 className="text-2xl font-bold tracking-tight text-white">
Biblioteca de activos Biblioteca de activos
</h2> </h2>
<p className="text-sm text-gray-400 mt-1 max-w-2xl"> <p className="text-sm text-gray-400 mt-1">
Entregables aprobados del cliente ({ctx.label}): briefs, audits, Selecciona un cliente en Contexto para subir o consultar archivos
copys, calendarios exportados. Fuente:{' '} validados.
<code>documents</code> con <code>status=approved</code> (agentes →
Slack → OK). Distinto del pipeline de creatividades ads.
</p> </p>
</div> </div>
{ctx.portfolioWide ? (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]"> <p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Selecciona un cliente en Contexto para ver su biblioteca. Vista global: elige el tenant arriba y vuelve aquí.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{documents.length === 0 ? (
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-300">
No hay activos aprobados todavía.
</p>
<p className="text-xs text-gray-400 mt-1">
Aparecerán aquí tras validación en Slack / ops.
</p> </p>
</div> </div>
) : ( );
documents.map((doc) => ( }
return (
<div className="space-y-10">
<AssetsLibrary tenantLabel={ctx.label} />
{documents.length > 0 && (
<section className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-white">
Entregables de agentes
</h3>
<p className="text-xs text-gray-500 mt-1">
Textos/briefs aprobados en <code>documents</code> (Slack / ops).
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{documents.map((doc) => (
<div <div
key={doc.id} key={doc.id}
className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between" className="bg-[#111827] border border-gray-800 rounded-xl p-5"
> >
<div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300"> <span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
{doc.type} {doc.type}
@@ -91,25 +92,12 @@ export default async function ActivosPage() {
<CheckCircle2 className="w-3 h-3" /> Aprobado <CheckCircle2 className="w-3 h-3" /> Aprobado
</span> </span>
</div> </div>
<h4 className="font-bold text-white text-base mb-2"> <h4 className="font-bold text-white text-sm mb-2">{doc.title}</h4>
{doc.title} <p className="text-xs text-gray-400 line-clamp-3">{doc.content}</p>
</h4>
<p className="text-xs text-gray-400 line-clamp-4">
{doc.content}
</p>
</div> </div>
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between"> ))}
<span>Versión v{doc.version}</span>
<span>
{doc.created_at
? new Date(doc.created_at).toLocaleDateString('es-ES')
: '—'}
</span>
</div>
</div>
))
)}
</div> </div>
</section>
)} )}
</div> </div>
); );

View File

@@ -135,8 +135,8 @@ export default function CreativesPage() {
<p className="text-gray-300 font-medium">Pipeline vacío</p> <p className="text-gray-300 font-medium">Pipeline vacío</p>
<p className="text-xs text-gray-500 mt-2 max-w-md mx-auto"> <p className="text-xs text-gray-500 mt-2 max-w-md mx-auto">
Úsalo para piezas en producción creativa por cliente. La biblioteca Úsalo para piezas en producción creativa por cliente. La biblioteca
de activos es para documentos/briefs aprobados; esto es el flow de de activos guarda archivos validados (PDF, Excel, packs…); esto es
ads. el flow de ads.
</p> </p>
</div> </div>
) : ( ) : (

View File

@@ -0,0 +1,313 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CheckCircle2,
Download,
FileText,
FolderOpen,
Loader2,
Trash2,
Upload,
} from 'lucide-react';
type Asset = {
id: string;
title: string;
folder?: string;
file_name: string;
mime_type?: string | null;
bytes?: number | null;
created_at?: string;
};
function formatBytes(n?: number | null) {
if (!n) return '—';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function iconHint(mime?: string | null) {
if (!mime) return 'FILE';
if (mime.startsWith('image/')) return 'IMG';
if (mime.includes('pdf')) return 'PDF';
if (mime.includes('sheet') || mime.includes('csv') || mime.includes('excel'))
return 'XLS';
if (mime.includes('zip') || mime.includes('rar')) return 'ZIP';
return 'FILE';
}
export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) {
const [assets, setAssets] = useState<Asset[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const [folder, setFolder] = useState('');
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [drag, setDrag] = useState(false);
const load = useCallback(async () => {
setLoading(true);
const res = await fetch('/api/assets');
const data = await res.json();
setAssets(data.assets || []);
setWarning(data.warning || null);
setLoading(false);
}, []);
useEffect(() => {
void load();
}, [load]);
const folders = useMemo(() => {
const s = new Set<string>();
for (const a of assets) {
if (a.folder) s.add(a.folder);
}
return [...s].sort();
}, [assets]);
const visible = useMemo(() => {
if (!folder) return assets;
return assets.filter((a) => (a.folder || '') === folder);
}, [assets, folder]);
async function uploadFiles(fileList: FileList | File[]) {
const files = [...fileList];
if (!files.length) return;
setUploading(true);
setErr(null);
const fd = new FormData();
fd.set('folder', folder);
for (const f of files) fd.append('files', f);
const res = await fetch('/api/assets', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) {
setErr(
data.error ||
(data.errors || []).join(' · ') ||
'Error al subir'
);
} else if (data.errors?.length) {
setErr(data.errors.join(' · '));
}
await load();
setUploading(false);
}
async function download(id: string) {
const res = await fetch(`/api/assets/download?id=${encodeURIComponent(id)}`);
const data = await res.json();
if (!res.ok || !data.url) {
setErr(data.error || 'No se pudo descargar');
return;
}
window.open(data.url, '_blank', 'noopener,noreferrer');
}
async function remove(id: string) {
if (!confirm('¿Eliminar este archivo de la biblioteca?')) return;
const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErr(data.error || 'No se pudo borrar');
}
await load();
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Biblioteca de activos
</h2>
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
Archivo oficial de <strong className="text-gray-300">{tenantLabel}</strong>:
sube PDF, Excel, CSV, imágenes, ZIP u otros archivos ya validados.
Opcional: carpeta lógica (p. ej. <code>briefs/2026</code>).
</p>
</div>
{warning && (
<p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2">
{warning}
</p>
)}
{err && <p className="text-xs text-red-400">{err}</p>}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div className="flex-1">
<label className="text-[11px] uppercase text-gray-500">Carpeta</label>
<input
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
placeholder="ej. creatividades/meta o vacío = raíz"
value={folder}
onChange={(e) => setFolder(e.target.value)}
list="asset-folders"
/>
<datalist id="asset-folders">
{folders.map((f) => (
<option key={f} value={f} />
))}
</datalist>
</div>
<label className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-medium text-white cursor-pointer">
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Subir archivos
<input
type="file"
className="hidden"
multiple
onChange={(e) => {
if (e.target.files) void uploadFiles(e.target.files);
e.target.value = '';
}}
/>
</label>
<label className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-700 px-4 py-2.5 text-sm text-gray-300 hover:text-white cursor-pointer">
Subir carpeta
<input
type="file"
className="hidden"
multiple
// Folder picker (Chromium/Safari)
{...{ webkitdirectory: '', directory: '' }}
onChange={(e) => {
if (e.target.files?.length) {
// Use first file relative path as folder hint if empty
const rel =
(e.target.files[0] as File & { webkitRelativePath?: string })
.webkitRelativePath || '';
const top = rel.split('/')[0];
if (top && !folder) setFolder(top);
void uploadFiles(e.target.files);
}
e.target.value = '';
}}
/>
</label>
</div>
<div
onDragEnter={(e) => {
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-700 bg-[#111827]'
}`}
>
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-300">
Arrastra aquí cualquier archivo (máx. 80 MB c/u)
</p>
<p className="text-[11px] text-gray-500 mt-1">
PDF · Excel · CSV · imágenes · ZIP · vídeo · etc.
</p>
</div>
{folders.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setFolder('')}
className={`text-[11px] px-2.5 py-1 rounded-md border ${
!folder
? 'border-brand-500/40 text-brand-400'
: 'border-gray-700 text-gray-400'
}`}
>
Todas
</button>
{folders.map((f) => (
<button
key={f}
type="button"
onClick={() => setFolder(f)}
className={`text-[11px] px-2.5 py-1 rounded-md border ${
folder === f
? 'border-brand-500/40 text-brand-400'
: 'border-gray-700 text-gray-400'
}`}
>
{f}
</button>
))}
</div>
)}
{loading ? (
<p className="text-sm text-gray-500">Cargando…</p>
) : visible.length === 0 ? (
<div className="bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-300">
Sin archivos en esta vista
</p>
</div>
) : (
<ul className="space-y-2">
{visible.map((a) => (
<li
key={a.id}
className="flex items-center gap-3 rounded-xl border border-gray-800 bg-[#111827] px-4 py-3"
>
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
{iconHint(a.mime_type)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium truncate">
{a.file_name}
</p>
<p className="text-[11px] text-gray-500">
{a.folder ? `${a.folder}/` : ''}
{formatBytes(a.bytes)}
{a.created_at
? ` · ${new Date(a.created_at).toLocaleString('es-ES')}`
: ''}
</p>
</div>
<span className="hidden sm:inline-flex items-center gap-1 text-[10px] text-emerald-400">
<CheckCircle2 className="w-3 h-3" /> Validado
</span>
<button
type="button"
onClick={() => download(a.id)}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800"
title="Descargar"
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => remove(a.id)}
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
);
}

68
src/lib/assets.ts Normal file
View File

@@ -0,0 +1,68 @@
import { createClient } from '@supabase/supabase-js';
import {
serviceHeaders,
supabaseUrl,
} from '@/lib/agency';
export const ASSETS_BUCKET = 'tenant-assets';
export const MAX_ASSET_BYTES = 80 * 1024 * 1024; // 80 MB
export function serviceSupabase() {
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!key) return null;
return createClient(supabaseUrl(), key, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
export async function ensureAssetsBucket(): Promise<{ ok: boolean; error?: string }> {
const headers = serviceHeaders();
if (!headers) return { ok: false, error: 'no service role' };
const list = await fetch(`${supabaseUrl()}/storage/v1/bucket`, {
headers,
cache: 'no-store',
});
if (list.ok) {
const buckets = (await list.json()) as { id: string }[];
if (buckets.some((b) => b.id === ASSETS_BUCKET)) {
return { ok: true };
}
}
const create = await fetch(`${supabaseUrl()}/storage/v1/bucket`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
id: ASSETS_BUCKET,
name: ASSETS_BUCKET,
public: false,
file_size_limit: MAX_ASSET_BYTES,
allowed_mime_types: null,
}),
});
if (!create.ok) {
const text = await create.text();
// race: already exists
if (text.toLowerCase().includes('already') || create.status === 409) {
return { ok: true };
}
return { ok: false, error: text.slice(0, 300) };
}
return { ok: true };
}
export function sanitizeFolder(raw: string): string {
return raw
.trim()
.replace(/\\/g, '/')
.replace(/\.\./g, '')
.replace(/^\/+|\/+$/g, '')
.replace(/[^a-zA-Z0-9/_ .-]/g, '-')
.slice(0, 180);
}
export function sanitizeFileName(name: string): string {
const base = name.split(/[/\\]/).pop() || 'file';
return base.replace(/[^\w.\- ()áéíóúÁÉÍÓÚñÑ]+/gi, '_').slice(0, 180);
}