Files
nebula/src/app/api/projects/import/route.ts
T
florian.wildeandClaude Opus 5 79c4bd1967 feat(export): Aufgaben in Export, Import und Sicherung aufnehmen
Aufgaben gehen mit - JSON-Export, Import und Instanz-Sicherung. Beim Import
wird die Position aus der Datei uebernommen, notfalls die Reihenfolge in ihr.
Wie bei den Notizen werden Aufgaben beim Ersetzen ausgetauscht statt
zusammengefuehrt, damit ein erneuter Import keine Dubletten erzeugt.

Der Markdown-Export bekommt einen Abschnitt "Aufgaben", nach Spalten
gegliedert und als Checkliste geschrieben - so bleibt der Stand auch
ausserhalb von Nebula lesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00

241 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import {
IMPORT_MAX_BODY_BYTES,
clientIp,
jsonError,
readJsonBody,
tooManyRequests,
} from "@/lib/security/request";
import { slugify } from "@/lib/utils";
export const dynamic = "force-dynamic";
const requestSchema = z.object({
data: z.unknown(),
/**
* skip – vorhandene Projekte unveraendert lassen
* rename – zusaetzlich anlegen, mit freiem Slug
* overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht)
*/
strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"),
/** Nur pruefen und berichten, nichts schreiben. */
dryRun: z.boolean().default(true),
});
type Action = "create" | "rename" | "overwrite" | "skip" | "error";
interface ItemResult {
name: string;
slug: string;
action: Action;
/** Bei "rename" der tatsaechlich vergebene Slug. */
newSlug?: string;
notes: number;
/** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */
connection: "matched" | "missing" | "none";
connectionName?: string;
message?: string;
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`import:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES);
if (!body.ok) return body.response;
const outer = requestSchema.safeParse(body.data);
if (!outer.success) return jsonError("Ungueltige Anfrage.", 422);
// Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas
// damit passiert.
const parsed = exportFileSchema.safeParse(outer.data.data);
if (!parsed.success) {
const first = parsed.error.issues[0];
return NextResponse.json(
{
error:
"Die Datei passt nicht zum erwarteten Format. " +
(first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""),
},
{ status: 422 },
);
}
const { strategy, dryRun } = outer.data;
const file = parsed.data;
// Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen.
const connections = await prisma.gitConnection.findMany({
where: { ownerId: auth.user.id },
select: { id: true, name: true, provider: true, baseUrl: true },
});
function matchConnection(described: ExportedProject["connection"]) {
if (!described) return null;
const byName = connections.find(
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
);
if (byName) return byName;
return (
connections.find(
(candidate) =>
candidate.provider === described.provider &&
candidate.baseUrl.replace(/\/+$/, "").toLowerCase() ===
described.baseUrl.replace(/\/+$/, "").toLowerCase(),
) ?? null
);
}
const results: ItemResult[] = [];
for (const incoming of file.projects) {
const slug = incoming.slug || slugify(incoming.name) || "projekt";
const connection = matchConnection(incoming.connection);
const connectionState: ItemResult["connection"] = incoming.connection
? connection
? "matched"
: "missing"
: "none";
const existing = await prisma.project.findFirst({
where: { slug, ownerId: auth.user.id },
select: { id: true },
});
const base: ItemResult = {
name: incoming.name,
slug,
action: "create",
notes: incoming.notes.length,
connection: connectionState,
connectionName: incoming.connection?.name,
};
if (existing && strategy === "skip") {
results.push({ ...base, action: "skip", message: "Slug bereits vergeben." });
continue;
}
const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite";
if (dryRun) {
results.push({
...base,
action,
...(action === "rename" ? { newSlug: `${slug}-…` } : {}),
...(action === "overwrite"
? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." }
: {}),
});
continue;
}
const data = {
name: incoming.name,
summary: incoming.summary ?? null,
description: incoming.description ?? null,
status: incoming.status,
priority: incoming.priority,
progress: incoming.progress,
accent: incoming.accent,
tags: incoming.tags,
favorite: incoming.favorite,
autoSync: incoming.autoSync,
repoUrl: incoming.repoUrl ?? null,
repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? null,
progressFromTasks: incoming.progressFromTasks,
};
const tasks = incoming.tasks.map((task, index) => ({
title: task.title,
description: task.description ?? null,
status: task.status,
// Position aus der Datei uebernehmen, notfalls die Reihenfolge in ihr.
position: task.position ?? index,
dueDate: task.dueDate && !Number.isNaN(Date.parse(task.dueDate))
? new Date(task.dueDate)
: null,
labels: task.labels,
doneAt: task.status === "DONE" ? new Date() : null,
...(task.createdAt && !Number.isNaN(Date.parse(task.createdAt))
? { createdAt: new Date(task.createdAt) }
: {}),
}));
const notes = incoming.notes.map((note) => ({
title: note.title ?? null,
content: note.content,
pinned: note.pinned ?? false,
...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt))
? { createdAt: new Date(note.createdAt) }
: {}),
}));
try {
if (existing && action === "overwrite") {
// Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein
// erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({
where: { id: existing.id },
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}),
]);
results.push({ ...base, action: "overwrite" });
} else {
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
await prisma.project.create({
data: {
...data,
ownerId: auth.user.id,
slug: finalSlug,
notes: { create: notes },
tasks: { create: tasks },
},
});
results.push({
...base,
action: existing ? "rename" : "create",
...(existing ? { newSlug: finalSlug } : {}),
});
}
} catch (error) {
results.push({
...base,
action: "error",
message: error instanceof Error ? error.message : String(error),
});
}
}
const summary = {
total: results.length,
created: results.filter((entry) => entry.action === "create").length,
renamed: results.filter((entry) => entry.action === "rename").length,
overwritten: results.filter((entry) => entry.action === "overwrite").length,
skipped: results.filter((entry) => entry.action === "skip").length,
errors: results.filter((entry) => entry.action === "error").length,
missingConnections: results.filter((entry) => entry.connection === "missing").length,
};
return NextResponse.json({ dryRun, strategy, summary, results });
}