feat: panel de creatividades (pipeline editorial) en /dashboard/panel-cliente

Nueva tabla creative_pipeline (separada de creatives, que es solo para
anuncios aprobados Meta/Google): Propuesta -> En diseño -> Revisión
interna -> Aprobado/Rechazado -> Publicado, con RLS por tenant.

- sql/creative_pipeline.sql: esquema + políticas RLS
- /api/creative-pipeline: CRUD con transiciones restringidas (el cliente
  solo puede aprobar/rechazar una pieza en revisión interna; el resto de
  movimientos y la creación son solo agencia)
- /api/creative-pipeline/upload + /media: portada y slides de carrusel
  sobre el bucket privado tenant-assets ya existente
- CreativePipelineBoard: tablero agrupado por mes, con subida de imagen
  inline y acciones según rol
- Alta del módulo "Panel de Creatividades" en tenant-modules y sidebar

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew
This commit is contained in:
Claude
2026-10-04 00:22:03 +02:00
parent e3180ceb51
commit ed18c9d318
8 changed files with 1060 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
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,
serviceSupabase,
} from '@/lib/assets';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
/**
* POST multipart: sube portada o slide de una pieza del pipeline.
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json(
{ error: 'Solo el equipo de la agencia puede subir imágenes' },
{ status: 403 }
);
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json({ error: 'Selecciona un cliente en Contexto' }, { status: 400 });
}
const form = await req.formData();
const file = form.get('file');
const itemId = String(form.get('itemId') || '').trim();
const slot = String(form.get('slot') || 'cover').trim();
const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0);
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
}
if (!itemId) {
return NextResponse.json({ error: 'itemId requerido' }, { status: 400 });
}
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
return NextResponse.json(
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
{ status: 400 }
);
}
const mime = file.type || 'application/octet-stream';
if (!ALLOWED_MIME.has(mime)) {
return NextResponse.json({ error: 'Solo jpg/png/webp' }, { status: 400 });
}
const bucketOk = await ensureAssetsBucket();
if (!bucketOk.ok) {
return NextResponse.json(
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
{ status: 500 }
);
}
const svc = serviceSupabase();
if (!svc) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
// Verifica que la pieza existe y pertenece al tenant activo.
const curRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
itemId
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,slide_paths`,
{ headers, cache: 'no-store' }
);
const curRows = curRes.ok ? await curRes.json() : [];
if (!curRows?.[0]) {
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
}
const ext = (sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg').toLowerCase();
const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`;
const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`;
const buf = Buffer.from(await file.arrayBuffer());
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
contentType: mime,
upsert: true,
});
if (up.error) {
return NextResponse.json({ error: up.error.message }, { status: 500 });
}
const patch: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (slot === 'cover') {
patch.cover_path = storagePath;
patch.cover_mime = mime;
} else {
const slides: string[] = Array.isArray(curRows[0].slide_paths)
? [...curRows[0].slide_paths]
: [];
slides[index] = storagePath;
patch.slide_paths = slides;
}
const patchRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(itemId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
}
);
if (!patchRes.ok) {
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
return NextResponse.json({ error: await patchRes.text() }, { status: 500 });
}
const data = await patchRes.json();
return NextResponse.json({
item: Array.isArray(data) ? data[0] : data,
storage_path: storagePath,
});
}