diff --git a/src/app/api/connections/[id]/check/route.ts b/src/app/api/connections/[id]/check/route.ts new file mode 100644 index 0000000..55706d8 --- /dev/null +++ b/src/app/api/connections/[id]/check/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { clientForConnection } from "@/lib/git"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** Testet Erreichbarkeit und Token einer Verbindung und merkt sich das Ergebnis. */ +export async function POST(_request: Request, { params }: Params) { + const { id } = await params; + + const connection = await prisma.gitConnection.findUnique({ where: { id } }); + if (!connection) { + return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 }); + } + + const result = await clientForConnection(connection).check(); + + await prisma.gitConnection.update({ + where: { id }, + data: { + lastCheckedAt: new Date(), + lastCheckOk: result.ok, + lastCheckMessage: result.message, + username: result.user ?? connection.username, + }, + }); + + return NextResponse.json(result, { status: result.ok ? 200 : 502 }); +} diff --git a/src/app/api/connections/[id]/repos/route.ts b/src/app/api/connections/[id]/repos/route.ts new file mode 100644 index 0000000..041032d --- /dev/null +++ b/src/app/api/connections/[id]/repos/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { clientForConnection } from "@/lib/git"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** Listet Repositories, auf die der hinterlegte Token Zugriff hat. */ +export async function GET(request: Request, { params }: Params) { + const { id } = await params; + const query = new URL(request.url).searchParams.get("q") ?? undefined; + + const connection = await prisma.gitConnection.findUnique({ where: { id } }); + if (!connection) { + return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 }); + } + + try { + const repos = await clientForConnection(connection).listRepos(query); + return NextResponse.json({ repos }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/connections/[id]/route.ts b/src/app/api/connections/[id]/route.ts new file mode 100644 index 0000000..0193271 --- /dev/null +++ b/src/app/api/connections/[id]/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { encryptSecret, tokenHint } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { normalizeBaseUrl } from "@/lib/git/http"; +import { connectionSafeSelect } from "@/lib/projects"; +import { connectionUpdateSchema, formatZodError } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +const safeSelect = { + ...connectionSafeSelect, + _count: { select: { projects: true } }, +}; + +export async function PATCH(request: Request, { params }: Params) { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 }); + } + + const parsed = connectionUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const data = parsed.data; + const update: Record = {}; + if (data.name !== undefined) update.name = data.name; + if (data.provider !== undefined) update.provider = data.provider; + if (data.baseUrl !== undefined) update.baseUrl = normalizeBaseUrl(data.baseUrl); + if (data.username !== undefined) update.username = data.username; + + // token === null -> Token entfernen + // token === "" -> vom Schema zu null normalisiert + // token undefined -> unveraendert lassen + if (data.token !== undefined) { + if (data.token) { + update.tokenCipher = encryptSecret(data.token); + update.tokenHint = tokenHint(data.token); + } else { + update.tokenCipher = null; + update.tokenHint = null; + } + } + + try { + const connection = await prisma.gitConnection.update({ + where: { id }, + data: update, + select: safeSelect, + }); + return NextResponse.json({ connection }); + } catch { + return NextResponse.json({ error: "Verbindung nicht gefunden oder Name vergeben" }, { status: 404 }); + } +} + +export async function DELETE(_request: Request, { params }: Params) { + const { id } = await params; + try { + // Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert. + await prisma.gitConnection.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 }); + } +} diff --git a/src/app/api/connections/route.ts b/src/app/api/connections/route.ts new file mode 100644 index 0000000..f762ba1 --- /dev/null +++ b/src/app/api/connections/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { encryptSecret, tokenHint } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { normalizeBaseUrl } from "@/lib/git/http"; +import { connectionSafeSelect } from "@/lib/projects"; +import { connectionCreateSchema, formatZodError } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */ +const safeSelect = { + ...connectionSafeSelect, + _count: { select: { projects: true } }, +}; + +export async function GET() { + const connections = await prisma.gitConnection.findMany({ + select: safeSelect, + orderBy: { name: "asc" }, + }); + return NextResponse.json({ connections }); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 }); + } + + const parsed = connectionCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { name, provider, baseUrl, username, token } = parsed.data; + + const duplicate = await prisma.gitConnection.findUnique({ where: { name } }); + if (duplicate) { + return NextResponse.json( + { error: "Es gibt bereits eine Verbindung mit diesem Namen.", fields: { name: "Name bereits vergeben" } }, + { status: 409 }, + ); + } + + const connection = await prisma.gitConnection.create({ + data: { + name, + provider, + baseUrl: normalizeBaseUrl(baseUrl), + username: username ?? null, + tokenCipher: token ? encryptSecret(token) : null, + tokenHint: token ? tokenHint(token) : null, + }, + select: safeSelect, + }); + + return NextResponse.json({ connection }, { status: 201 }); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..68ff2fd --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +/** Liveness/Readiness-Endpoint – wird auch vom Docker-Healthcheck benutzt. */ +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1`; + return NextResponse.json({ + status: "ok", + database: "up", + time: new Date().toISOString(), + }); + } catch (error) { + return NextResponse.json( + { + status: "degraded", + database: "down", + error: error instanceof Error ? error.message : String(error), + }, + { status: 503 }, + ); + } +} diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts new file mode 100644 index 0000000..461af50 --- /dev/null +++ b/src/app/api/notes/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { formatZodError, noteUpdateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: Params) { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 }); + } + + const parsed = noteUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + try { + const note = await prisma.note.update({ + where: { id }, + data: { + ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), + ...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}), + ...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}), + }, + }); + return NextResponse.json({ note }); + } catch { + return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 }); + } +} + +export async function DELETE(_request: Request, { params }: Params) { + const { id } = await params; + try { + await prisma.note.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 }); + } +} diff --git a/src/app/api/projects/[id]/notes/route.ts b/src/app/api/projects/[id]/notes/route.ts new file mode 100644 index 0000000..815286f --- /dev/null +++ b/src/app/api/projects/[id]/notes/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { formatZodError, noteCreateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { id } = await params; + const notes = await prisma.note.findMany({ + where: { projectId: id }, + orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], + }); + return NextResponse.json({ notes }); +} + +export async function POST(request: Request, { params }: Params) { + const { id } = await params; + + const project = await prisma.project.findUnique({ where: { id }, select: { id: true } }); + if (!project) { + return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 }); + } + + const parsed = noteCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const note = await prisma.note.create({ + data: { + projectId: id, + title: parsed.data.title ?? null, + content: parsed.data.content, + pinned: parsed.data.pinned, + }, + }); + + // Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt. + await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } }); + + return NextResponse.json({ note }, { status: 201 }); +} diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..0ea838c --- /dev/null +++ b/src/app/api/projects/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { parseRepoUrl } from "@/lib/git"; +import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; +import { formatZodError, projectUpdateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { id } = await params; + const project = await prisma.project.findUnique({ + where: { id }, + include: projectDetailInclude, + }); + + if (!project) { + return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 }); + } + return NextResponse.json({ project }); +} + +export async function PATCH(request: Request, { params }: Params) { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 }); + } + + const parsed = projectUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const existing = await prisma.project.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 }); + } + + const data = parsed.data; + const update: Record = {}; + + if (data.name !== undefined && data.name !== existing.name) { + update.name = data.name; + update.slug = await uniqueSlug(data.name, id); + } + for (const key of ["summary", "description", "status", "priority", "progress", "accent", "tags", "favorite", "connectionId"] as const) { + if (data[key] !== undefined) update[key] = data[key]; + } + + if (data.repoUrl !== undefined) { + update.repoUrl = data.repoUrl; + const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null; + if (parsedUrl) { + update.repoOwner = data.repoOwner ?? parsedUrl.owner; + update.repoName = data.repoName ?? parsedUrl.name; + } else if (!data.repoUrl) { + update.repoOwner = null; + update.repoName = null; + } + } + if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner; + if (data.repoName !== undefined) update.repoName = data.repoName; + + const project = await prisma.project.update({ + where: { id }, + data: update, + include: projectListInclude, + }); + + return NextResponse.json({ project }); +} + +export async function DELETE(_request: Request, { params }: Params) { + const { id } = await params; + try { + await prisma.project.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 }); + } +} diff --git a/src/app/api/projects/[id]/sync/route.ts b/src/app/api/projects/[id]/sync/route.ts new file mode 100644 index 0000000..c2a39da --- /dev/null +++ b/src/app/api/projects/[id]/sync/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { projectDetailInclude } from "@/lib/projects"; +import { syncProject } from "@/lib/sync"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** Holt Branches, Tags, Commits und Releases beim Git-System ab. */ +export async function POST(_request: Request, { params }: Params) { + const { id } = await params; + const result = await syncProject(id); + + const project = await prisma.project.findUnique({ + where: { id }, + include: projectDetailInclude, + }); + + return NextResponse.json({ ...result, project }, { status: result.ok ? 200 : 502 }); +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..486ffb1 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { parseRepoUrl } from "@/lib/git"; +import { projectListInclude, uniqueSlug } from "@/lib/projects"; +import { formatZodError, projectCreateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const status = searchParams.get("status"); + const query = searchParams.get("q"); + + const projects = await prisma.project.findMany({ + where: { + ...(status ? { status: status as never } : {}), + ...(query + ? { + OR: [ + { name: { contains: query, mode: "insensitive" as const } }, + { summary: { contains: query, mode: "insensitive" as const } }, + { tags: { has: query.toLowerCase() } }, + ], + } + : {}), + }, + include: projectListInclude, + orderBy: [{ favorite: "desc" }, { updatedAt: "desc" }], + }); + + return NextResponse.json({ projects }); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 }); + } + + const parsed = projectCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const data = parsed.data; + + // Owner/Name aus der URL ableiten, falls nicht explizit angegeben. + let repoOwner = data.repoOwner ?? null; + let repoName = data.repoName ?? null; + if (data.repoUrl && (!repoOwner || !repoName)) { + const parsedUrl = parseRepoUrl(data.repoUrl); + if (parsedUrl) { + repoOwner = repoOwner ?? parsedUrl.owner; + repoName = repoName ?? parsedUrl.name; + } + } + + const project = await prisma.project.create({ + data: { + name: data.name, + slug: await uniqueSlug(data.name), + summary: data.summary ?? null, + description: data.description ?? null, + status: data.status, + priority: data.priority, + progress: data.progress, + accent: data.accent, + tags: data.tags, + favorite: data.favorite, + repoUrl: data.repoUrl ?? null, + repoOwner, + repoName, + connectionId: data.connectionId ?? null, + }, + include: projectListInclude, + }); + + return NextResponse.json({ project }, { status: 201 }); +}