diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 2dc76ec..c97eece 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -7,6 +7,7 @@ - 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) - 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 @@ -36,7 +37,8 @@ Nunca pegar keys en PDFs ni `.env.example`. ## Pendiente ops - `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 diff --git a/scripts/dokploy_apply_asset_files.py b/scripts/dokploy_apply_asset_files.py new file mode 100644 index 0000000..acf9a45 --- /dev/null +++ b/scripts/dokploy_apply_asset_files.py @@ -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()) diff --git a/sql/asset_files.sql b/sql/asset_files.sql new file mode 100644 index 0000000..d4dd2ff --- /dev/null +++ b/sql/asset_files.sql @@ -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} diff --git a/src/app/api/assets/download/route.ts b/src/app/api/assets/download/route.ts new file mode 100644 index 0000000..f6a4b3c --- /dev/null +++ b/src/app/api/assets/download/route.ts @@ -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, + }); +} diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts new file mode 100644 index 0000000..8016f8d --- /dev/null +++ b/src/app/api/assets/route.ts @@ -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> & { + 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> & { + 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> & { + 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 }); +} diff --git a/src/app/dashboard/activos/page.tsx b/src/app/dashboard/activos/page.tsx index 3549fe9..7900f66 100644 --- a/src/app/dashboard/activos/page.tsx +++ b/src/app/dashboard/activos/page.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { FileText, CheckCircle2 } from 'lucide-react'; +import { CheckCircle2 } from 'lucide-react'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; +import AssetsLibrary from '@/components/AssetsLibrary'; export const dynamic = 'force-dynamic'; @@ -28,15 +29,13 @@ export default async function ActivosPage() { const agency = isAgencyRole(userRole(user)); let documents: Doc[] = []; - if (ctx.portfolioWide) { - documents = []; - } else if (agency && ctx.viaView && ctx.tenantId) { + if (!ctx.portfolioWide && agency && ctx.viaView && ctx.tenantId) { const tid = encodeURIComponent(ctx.tenantId); const r = await restGet( `/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 || []; - } else { + } else if (!ctx.portfolioWide) { const { data, error } = await supabase .from('documents') .select('id,type,title,content,version,created_at,status') @@ -46,70 +45,59 @@ export default async function ActivosPage() { else documents = data ?? []; } - return ( -
-
-

- Biblioteca de activos -

-

- Entregables aprobados del cliente ({ctx.label}): briefs, audits, - copys, calendarios exportados. Fuente:{' '} - documents con status=approved (agentes → - Slack → OK). Distinto del pipeline de creatividades ads. + if (ctx.portfolioWide) { + return ( +

+
+

+ Biblioteca de activos +

+

+ Selecciona un cliente en Contexto para subir o consultar archivos + validados. +

+
+

+ Vista global: elige el tenant arriba y vuelve aquí.

+ ); + } - {ctx.portfolioWide ? ( -

- Selecciona un cliente en Contexto para ver su biblioteca. -

- ) : ( -
- {documents.length === 0 ? ( -
- -

- No hay activos aprobados todavía. -

-

- Aparecerán aquí tras validación en Slack / ops. -

-
- ) : ( - documents.map((doc) => ( + return ( +
+ + + {documents.length > 0 && ( +
+
+

+ Entregables de agentes +

+

+ Textos/briefs aprobados en documents (Slack / ops). +

+
+
+ {documents.map((doc) => (
-
-
- - {doc.type} - - - Aprobado - -
-

- {doc.title} -

-

- {doc.content} -

-
-
- Versión v{doc.version} - - {doc.created_at - ? new Date(doc.created_at).toLocaleDateString('es-ES') - : '—'} +
+ + {doc.type} + + + Aprobado
+

{doc.title}

+

{doc.content}

- )) - )} -
+ ))} +
+
)}
); diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index 005ef19..57953e5 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -135,8 +135,8 @@ export default function CreativesPage() {

Pipeline vacío

Úsalo para piezas en producción creativa por cliente. La biblioteca - de activos es para documentos/briefs aprobados; esto es el flow de - ads. + de activos guarda archivos validados (PDF, Excel, packs…); esto es + el flow de ads.

) : ( diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx new file mode 100644 index 0000000..2b0b4fc --- /dev/null +++ b/src/components/AssetsLibrary.tsx @@ -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([]); + const [warning, setWarning] = useState(null); + const [folder, setFolder] = useState(''); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [err, setErr] = useState(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(); + 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 ( +
+
+

+ Biblioteca de activos +

+

+ Archivo oficial de {tenantLabel}: + sube PDF, Excel, CSV, imágenes, ZIP u otros archivos ya validados. + Opcional: carpeta lógica (p. ej. briefs/2026). +

+
+ + {warning && ( +

+ {warning} +

+ )} + {err &&

{err}

} + +
+
+ + 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-700 bg-[#111827]' + }`} + > + +

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

+

+ PDF · Excel · CSV · imágenes · ZIP · vídeo · etc. +

+
+ + {folders.length > 0 && ( +
+ + {folders.map((f) => ( + + ))} +
+ )} + + {loading ? ( +

Cargando…

+ ) : visible.length === 0 ? ( +
+ +

+ Sin archivos en esta vista +

+
+ ) : ( +
    + {visible.map((a) => ( +
  • + + {iconHint(a.mime_type)} + +
    +

    + {a.file_name} +

    +

    + {a.folder ? `${a.folder}/` : ''} + {formatBytes(a.bytes)} + {a.created_at + ? ` · ${new Date(a.created_at).toLocaleString('es-ES')}` + : ''} +

    +
    + + Validado + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/lib/assets.ts b/src/lib/assets.ts new file mode 100644 index 0000000..3715c02 --- /dev/null +++ b/src/lib/assets.ts @@ -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); +}