Files
4ventures-client-portal/src/app/api/creative-pipeline/upload/route.ts
4Ventures 4a1abd3318 feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 04:15:51 +02:00

183 lines
5.5 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';
import { syncPipelineAssetToDrive } from '@/lib/pipeline-drive';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export const maxDuration = 90;
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)
*
* También espeja el archivo en Drive del cliente:
* {carpeta-cliente}/Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
*/
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() as 'cover' | 'slide';
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 });
}
const curRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
itemId
)}&tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&select=id,title,format,caption,notes,scheduled_at,created_at,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 item = curRows[0] as {
id: string;
title?: string | null;
format?: string | null;
caption?: string | null;
notes?: string | null;
scheduled_at?: string | null;
created_at?: string | null;
slide_paths?: string[];
};
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(item.slide_paths)
? [...item.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();
const drive = await syncPipelineAssetToDrive({
tenantId: ctx.tenantId,
clientLabel: ctx.label,
item,
buffer: buf,
mimeType: mime,
ext,
slot: slot === 'slide' ? 'slide' : 'cover',
slideIndex: index,
});
return NextResponse.json({
item: Array.isArray(data) ? data[0] : data,
storage_path: storagePath,
drive: {
ok: drive.ok,
skipped: drive.skipped || false,
path: drive.drivePath || null,
fileName: drive.fileName || null,
fileId: drive.driveFileId || null,
warning: drive.warning || null,
},
});
}