Files
4ventures-client-portal/src/app/api/calendar/upload/route.ts
4Ventures 501f8203e8 Replace BPS Excel stub with multi-client calendar template.
Official download now generates a blank workbook with catalog autofill, notas_internas ignored on import, and the same template for every tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:38:38 +02:00

212 lines
5.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
parseJsonCalendar,
sheetsToPlans,
type ImportResult,
type SheetRows,
} from '@/lib/calendar-import';
import { buildOfficialCsv } from '@/lib/calendar-template';
import type { RrssMonthPlan } from '@/components/RrssMonthView';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const MAX_BYTES = 8 * 1024 * 1024;
/** @deprecated Prefer GET /api/calendar/template?format=csv */
export async function GET() {
return new NextResponse(buildOfficialCsv('CLIENTE'), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition':
'attachment; filename="plantilla-calendario-4ventures.csv"',
'Cache-Control': 'no-store',
},
});
}
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 4Ventures puede subir calendarios.' },
{ 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().catch(() => null);
const file = form?.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Adjunta un CSV, Excel o JSON.' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'El archivo supera 8 MB.' }, { status: 400 });
}
const name = file.name || 'calendario';
const buf = Buffer.from(await file.arrayBuffer());
const text = buf.toString('utf8').replace(/^\uFEFF/, '');
const yearHint = new Date().getFullYear();
let parsed: ImportResult;
const lower = name.toLowerCase();
if (lower.endsWith('.json') || text.trim().startsWith('{') || text.trim().startsWith('[')) {
const json = parseJsonCalendar(text, ctx.label);
if (!json || !json.plans.length) {
return NextResponse.json(
{ error: json?.warnings[0] || 'JSON no válido.' },
{ status: 400 }
);
}
parsed = json;
} else {
try {
const mod = await import('xlsx');
const XLSX = (mod as { default?: typeof import('xlsx') }).default ?? mod;
const wb = XLSX.read(buf, { type: 'buffer', cellDates: true, raw: false });
const sheets: { name: string; rows: SheetRows }[] = [];
for (const sheetName of wb.SheetNames) {
const ws = wb.Sheets[sheetName];
if (!ws) continue;
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(ws, {
header: 1,
defval: '',
raw: false,
blankrows: false,
}) as SheetRows;
sheets.push({ name: sheetName, rows });
}
parsed = sheetsToPlans(sheets, ctx.label, yearHint);
} catch (e) {
return NextResponse.json(
{
error:
e instanceof Error
? `No se pudo leer el Excel/CSV: ${e.message}`
: 'No se pudo leer el Excel/CSV',
},
{ status: 400 }
);
}
}
if (!parsed.plans.length) {
return NextResponse.json(
{
error: parsed.warnings[0] || 'No se leyeron piezas.',
sheets: parsed.sheets,
},
{ status: 400 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const saved: { month: string; pieces: number }[] = [];
try {
for (const plan of parsed.plans) {
await upsertPlan(base, headers, ctx.tenantId, plan);
saved.push({ month: plan.month, pieces: plan.pieces.length });
}
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Error al guardar' },
{ status: 500 }
);
}
return NextResponse.json({
ok: true,
tenant: ctx.label,
months: saved,
warnings: parsed.warnings,
sheets: parsed.sheets,
});
}
async function upsertPlan(
base: string,
headers: { apikey: string; Authorization: string },
tenantId: string,
plan: RrssMonthPlan
) {
const title = `RRSS ${plan.label}`;
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = existing.ok
? ((await existing.json()) as {
id: string;
title: string;
content: unknown;
}[])
: [];
for (const d of docs) {
let c = d.content as { month?: string } | string | null;
if (typeof c === 'string') {
try {
c = JSON.parse(c) as { month?: string };
} catch {
c = null;
}
}
const month = c && typeof c === 'object' ? c.month : null;
if (month === plan.month || d.title === title) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
}
}
const row = {
tenant_id: tenantId,
type: 'calendar',
title,
content: plan,
status: 'approved',
};
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!ins.ok) {
throw new Error(`No se pudo guardar ${plan.month}: ${(await ins.text()).slice(0, 280)}`);
}
}