Aktueller Stand

This commit is contained in:
2026-01-15 16:24:09 +01:00
parent 5d2630a02f
commit 46eae2a2a9
70 changed files with 7866 additions and 447 deletions

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { prisma } from "../../../../lib/prisma";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const DATA_DIR = path.join(process.cwd(), "prisma", "data");
const resolveLogoPath = (relativePath: string) => {
const absolutePath = path.join(DATA_DIR, relativePath);
if (!absolutePath.startsWith(DATA_DIR)) {
throw new Error("Ungültiger Pfad.");
}
return absolutePath;
};
const getLogoSettings = async () => {
const pathSetting = await prisma.setting.findUnique({
where: { key: "app_logo_path" }
});
const typeSetting = await prisma.setting.findUnique({
where: { key: "app_logo_type" }
});
if (!pathSetting?.value || !typeSetting?.value) {
return null;
}
return { path: pathSetting.value, type: typeSetting.value };
};
export async function GET() {
const settings = await getLogoSettings();
if (!settings) {
return NextResponse.json({ error: "Kein Logo vorhanden." }, { status: 404 });
}
try {
const absolutePath = resolveLogoPath(settings.path);
const file = await fs.readFile(absolutePath);
return new NextResponse(file, {
headers: {
"Content-Type": settings.type,
"Cache-Control": "no-store"
}
});
} catch {
return NextResponse.json({ error: "Logo konnte nicht geladen werden." }, { status: 404 });
}
}
export async function HEAD() {
const settings = await getLogoSettings();
if (!settings) {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(null, {
status: 200,
headers: {
"Content-Type": settings.type,
"Cache-Control": "no-store"
}
});
}

120
app/api/categories/route.ts Normal file
View File

@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
export async function GET() {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const categories = await prisma.category.findMany({
orderBy: { name: "asc" }
});
return NextResponse.json(categories);
}
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { name } = body || {};
if (!name) {
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
}
const existing = await prisma.category.findUnique({ where: { name } });
if (existing) {
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
}
const category = await prisma.category.create({
data: { name }
});
const views = await prisma.userView.findMany({
select: { id: true }
});
if (views.length > 0) {
await prisma.userViewCategory.createMany({
data: views.map((view) => ({
viewId: view.id,
categoryId: category.id
}))
});
}
return NextResponse.json(category, { status: 201 });
}
export async function PATCH(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { id, name } = body || {};
if (!id || !name) {
return NextResponse.json({ error: "ID und Name erforderlich." }, { status: 400 });
}
const trimmed = String(name).trim();
if (!trimmed) {
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
}
const existing = await prisma.category.findUnique({ where: { name: trimmed } });
if (existing && existing.id !== id) {
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
}
const category = await prisma.category.update({
where: { id },
data: { name: trimmed }
});
return NextResponse.json(category);
}
export async function DELETE(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID erforderlich." }, { status: 400 });
}
await prisma.event.updateMany({
where: { categoryId: id },
data: { categoryId: null }
});
await prisma.category.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -13,16 +13,76 @@ export async function PATCH(request: Request, context: { params: { id: string }
}
const body = await request.json();
const { status } = body || {};
const {
status,
title,
description,
location,
locationPlaceId,
locationLat,
locationLng,
startAt,
endAt,
categoryId
} = body || {};
if (!status || !["APPROVED", "REJECTED"].includes(status)) {
return NextResponse.json({ error: "Status ungueltig." }, { status: 400 });
if (status && ["APPROVED", "REJECTED"].includes(status)) {
const event = await prisma.event.update({
where: { id: context.params.id },
data: { status }
});
return NextResponse.json(event);
}
if (!title || !startAt || !categoryId) {
return NextResponse.json(
{ error: "Titel, Start und Kategorie sind erforderlich." },
{ status: 400 }
);
}
const startDate = new Date(startAt);
const endDate = endAt ? new Date(endAt) : null;
const event = await prisma.event.update({
where: { id: context.params.id },
data: { status }
data: {
title,
description: description || null,
location: location || null,
locationPlaceId: locationPlaceId || null,
locationLat: locationLat ? Number(locationLat) : null,
locationLng: locationLng ? Number(locationLng) : null,
startAt: startDate,
endAt: endDate,
category: { connect: { id: categoryId } }
}
});
return NextResponse.json(event);
}
export async function DELETE(
_request: Request,
context: { params: { id: string } }
) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await prisma.userViewItem.deleteMany({
where: { eventId: context.params.id }
});
await prisma.event.delete({
where: { id: context.params.id }
});
return NextResponse.json({ ok: true });
}

View File

@@ -25,7 +25,8 @@ export async function GET(request: Request) {
const events = await prisma.event.findMany({
where,
orderBy: { startAt: "asc" }
orderBy: { startAt: "asc" },
include: { category: true }
});
return NextResponse.json(events);
@@ -38,24 +39,68 @@ export async function POST(request: Request) {
}
const body = await request.json();
const { title, description, location, startAt, endAt } = body || {};
const {
title,
description,
location,
locationPlaceId,
locationLat,
locationLng,
startAt,
endAt,
categoryId
} = body || {};
if (!title || !startAt || !endAt) {
if (!title || !startAt) {
return NextResponse.json(
{ error: "Titel, Start und Ende sind erforderlich." },
{ error: "Titel und Start sind erforderlich." },
{ status: 400 }
);
}
if (!categoryId) {
return NextResponse.json(
{ error: "Kategorie ist erforderlich." },
{ status: 400 }
);
}
const startDate = new Date(startAt);
const endDate = endAt
? new Date(endAt)
: new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
const creatorEmail = session.user?.email || "";
const existing = await prisma.event.findFirst({
where: {
title,
startAt: startDate,
location: location || null,
categoryId,
createdBy: { email: creatorEmail }
}
});
if (existing) {
return NextResponse.json(
{ error: "Ein identischer Termin existiert bereits." },
{ status: 409 }
);
}
const event = await prisma.event.create({
data: {
title,
description: description || null,
location: location || null,
startAt: new Date(startAt),
endAt: new Date(endAt),
locationPlaceId: locationPlaceId || null,
locationLat: locationLat ? Number(locationLat) : null,
locationLng: locationLng ? Number(locationLng) : null,
startAt: startDate,
endAt: endDate,
status: isAdminSession(session) ? "APPROVED" : "PENDING",
createdBy: { connect: { email: session.user?.email || "" } }
createdBy: { connect: { email: creatorEmail } },
category: { connect: { id: categoryId } }
}
});

View File

@@ -8,7 +8,12 @@ export async function GET(
) {
const view = await prisma.userView.findUnique({
where: { token: context.params.token },
include: { items: { include: { event: true } }, user: true }
include: {
items: { include: { event: true } },
categories: true,
exclusions: true,
user: true
}
});
if (!view) {
@@ -20,17 +25,36 @@ export async function GET(
timezone: "Europe/Berlin"
});
view.items
const excludedIds = new Set(view.exclusions.map((item) => item.eventId));
const explicitEvents = view.items
.map((item) => item.event)
.filter((event) => event.status === "APPROVED")
.forEach((event) => {
.filter((event) => event.status === "APPROVED");
const categoryIds = view.categories.map((item) => item.categoryId);
const categoryEvents =
categoryIds.length > 0
? await prisma.event.findMany({
where: { categoryId: { in: categoryIds }, status: "APPROVED" }
})
: [];
const combined = [...explicitEvents, ...categoryEvents].filter(
(event, index, all) =>
all.findIndex((item) => item.id === event.id) === index &&
!excludedIds.has(event.id)
);
combined.forEach((event) => {
const start = event.startAt;
const end =
event.endAt || new Date(event.startAt.getTime() + 3 * 60 * 60 * 1000);
calendar.createEvent({
id: event.id,
summary: event.title,
description: event.description || undefined,
location: event.location || undefined,
start: event.startAt,
end: event.endAt
start,
end
});
});

View File

@@ -0,0 +1,143 @@
import { NextResponse } from "next/server";
import { parseICS } from "node-ical";
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
import { prisma } from "../../../../lib/prisma";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const asText = (value: unknown) => {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
return String(value).trim();
};
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Nur für Admins." }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file");
const categoryId = asText(formData.get("categoryId"));
if (!(file instanceof File)) {
return NextResponse.json(
{ error: "Bitte eine iCal-Datei hochladen." },
{ status: 400 }
);
}
if (!categoryId) {
return NextResponse.json(
{ error: "Bitte eine Kategorie auswählen." },
{ status: 400 }
);
}
const category = await prisma.category.findUnique({
where: { id: categoryId }
});
if (!category) {
return NextResponse.json(
{ error: "Kategorie nicht gefunden." },
{ status: 404 }
);
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "Datei ist zu groß (max. 5 MB)." },
{ status: 400 }
);
}
let parsed: Record<string, any>;
try {
const raw = await file.text();
parsed = parseICS(raw);
} catch (err) {
return NextResponse.json(
{ error: "iCal-Datei konnte nicht gelesen werden." },
{ status: 400 }
);
}
const entries = Object.values(parsed).filter(
(entry) => entry && entry.type === "VEVENT"
);
if (entries.length === 0) {
return NextResponse.json(
{ error: "Keine Termine in der iCal-Datei gefunden." },
{ status: 400 }
);
}
let created = 0;
let duplicates = 0;
let skipped = 0;
let recurringSkipped = 0;
const creatorEmail = session.user?.email || "";
for (const entry of entries) {
if (entry.rrule) {
recurringSkipped += 1;
continue;
}
const title = asText(entry.summary);
const start = entry.start instanceof Date ? entry.start : null;
if (!title || !start || Number.isNaN(start.getTime())) {
skipped += 1;
continue;
}
const end =
entry.end instanceof Date && !Number.isNaN(entry.end.getTime())
? entry.end
: new Date(start.getTime() + 3 * 60 * 60 * 1000);
const location = asText(entry.location) || null;
const description = asText(entry.description) || null;
const existing = await prisma.event.findFirst({
where: {
title,
startAt: start,
location,
categoryId
}
});
if (existing) {
duplicates += 1;
continue;
}
await prisma.event.create({
data: {
title,
description,
location,
startAt: start,
endAt: end,
status: "APPROVED",
createdBy: { connect: { email: creatorEmail } },
category: { connect: { id: categoryId } }
}
});
created += 1;
}
return NextResponse.json({
created,
duplicates,
skipped,
recurringSkipped
});
}

View File

@@ -0,0 +1,36 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
export async function POST(request: Request) {
const body = await request.json();
const { token, newPassword } = body || {};
if (!token || !newPassword) {
return NextResponse.json(
{ error: "Token und neues Passwort erforderlich." },
{ status: 400 }
);
}
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token }
});
if (!resetToken || resetToken.expiresAt < new Date()) {
return NextResponse.json({ error: "Token ungültig." }, { status: 400 });
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash }
});
await prisma.passwordResetToken.deleteMany({
where: { userId: resetToken.userId }
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,41 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { sendMail } from "../../../../lib/mailer";
export async function POST(request: Request) {
const body = await request.json();
const { email } = body || {};
if (!email) {
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } });
const token = randomUUID();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await prisma.passwordResetToken.create({
data: {
userId: user.id,
token,
expiresAt
}
});
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const resetUrl = `${baseUrl}/reset/confirm?token=${token}`;
await sendMail({
to: email,
subject: "Passwort zurücksetzen",
text: `Passwort zurücksetzen: ${resetUrl}`
});
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { requireSession } from "../../../../lib/auth-helpers";
export async function GET(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const input = searchParams.get("input") || "";
const countries = searchParams.get("countries") || "de,fr,ch,at";
const countryList = countries
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
.slice(0, 5);
const countryParam = countryList.join(",");
if (input.trim().length < 3) {
return NextResponse.json({ predictions: [] });
}
const apiKeySetting = await prisma.setting.findUnique({
where: { key: "google_places_api_key" }
});
const providerSetting = await prisma.setting.findUnique({
where: { key: "geocoding_provider" }
});
const provider =
providerSetting?.value || (apiKeySetting?.value ? "google" : "osm");
if (provider === "google") {
if (!apiKeySetting?.value) {
return NextResponse.json({ predictions: [] });
}
const apiUrl = new URL("https://maps.googleapis.com/maps/api/place/autocomplete/json");
apiUrl.searchParams.set("input", input);
apiUrl.searchParams.set("key", apiKeySetting.value);
apiUrl.searchParams.set("language", "de");
apiUrl.searchParams.set("types", "geocode");
if (countryParam) {
apiUrl.searchParams.set("components", `country:${countryParam}`);
}
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return NextResponse.json({ predictions: [] });
}
const payload = await response.json();
return NextResponse.json({ predictions: payload.predictions || [] });
}
const apiUrl = new URL("https://nominatim.openstreetmap.org/search");
apiUrl.searchParams.set("format", "jsonv2");
apiUrl.searchParams.set("q", input);
apiUrl.searchParams.set("addressdetails", "1");
apiUrl.searchParams.set("limit", "5");
apiUrl.searchParams.set("accept-language", "de");
if (countryParam) {
apiUrl.searchParams.set("countrycodes", countryParam);
}
const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0";
const response = await fetch(apiUrl.toString(), {
headers: { "User-Agent": userAgent }
});
if (!response.ok) {
return NextResponse.json({ predictions: [] });
}
const payload = await response.json();
const predictions = Array.isArray(payload)
? payload.map((item: any) => ({
description: item.display_name,
place_id: String(item.place_id)
}))
: [];
return NextResponse.json({ predictions });
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { requireSession } from "../../../../lib/auth-helpers";
export async function GET(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const placeId = searchParams.get("placeId") || "";
if (!placeId) {
return NextResponse.json({ error: "PlaceId erforderlich." }, { status: 400 });
}
const apiKeySetting = await prisma.setting.findUnique({
where: { key: "google_places_api_key" }
});
const providerSetting = await prisma.setting.findUnique({
where: { key: "geocoding_provider" }
});
const provider =
providerSetting?.value || (apiKeySetting?.value ? "google" : "osm");
if (provider === "google") {
if (!apiKeySetting?.value) {
return NextResponse.json({ error: "API-Key fehlt." }, { status: 400 });
}
const apiUrl = new URL("https://maps.googleapis.com/maps/api/place/details/json");
apiUrl.searchParams.set("place_id", placeId);
apiUrl.searchParams.set("fields", "geometry/location");
apiUrl.searchParams.set("key", apiKeySetting.value);
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
}
const payload = await response.json();
const location = payload?.result?.geometry?.location;
return NextResponse.json({
lat: location?.lat ?? null,
lng: location?.lng ?? null
});
}
const apiUrl = new URL("https://nominatim.openstreetmap.org/details");
apiUrl.searchParams.set("place_id", placeId);
apiUrl.searchParams.set("format", "json");
const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0";
const response = await fetch(apiUrl.toString(), {
headers: { "User-Agent": userAgent }
});
if (!response.ok) {
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
}
const payload = await response.json();
const coords = payload?.centroid?.coordinates;
const lat = Array.isArray(coords) ? Number(coords[1]) : Number(payload?.lat);
const lng = Array.isArray(coords) ? Number(coords[0]) : Number(payload?.lon);
return NextResponse.json({
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null
});
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { requireSession } from "../../../../lib/auth-helpers";
export async function GET(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const lat = searchParams.get("lat");
const lng = searchParams.get("lng");
if (!lat || !lng) {
return NextResponse.json({ error: "Koordinaten erforderlich." }, { status: 400 });
}
const apiKeySetting = await prisma.setting.findUnique({
where: { key: "google_places_api_key" }
});
const providerSetting = await prisma.setting.findUnique({
where: { key: "geocoding_provider" }
});
const provider =
providerSetting?.value || (apiKeySetting?.value ? "google" : "osm");
if (provider === "google") {
if (!apiKeySetting?.value) {
return NextResponse.json({ error: "API-Key fehlt." }, { status: 400 });
}
const apiUrl = new URL("https://maps.googleapis.com/maps/api/geocode/json");
apiUrl.searchParams.set("latlng", `${lat},${lng}`);
apiUrl.searchParams.set("key", apiKeySetting.value);
apiUrl.searchParams.set("language", "de");
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
}
const payload = await response.json();
const result = payload?.results?.[0];
return NextResponse.json({
label: result?.formatted_address || null,
placeId: result?.place_id || null
});
}
const apiUrl = new URL("https://nominatim.openstreetmap.org/reverse");
apiUrl.searchParams.set("format", "jsonv2");
apiUrl.searchParams.set("lat", lat);
apiUrl.searchParams.set("lon", lng);
apiUrl.searchParams.set("addressdetails", "1");
const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0";
const response = await fetch(apiUrl.toString(), {
headers: { "User-Agent": userAgent }
});
if (!response.ok) {
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
}
const payload = await response.json();
return NextResponse.json({
label: payload?.display_name || null,
placeId: payload?.place_id ? String(payload.place_id) : null
});
}

91
app/api/profile/route.ts Normal file
View File

@@ -0,0 +1,91 @@
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { requireSession } from "../../../lib/auth-helpers";
import { sendMail } from "../../../lib/mailer";
export async function PATCH(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { currentPassword, newPassword, newEmail } = body || {};
const normalizedEmail = newEmail ? String(newEmail).trim().toLowerCase() : "";
if (!currentPassword) {
return NextResponse.json(
{ error: "Aktuelles Passwort erforderlich." },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { email: session.user?.email || "" }
});
if (!user) {
return NextResponse.json({ error: "User nicht gefunden." }, { status: 404 });
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Passwort ungültig." }, { status: 401 });
}
const data: { email?: string; passwordHash?: string; emailVerified?: boolean } = {};
if (normalizedEmail && normalizedEmail !== user.email) {
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existing) {
return NextResponse.json(
{ error: "E-Mail bereits vergeben." },
{ status: 409 }
);
}
data.email = normalizedEmail;
data.emailVerified = false;
}
if (newPassword) {
data.passwordHash = await bcrypt.hash(newPassword, 10);
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: "Keine Änderungen." }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id: user.id },
data
});
if (data.email) {
const token = crypto.randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.create({
data: {
identifier: data.email,
token,
expires
}
});
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
await sendMail({
to: data.email,
subject: "E-Mail verifizieren",
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
});
}
return NextResponse.json({
id: updated.id,
email: updated.email,
changedEmail: Boolean(data.email),
changedPassword: Boolean(data.passwordHash)
});
}

View File

@@ -1,30 +1,89 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { prisma } from "../../../lib/prisma";
import { isAdminEmail } from "../../../lib/auth";
import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
import { sendMail } from "../../../lib/mailer";
export async function POST(request: Request) {
const registrationSetting = await prisma.setting.findUnique({
where: { key: "registration_enabled" }
});
if (registrationSetting?.value === "false") {
return NextResponse.json(
{ error: "Registrierung ist derzeit deaktiviert." },
{ status: 403 }
);
}
const body = await request.json();
const { email, name, password } = body || {};
const normalizedEmail = String(email || "").trim().toLowerCase();
if (!email || !password) {
if (!normalizedEmail || !password) {
return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email } });
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existing) {
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 10);
const superAdmin = isSuperAdminEmail(normalizedEmail);
const admin = isAdminEmail(normalizedEmail) || superAdmin;
const user = await prisma.user.create({
data: {
email,
email: normalizedEmail,
name: name || null,
passwordHash,
role: isAdminEmail(email) ? "ADMIN" : "USER"
role: superAdmin ? "SUPERADMIN" : admin ? "ADMIN" : "USER",
status: admin ? "ACTIVE" : "PENDING",
emailVerified: admin
}
});
const categories = await prisma.category.findMany({
select: { id: true }
});
const view = await prisma.userView.create({
data: {
name: "Meine Ansicht",
token: randomUUID(),
user: { connect: { id: user.id } }
}
});
if (categories.length > 0) {
await prisma.userViewCategory.createMany({
data: categories.map((category) => ({
viewId: view.id,
categoryId: category.id
}))
});
}
if (!admin) {
const token = randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.create({
data: {
identifier: normalizedEmail,
token,
expires
}
});
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
await sendMail({
to: normalizedEmail,
subject: "E-Mail verifizieren",
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
});
}
return NextResponse.json({ id: user.id, email: user.email });
}

View File

@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
export async function GET() {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const apiKeySetting = await prisma.setting.findUnique({
where: { key: "google_places_api_key" }
});
const providerSetting = await prisma.setting.findUnique({
where: { key: "geocoding_provider" }
});
const registrationSetting = await prisma.setting.findUnique({
where: { key: "registration_enabled" }
});
const apiKey = apiKeySetting?.value || "";
const provider =
providerSetting?.value || (apiKey ? "google" : "osm");
const registrationEnabled = registrationSetting?.value !== "false";
return NextResponse.json({ apiKey, provider, registrationEnabled });
}
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { apiKey, provider, registrationEnabled } = body || {};
if (!provider || !["google", "osm"].includes(provider)) {
return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 });
}
if (provider === "google" && !apiKey) {
return NextResponse.json({ error: "API-Key erforderlich." }, { status: 400 });
}
const apiKeyValue = provider === "google" ? apiKey : "";
const apiKeySetting = await prisma.setting.upsert({
where: { key: "google_places_api_key" },
update: { value: apiKeyValue },
create: { key: "google_places_api_key", value: apiKeyValue }
});
const providerSetting = await prisma.setting.upsert({
where: { key: "geocoding_provider" },
update: { value: provider },
create: { key: "geocoding_provider", value: provider }
});
const registrationValue = registrationEnabled === false ? "false" : "true";
await prisma.setting.upsert({
where: { key: "registration_enabled" },
update: { value: registrationValue },
create: { key: "registration_enabled", value: registrationValue }
});
return NextResponse.json({
apiKey: apiKeySetting.value,
provider: providerSetting.value,
registrationEnabled: registrationValue !== "false"
});
}

View File

@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { prisma } from "../../../../lib/prisma";
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const DATA_DIR = path.join(process.cwd(), "prisma", "data");
const UPLOADS_DIR = path.join(DATA_DIR, "uploads");
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/svg+xml": "svg"
};
const resolveLogoPath = (relativePath: string) => {
const absolutePath = path.join(DATA_DIR, relativePath);
if (!absolutePath.startsWith(DATA_DIR)) {
throw new Error("Ungültiger Pfad.");
}
return absolutePath;
};
const getLogoSetting = async () =>
prisma.setting.findUnique({ where: { key: "app_logo_path" } });
const getLogoTypeSetting = async () =>
prisma.setting.findUnique({ where: { key: "app_logo_type" } });
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return NextResponse.json({ error: "Datei fehlt." }, { status: 400 });
}
const extension = MIME_TO_EXT[file.type];
if (!extension) {
return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 });
}
await fs.mkdir(UPLOADS_DIR, { recursive: true });
const previousSetting = await getLogoSetting();
const previousTypeSetting = await getLogoTypeSetting();
const filename = `app-logo.${extension}`;
const relativePath = path.join("uploads", filename);
const absolutePath = resolveLogoPath(relativePath);
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(absolutePath, buffer);
if (previousSetting?.value && previousSetting.value !== relativePath) {
try {
await fs.unlink(resolveLogoPath(previousSetting.value));
} catch {
// ignore missing old file
}
}
await prisma.setting.upsert({
where: { key: "app_logo_path" },
update: { value: relativePath },
create: { key: "app_logo_path", value: relativePath }
});
await prisma.setting.upsert({
where: { key: "app_logo_type" },
update: { value: file.type },
create: { key: "app_logo_type", value: file.type }
});
return NextResponse.json({ ok: true });
}
export async function DELETE() {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const logoSetting = await getLogoSetting();
const typeSetting = await getLogoTypeSetting();
if (logoSetting?.value) {
try {
await fs.unlink(resolveLogoPath(logoSetting.value));
} catch {
// ignore missing file
}
}
if (logoSetting) {
await prisma.setting.delete({ where: { key: "app_logo_path" } });
}
if (typeSetting) {
await prisma.setting.delete({ where: { key: "app_logo_type" } });
}
return NextResponse.json({ ok: true });
}

303
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,303 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "../../../lib/prisma";
import {
isAdminSession,
isSuperAdminSession,
requireSession
} from "../../../lib/auth-helpers";
export async function GET(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const users = await prisma.user.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
status: true,
role: true,
emailVerified: true,
createdAt: true
}
});
if (!isSuperAdminSession(session)) {
return NextResponse.json(users);
}
const emails = users.map((user) => user.email).filter(Boolean);
const attempts = emails.length
? await prisma.loginAttempt.findMany({
where: { email: { in: emails } }
})
: [];
const stats = attempts.reduce<Record<string, {
attempts: number;
lastAttempt: Date | null;
lockedUntil: Date | null;
}>>((acc, attempt) => {
const current = acc[attempt.email] || {
attempts: 0,
lastAttempt: null,
lockedUntil: null
};
current.attempts += attempt.attempts;
if (!current.lastAttempt || attempt.lastAttempt > current.lastAttempt) {
current.lastAttempt = attempt.lastAttempt;
}
if (!current.lockedUntil || (attempt.lockedUntil && attempt.lockedUntil > current.lockedUntil)) {
current.lockedUntil = attempt.lockedUntil;
}
acc[attempt.email] = current;
return acc;
}, {});
const enriched = users.map((user) => ({
...user,
loginStats: stats[user.email] || {
attempts: 0,
lastAttempt: null,
lockedUntil: null
}
}));
return NextResponse.json(enriched);
}
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const {
email,
name,
password,
role,
status,
emailVerified
} = body || {};
if (!email || !password) {
return NextResponse.json(
{ error: "E-Mail und Passwort sind erforderlich." },
{ status: 400 }
);
}
const normalizedEmail = String(email).trim().toLowerCase();
const allowedRoles = ["USER", "ADMIN", "SUPERADMIN"];
const allowedStatuses = ["ACTIVE", "PENDING", "DISABLED"];
const isSuperAdmin = isSuperAdminSession(session);
const nextRole = isSuperAdmin && allowedRoles.includes(role) ? role : "USER";
const nextStatus = allowedStatuses.includes(status) ? status : "PENDING";
if (!normalizedEmail) {
return NextResponse.json({ error: "Ungültige E-Mail." }, { status: 400 });
}
const passwordHash = await bcrypt.hash(String(password), 10);
try {
const user = await prisma.user.create({
data: {
email: normalizedEmail,
name: name ? String(name).trim() : null,
passwordHash,
role: nextRole,
status: nextStatus,
emailVerified: Boolean(emailVerified)
},
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
emailVerified: true,
createdAt: true
}
});
return NextResponse.json(user, { status: 201 });
} catch (err) {
return NextResponse.json(
{ error: "Benutzer konnte nicht angelegt werden." },
{ status: 400 }
);
}
}
export async function PATCH(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const {
userId,
status,
role,
name,
email,
password,
emailVerified,
resetLoginAttempts
} = body || {};
if (!userId) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const target = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
});
if (!target) {
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
}
if (resetLoginAttempts) {
if (!isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const userRecord = await prisma.user.findUnique({
where: { id: userId },
select: { email: true }
});
if (userRecord?.email) {
await prisma.loginAttempt.deleteMany({ where: { email: userRecord.email } });
}
return NextResponse.json({ ok: true });
}
if (target.role === "SUPERADMIN" && !isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const allowedStatuses = ["ACTIVE", "PENDING", "DISABLED"];
const data: Record<string, any> = {};
if (status) {
if (!allowedStatuses.includes(status)) {
return NextResponse.json({ error: "Ungültiger Status." }, { status: 400 });
}
data.status = status;
}
if (name !== undefined) {
data.name = name ? String(name).trim() : null;
}
if (email) {
data.email = String(email).trim().toLowerCase();
}
if (emailVerified !== undefined) {
data.emailVerified = Boolean(emailVerified);
}
if (password) {
data.passwordHash = await bcrypt.hash(String(password), 10);
}
if (role) {
if (!isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!["USER", "ADMIN", "SUPERADMIN"].includes(role)) {
return NextResponse.json({ error: "Ungültige Rolle." }, { status: 400 });
}
data.role = role;
}
const user = await prisma.user.update({
where: { id: userId },
data,
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
emailVerified: true,
createdAt: true
}
});
return NextResponse.json({ id: user.id, status: user.status });
}
export async function DELETE(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const userId = searchParams.get("id");
if (!userId) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
if (session.user?.id === userId) {
return NextResponse.json(
{ error: "Eigenes Konto kann nicht gelöscht werden." },
{ status: 400 }
);
}
const target = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
});
if (!target) {
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
}
if (target.role !== "USER" && !isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await prisma.session.deleteMany({ where: { userId } });
await prisma.account.deleteMany({ where: { userId } });
await prisma.user.update({
where: { id: userId },
data: { status: "DISABLED", emailVerified: false }
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
export async function POST(request: Request) {
const body = await request.json();
const { token } = body || {};
if (!token) {
return NextResponse.json({ error: "Token erforderlich." }, { status: 400 });
}
const record = await prisma.verificationToken.findUnique({
where: { token }
});
if (!record || record.expires < new Date()) {
return NextResponse.json({ error: "Token ungültig." }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email: record.identifier }
});
if (!user) {
return NextResponse.json({ error: "User nicht gefunden." }, { status: 404 });
}
await prisma.user.update({
where: { id: user.id },
data: { emailVerified: true }
});
await prisma.verificationToken.deleteMany({
where: { identifier: record.identifier }
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,44 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { sendMail } from "../../../../lib/mailer";
export async function POST(request: Request) {
const body = await request.json();
const { email } = body || {};
if (!email) {
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ ok: true });
}
if (user.emailVerified) {
return NextResponse.json({ ok: true });
}
await prisma.verificationToken.deleteMany({ where: { identifier: email } });
const token = randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.create({
data: {
identifier: email,
token,
expires
}
});
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
await sendMail({
to: email,
subject: "E-Mail verifizieren",
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
import { requireSession } from "../../../../../lib/auth-helpers";
async function ensureOwner(viewId: string, email: string) {
const view = await prisma.userView.findFirst({
where: { id: viewId, user: { email } }
});
return view;
}
export async function POST(request: Request, context: { params: { id: string } }) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = session.user?.email || "";
const view = await ensureOwner(context.params.id, email);
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const { categoryId } = body || {};
if (!categoryId) {
return NextResponse.json({ error: "Kategorie erforderlich." }, { status: 400 });
}
await prisma.userViewCategory.upsert({
where: { viewId_categoryId: { viewId: view.id, categoryId } },
update: {},
create: { viewId: view.id, categoryId }
});
return NextResponse.json({ ok: true }, { status: 201 });
}
export async function DELETE(
request: Request,
context: { params: { id: string } }
) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = session.user?.email || "";
const view = await ensureOwner(context.params.id, email);
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const { categoryId } = body || {};
if (!categoryId) {
return NextResponse.json({ error: "Kategorie erforderlich." }, { status: 400 });
}
await prisma.userViewCategory.deleteMany({
where: { viewId: view.id, categoryId }
});
return NextResponse.json({ ok: true });
}

View File

@@ -27,8 +27,34 @@ export async function POST(request: Request, context: { params: { id: string } }
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
}
await prisma.userViewItem.create({
data: { viewId: view.id, eventId }
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { categoryId: true }
});
if (!event) {
return NextResponse.json({ error: "Event nicht gefunden." }, { status: 404 });
}
await prisma.userViewExclusion.deleteMany({
where: { viewId: view.id, eventId }
});
if (event.categoryId) {
const subscribed = await prisma.userViewCategory.findUnique({
where: {
viewId_categoryId: { viewId: view.id, categoryId: event.categoryId }
}
});
if (subscribed) {
return NextResponse.json({ ok: true }, { status: 201 });
}
}
await prisma.userViewItem.upsert({
where: { viewId_eventId: { viewId: view.id, eventId } },
update: {},
create: { viewId: view.id, eventId }
});
return NextResponse.json({ ok: true }, { status: 201 });
@@ -52,6 +78,31 @@ export async function DELETE(request: Request, context: { params: { id: string }
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
}
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { categoryId: true }
});
if (!event) {
return NextResponse.json({ error: "Event nicht gefunden." }, { status: 404 });
}
if (event.categoryId) {
const subscribed = await prisma.userViewCategory.findUnique({
where: {
viewId_categoryId: { viewId: view.id, categoryId: event.categoryId }
}
});
if (subscribed) {
await prisma.userViewExclusion.upsert({
where: { viewId_eventId: { viewId: view.id, eventId } },
update: {},
create: { viewId: view.id, eventId }
});
return NextResponse.json({ ok: true });
}
}
await prisma.userViewItem.deleteMany({
where: { viewId: view.id, eventId }
});

View File

@@ -0,0 +1,27 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
import { requireSession } from "../../../../../lib/auth-helpers";
export async function POST() {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = session.user?.email || "";
const view = await prisma.userView.findFirst({
where: { user: { email } }
});
if (!view) {
return NextResponse.json({ error: "Keine Ansicht gefunden." }, { status: 404 });
}
const updated = await prisma.userView.update({
where: { id: view.id },
data: { token: randomUUID() }
});
return NextResponse.json({ id: updated.id, token: updated.token });
}

View File

@@ -0,0 +1,57 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { requireSession } from "../../../../lib/auth-helpers";
export async function GET() {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = session.user?.email || "";
const existing = await prisma.userView.findFirst({
where: { user: { email } },
include: {
items: { include: { event: true } },
categories: { include: { category: true } },
exclusions: true
}
});
if (existing) {
return NextResponse.json(existing);
}
const view = await prisma.userView.create({
data: {
name: "Meine Ansicht",
token: randomUUID(),
user: { connect: { email } }
}
});
const categories = await prisma.category.findMany({
select: { id: true }
});
if (categories.length > 0) {
await prisma.userViewCategory.createMany({
data: categories.map((category) => ({
viewId: view.id,
categoryId: category.id
}))
});
}
const hydrated = await prisma.userView.findUnique({
where: { id: view.id },
include: {
items: { include: { event: true } },
categories: { include: { category: true } },
exclusions: true
}
});
return NextResponse.json(hydrated, { status: 201 });
}

View File

@@ -11,7 +11,11 @@ export async function GET() {
const views = await prisma.userView.findMany({
where: { user: { email: session.user?.email || "" } },
include: { items: { include: { event: true } } },
include: {
items: { include: { event: true } },
categories: { include: { category: true } },
exclusions: true
},
orderBy: { createdAt: "desc" }
});