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>
212 lines
5.9 KiB
TypeScript
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)}`);
|
|
}
|
|
}
|