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
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
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,
|
|
});
|
|
}
|