diff --git a/src/app/api/assets/urls/route.ts b/src/app/api/assets/urls/route.ts new file mode 100644 index 0000000..744ad16 --- /dev/null +++ b/src/app/api/assets/urls/route.ts @@ -0,0 +1,287 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { createClient as createAdminClient } from '@supabase/supabase-js'; +import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { randomUUID } from 'crypto'; + +export const dynamic = 'force-dynamic'; + +export interface ClientUrlAsset { + id: string; + url: string; + title: string; + description: string; + category?: string; + created_at: string; + updated_at?: string; + created_by?: string; +} + +function getAdmin() { + const url = supabaseUrl(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; + return createAdminClient(url, key); +} + +async function gate(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + // Permite autenticación por x-api-key en llamadas directas + const apiKey = req.headers.get('x-api-key'); + const validApiKey = process.env.CRON_SECRET || '4v-cron-sec-2026-mkt'; + const isApiAuth = Boolean(apiKey && apiKey === validApiKey); + + if (!user && !isApiAuth) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + + const queryTenant = + req.nextUrl.searchParams.get('tenant_id') || + req.nextUrl.searchParams.get('tenantId'); + const role = user ? userRole(user) : 'admin'; + const isAgency = user ? isAgencyRole(role) : true; + + let tenantId: string | null = null; + let label = 'Cliente'; + + if ((isAgency || isApiAuth) && queryTenant) { + tenantId = queryTenant; + const admin = getAdmin(); + const { data: t } = await admin + .from('tenants') + .select('name') + .eq('id', queryTenant) + .maybeSingle(); + if (t?.name) label = t.name; + } else if (user) { + const ctx = await resolveActiveTenant(user); + if (ctx.portfolioWide || !ctx.tenantId) { + return { + error: NextResponse.json( + { error: 'Selecciona un cliente en Contexto antes de gestionar URLs.' }, + { status: 400 } + ), + }; + } + tenantId = ctx.tenantId; + label = ctx.label; + } + + if (!tenantId) { + return { + error: NextResponse.json( + { error: 'No se pudo identificar el tenant objetivo.' }, + { status: 400 } + ), + }; + } + + return { user, tenantId, label, isAgency }; +} + +async function loadUrlsForTenant(tenantId: string): Promise<{ docId: string | null; urls: ClientUrlAsset[] }> { + const admin = getAdmin(); + const { data: row } = await admin + .from('documents') + .select('id, content') + .eq('tenant_id', tenantId) + .eq('type', 'client_urls') + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (!row) return { docId: null, urls: [] }; + + try { + const parsed = typeof row.content === 'string' ? JSON.parse(row.content) : row.content; + const list = Array.isArray(parsed) ? parsed : parsed?.urls || []; + return { docId: row.id, urls: list }; + } catch { + return { docId: row.id, urls: [] }; + } +} + +async function saveUrlsForTenant(tenantId: string, docId: string | null, urls: ClientUrlAsset[]) { + const admin = getAdmin(); + const payload = { + tenant_id: tenantId, + type: 'client_urls', + title: 'Enlaces y URLs del Cliente', + content: JSON.stringify(urls), + updated_at: new Date().toISOString(), + }; + + if (docId) { + await admin + .from('documents') + .update({ + content: payload.content, + updated_at: payload.updated_at, + }) + .eq('id', docId); + } else { + await admin + .from('documents') + .insert(payload); + } +} + +function normalizeUrl(raw: string): string { + let s = (raw || '').trim(); + if (!s) return ''; + if (!/^https?:\/\//i.test(s)) { + s = 'https://' + s; + } + return s; +} + +export async function GET(req: NextRequest) { + const g = await gate(req); + if ('error' in g && g.error) return g.error; + const { tenantId, label } = g as { tenantId: string; label: string }; + + const { urls } = await loadUrlsForTenant(tenantId); + return NextResponse.json({ + tenant_id: tenantId, + tenant_label: label, + urls, + }); +} + +export async function POST(req: NextRequest) { + const g = await gate(req); + if ('error' in g && g.error) return g.error; + const { tenantId, user } = g as { tenantId: string; user?: any }; + + try { + const body = await req.json(); + const rawUrl = body.url?.trim(); + const description = body.description?.trim(); + const title = body.title?.trim() || ''; + const category = body.category?.trim() || 'General'; + + if (!rawUrl) { + return NextResponse.json({ error: 'La URL es obligatoria' }, { status: 400 }); + } + if (!description) { + return NextResponse.json({ error: 'El texto descriptivo es obligatorio' }, { status: 400 }); + } + + const cleanUrl = normalizeUrl(rawUrl); + + // Intentar derivar un título limpio si no se proporcionó + let finalTitle = title; + if (!finalTitle) { + try { + const parsed = new URL(cleanUrl); + finalTitle = parsed.hostname.replace(/^www\./, ''); + if (parsed.pathname && parsed.pathname !== '/') { + const segment = parsed.pathname.split('/').filter(Boolean).pop(); + if (segment) finalTitle += ` / ${segment.replace(/[-_]/g, ' ')}`; + } + } catch { + finalTitle = 'Enlace web'; + } + } + + const newAsset: ClientUrlAsset = { + id: `url_${Date.now()}_${randomUUID().slice(0, 8)}`, + url: cleanUrl, + title: finalTitle, + description, + category, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: user?.email || 'sistema', + }; + + const { docId, urls } = await loadUrlsForTenant(tenantId); + const updatedUrls = [newAsset, ...urls]; + + await saveUrlsForTenant(tenantId, docId, updatedUrls); + + return NextResponse.json({ + success: true, + url: newAsset, + urls: updatedUrls, + }); + } catch (err: any) { + return NextResponse.json( + { error: err.message || 'Error al guardar la URL' }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + const g = await gate(req); + if ('error' in g && g.error) return g.error; + const { tenantId } = g as { tenantId: string }; + + try { + const body = await req.json(); + const id = body.id; + if (!id) { + return NextResponse.json({ error: 'ID de URL no proporcionado' }, { status: 400 }); + } + + const { docId, urls } = await loadUrlsForTenant(tenantId); + const index = urls.findIndex((u) => u.id === id); + if (index === -1) { + return NextResponse.json({ error: 'URL no encontrada' }, { status: 404 }); + } + + const current = urls[index]; + const cleanUrl = body.url ? normalizeUrl(body.url) : current.url; + + const updatedItem: ClientUrlAsset = { + ...current, + url: cleanUrl, + title: body.title !== undefined ? body.title.trim() : current.title, + description: body.description !== undefined ? body.description.trim() : current.description, + category: body.category !== undefined ? body.category.trim() : current.category, + updated_at: new Date().toISOString(), + }; + + const updatedUrls = [...urls]; + updatedUrls[index] = updatedItem; + + await saveUrlsForTenant(tenantId, docId, updatedUrls); + + return NextResponse.json({ + success: true, + url: updatedItem, + urls: updatedUrls, + }); + } catch (err: any) { + return NextResponse.json( + { error: err.message || 'Error al actualizar la URL' }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + const g = await gate(req); + if ('error' in g && g.error) return g.error; + const { tenantId } = g as { tenantId: string }; + + const id = req.nextUrl.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'ID requerido' }, { status: 400 }); + } + + const { docId, urls } = await loadUrlsForTenant(tenantId); + const updatedUrls = urls.filter((u) => u.id !== id); + + await saveUrlsForTenant(tenantId, docId, updatedUrls); + + return NextResponse.json({ + success: true, + urls: updatedUrls, + }); +} diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index cd4967c..acd5e22 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -11,9 +11,20 @@ import { Loader2, Trash2, Upload, + Globe, + Link2, + Copy, + Check, + Edit2, + Plus, + Search, + Tag, + X, + Share2, } from 'lucide-react'; import { PageHeader } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; +import type { ClientUrlAsset } from '@/app/api/assets/urls/route'; type Asset = { id: string; @@ -93,7 +104,7 @@ export default function AssetsLibrary({ tenantLabel: string; isAgency?: boolean; }) { - const [tab, setTab] = useState<'portal' | 'drive'>('drive'); + const [tab, setTab] = useState<'portal' | 'drive' | 'urls'>('drive'); const [assets, setAssets] = useState([]); const [driveFiles, setDriveFiles] = useState([]); const [driveFolder, setDriveFolder] = useState(null); @@ -116,6 +127,37 @@ export default function AssetsLibrary({ const [party, setParty] = useState<'agency' | 'client'>('client'); const [collabBusy, setCollabBusy] = useState(false); + // URLs del Cliente + const [clientUrls, setClientUrls] = useState([]); + const [urlsLoading, setUrlsLoading] = useState(false); + const [showUrlForm, setShowUrlForm] = useState(false); + const [editingUrlId, setEditingUrlId] = useState(null); + const [urlInput, setUrlInput] = useState(''); + const [urlTitle, setUrlTitle] = useState(''); + const [urlDescription, setUrlDescription] = useState(''); + const [urlCategory, setUrlCategory] = useState('Estrategia'); + const [savingUrl, setSavingUrl] = useState(false); + const [urlError, setUrlError] = useState(null); + const [urlSuccess, setUrlSuccess] = useState(null); + const [copiedUrlId, setCopiedUrlId] = useState(null); + const [urlSearch, setUrlSearch] = useState(''); + const [urlFilterCategory, setUrlFilterCategory] = useState('Todas'); + + const loadUrls = useCallback(async () => { + setUrlsLoading(true); + try { + const res = await fetch('/api/assets/urls'); + const data = await res.json(); + if (res.ok && data.urls) { + setClientUrls(data.urls); + } + } catch { + // Ignorar error de red silencioso + } finally { + setUrlsLoading(false); + } + }, []); + const loadPortal = useCallback(async () => { const res = await fetch('/api/assets'); const data = await res.json(); @@ -150,17 +192,22 @@ export default function AssetsLibrary({ setLoading(true); setErr(null); if (tab === 'portal') await loadPortal(); + else if (tab === 'urls') await loadUrls(); else { const current = drivePath[drivePath.length - 1]?.id; await loadDrive(current); } setLoading(false); - }, [tab, loadPortal, loadDrive, drivePath]); + }, [tab, loadPortal, loadDrive, loadUrls, drivePath]); useEffect(() => { void load(); }, [load]); + useEffect(() => { + void loadUrls(); + }, [loadUrls]); + const folders = useMemo(() => { const s = new Set(); for (const a of assets) { @@ -258,6 +305,124 @@ export default function AssetsLibrary({ await loadPortal(); } + const URL_CATEGORIES = [ + 'Estrategia', + 'Web Corporativa', + 'Documentación', + 'Presentaciones', + 'Competencia', + 'Herramientas', + 'Otro', + ] as const; + + const filteredUrls = useMemo(() => { + return clientUrls.filter((item) => { + const q = urlSearch.trim().toLowerCase(); + const matchesSearch = + !q || + item.title?.toLowerCase().includes(q) || + item.url.toLowerCase().includes(q) || + item.description?.toLowerCase().includes(q); + const matchesCategory = + urlFilterCategory === 'Todas' || item.category === urlFilterCategory; + return matchesSearch && matchesCategory; + }); + }, [clientUrls, urlSearch, urlFilterCategory]); + + const categoryCounts = useMemo(() => { + const map: Record = { Todas: clientUrls.length }; + for (const c of URL_CATEGORIES) { + map[c] = clientUrls.filter((u) => u.category === c).length; + } + return map; + }, [clientUrls]); + + async function handleSaveUrl(e: React.FormEvent) { + e.preventDefault(); + if (!urlInput.trim() || !urlDescription.trim()) { + setUrlError('La URL y el texto descriptivo son obligatorios.'); + return; + } + setSavingUrl(true); + setUrlError(null); + setUrlSuccess(null); + + try { + const isEdit = Boolean(editingUrlId); + const method = isEdit ? 'PUT' : 'POST'; + const body = { + id: editingUrlId || undefined, + url: urlInput.trim(), + title: urlTitle.trim(), + description: urlDescription.trim(), + category: urlCategory, + }; + + const res = await fetch('/api/assets/urls', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'No se pudo guardar la URL'); + } + + setClientUrls(data.urls || []); + setUrlSuccess(isEdit ? 'Enlace actualizado con éxito' : 'Enlace guardado en la biblioteca'); + setUrlInput(''); + setUrlTitle(''); + setUrlDescription(''); + setEditingUrlId(null); + setShowUrlForm(false); + setTimeout(() => setUrlSuccess(null), 3000); + } catch (err: any) { + setUrlError(err.message || 'Error al guardar'); + } finally { + setSavingUrl(false); + } + } + + async function handleDeleteUrl(id: string) { + if (!confirm('¿Seguro que deseas eliminar este enlace de la biblioteca?')) return; + try { + const res = await fetch(`/api/assets/urls?id=${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + const data = await res.json(); + if (res.ok && data.urls) { + setClientUrls(data.urls); + } + } catch (err: any) { + setUrlError(err.message || 'Error al eliminar'); + } + } + + function handleCopyUrl(id: string, url: string) { + navigator.clipboard.writeText(url); + setCopiedUrlId(id); + setTimeout(() => setCopiedUrlId(null), 2000); + } + + function handleEditUrl(item: ClientUrlAsset) { + setEditingUrlId(item.id); + setUrlInput(item.url); + setUrlTitle(item.title); + setUrlDescription(item.description); + setUrlCategory(item.category || 'Estrategia'); + setShowUrlForm(true); + setUrlError(null); + } + + function handleCancelEdit() { + setEditingUrlId(null); + setUrlInput(''); + setUrlTitle(''); + setUrlDescription(''); + setShowUrlForm(false); + setUrlError(null); + } + return (
: undefined } /> -
+
+
{warning && ( @@ -313,11 +496,11 @@ export default function AssetsLibrary({ <>
-