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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
127
scripts/dokploy_apply_asset_files.py
Normal file
127
scripts/dokploy_apply_asset_files.py
Normal 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
64
sql/asset_files.sql
Normal 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}
|
||||||
77
src/app/api/assets/download/route.ts
Normal file
77
src/app/api/assets/download/route.ts
Normal 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
229
src/app/api/assets/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
313
src/components/AssetsLibrary.tsx
Normal file
313
src/components/AssetsLibrary.tsx
Normal 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
68
src/lib/assets.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user