Add CSV/Excel calendar upload that maps posts into the monthly RRSS view.
Agency can import editorial calendars per tenant; pieces land on each day with the same month grid and day zoom as existing plans. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
209
src/app/api/calendar/upload/route.ts
Normal file
209
src/app/api/calendar/upload/route.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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,
|
||||
SAMPLE_CSV,
|
||||
sheetsToPlans,
|
||||
type ImportResult,
|
||||
type SheetRows,
|
||||
} from '@/lib/calendar-import';
|
||||
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export async function GET() {
|
||||
return new NextResponse(SAMPLE_CSV, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="plantilla-calendario-4ventures.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user