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,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
export const dynamic = 'force-dynamic';
/** GET ?path=<storage_path> — redirect to a short-lived signed URL. */
export async function GET(req: NextRequest) {
const path = req.nextUrl.searchParams.get('path')?.trim() || '';
if (!path) {
return NextResponse.json({ error: 'path requerido' }, { status: 400 });
}
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: 'Sin tenant' }, { status: 400 });
}
// El path siempre empieza por "{tenant_id}/pipeline/..." — evita servir
// ficheros de otro tenant aunque conozcan la ruta.
if (!path.startsWith(`${ctx.tenantId}/pipeline/`)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Doble check contra la fila (evita servir paths "adivinados").
const headers = serviceHeaders();
if (headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&or=(cover_path.eq.${encodeURIComponent(path)},slide_paths.cs.${encodeURIComponent(
JSON.stringify([path])
)})&select=id&limit=1`,
{ headers, cache: 'no-store' }
);
if (r.ok) {
const rows = await r.json();
if (!rows?.length) {
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
}
}
}
const svc = serviceSupabase();
if (!svc) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const signed = await svc.storage.from(ASSETS_BUCKET).createSignedUrl(path, 300);
if (signed.error || !signed.data?.signedUrl) {
return NextResponse.json(
{ error: signed.error?.message || 'No signed URL' },
{ status: 500 }
);
}
return NextResponse.redirect(signed.data.signedUrl);
}

View File

@@ -0,0 +1,300 @@
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';
const SELECT =
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at';
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
const ALL_STATUS = [
'propuesta',
'en_diseno',
'revision_interna',
'aprobado',
'rechazado',
'publicado',
] as const;
type Status = (typeof ALL_STATUS)[number];
const STATUS_SET = new Set<string>(ALL_STATUS);
// El cliente solo puede mover una pieza que esté en revisión interna,
// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida
// "publicado") son cosa del equipo de la agencia.
const CLIENT_ALLOWED_TRANSITIONS: Record<string, Status[]> = {
revision_interna: ['aprobado', 'rechazado'],
};
function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null;
const t = v.trim();
if (!t) return null;
return t.slice(0, max);
}
function slidePaths(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === 'string').slice(0, 10);
}
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);
const month = req.nextUrl.searchParams.get('month')?.trim() || null;
const isAgency = isAgencyRole(userRole(user));
if (ctx.portfolioWide) {
return NextResponse.json({ items: [], isAgency, warning: 'Selecciona un cliente en Contexto' });
}
if (!ctx.tenantId) {
return NextResponse.json({ items: [], isAgency });
}
let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&select=${SELECT}&order=month.asc,created_at.asc`;
if (month) q += `&month=eq.${encodeURIComponent(month)}`;
if (ctx.viaView) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ items: [], isAgency, warning: 'Service role required for superadmin view' },
{ status: 200 }
);
}
const r = await fetch(q, { headers, cache: 'no-store' });
if (!r.ok) {
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
}
return NextResponse.json({ items: await r.json(), isAgency });
}
let query = supabase
.from('creative_pipeline')
.select(SELECT)
.order('month', { ascending: true })
.order('created_at', { ascending: true });
if (month) query = query.eq('month', month);
const { data, error } = await query;
if (error) {
return NextResponse.json({ items: [], isAgency, warning: error.message });
}
return NextResponse.json({ items: data ?? [], isAgency });
}
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 });
}
const agency = isAgencyRole(userRole(user));
if (!agency) {
return NextResponse.json(
{ error: 'Solo el equipo de la agencia puede crear piezas' },
{ status: 403 }
);
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const title = str(body.title, 300);
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const format = str(body.format, 20);
const row = {
tenant_id: ctx.tenantId,
title,
external_ref: str(body.external_ref, 80),
format: format && FORMATS.has(format) ? format : 'post',
month: str(body.month, 7),
caption: str(body.caption, 4000),
notes: str(body.notes, 2000),
status: 'propuesta',
created_by: user.id,
};
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 });
}
export async function PATCH(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: 'Sin tenant' }, { status: 400 });
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const id = str(body.id, 80);
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
// Resolve current row first — status transitions are checked against it.
const curRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
{ headers: headers || undefined, cache: 'no-store' }
);
const curRows = curRes.ok ? await curRes.json() : [];
const current: string | undefined = curRows?.[0]?.status;
if (!current) {
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
}
const clean: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (typeof body.status === 'string' && STATUS_SET.has(body.status)) {
const next = body.status as Status;
if (agency) {
clean.status = next;
} else {
const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || [];
if (!allowed.includes(next)) {
return NextResponse.json(
{ error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` },
{ status: 403 }
);
}
clean.status = next;
}
if (next === 'aprobado') {
clean.approved_by = user.id;
clean.approved_at = new Date().toISOString();
}
}
// Resto de campos: solo agencia los edita.
if (agency) {
const title = str(body.title, 300);
if (title) clean.title = title;
const month = str(body.month, 7);
if (month) clean.month = month;
const format = str(body.format, 20);
if (format && FORMATS.has(format)) clean.format = format;
if ('caption' in body) clean.caption = str(body.caption, 4000);
if ('notes' in body) clean.notes = str(body.notes, 2000);
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
}
if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(clean),
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data });
}
export async function DELETE(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const agency = isAgencyRole(userRole(user));
if (!agency) {
return NextResponse.json({ error: 'Solo la agencia puede borrar' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId) {
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
}
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{ method: 'DELETE', headers }
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

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,
});
}

View File

@@ -0,0 +1,11 @@
/**
* Panel de Creatividades — pipeline editorial interno (Propuesta → Diseño →
* Revisión interna → Aprobación), visible también para el cliente.
*/
'use client';
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
export default function PanelClientePage() {
return <CreativePipelineBoard />;
}