Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.

Made-with: Cursor
This commit is contained in:
lo
2026-04-13 23:17:07 +02:00
commit d3367f0046
66 changed files with 3641 additions and 0 deletions

54
lib/auth-options.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export const authOptions: NextAuthOptions = {
trustHost: true,
session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 14 },
pages: { signIn: "/login" },
providers: [
CredentialsProvider({
name: "E-Mail",
credentials: {
email: { label: "E-Mail", type: "email" },
password: { label: "Passwort", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email?.trim().toLowerCase();
const password = credentials?.password;
if (!email || !password) return null;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = (token.role as string) ?? "LEARNER";
}
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};

50
lib/certificates.ts Normal file
View File

@@ -0,0 +1,50 @@
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { getUserCourseProgress } from "@/lib/course-progress";
function makeCode() {
return `FA-${randomBytes(5).toString("hex").toUpperCase()}`;
}
export async function syncCertificateForCourse(userId: string, courseId: string) {
const { total, completed } = await getUserCourseProgress(userId, courseId);
const existing = await prisma.certificate.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (total === 0 || completed < total) {
if (existing) {
await prisma.certificate.delete({ where: { id: existing.id } });
}
return { issued: false as const };
}
if (existing) {
return { issued: true as const, code: existing.code };
}
let code = makeCode();
for (let i = 0; i < 8; i++) {
const clash = await prisma.certificate.findUnique({ where: { code } });
if (!clash) break;
code = makeCode();
}
await prisma.certificate.create({
data: { userId, courseId, code },
});
return { issued: true as const, code };
}
export async function clearCourseProgress(userId: string, courseId: string) {
const lessons = await prisma.lesson.findMany({
where: { module: { courseId } },
select: { id: true },
});
await prisma.lessonProgress.deleteMany({
where: { userId, lessonId: { in: lessons.map((l) => l.id) } },
});
await prisma.certificate.deleteMany({ where: { userId, courseId } });
}

28
lib/course-progress.ts Normal file
View File

@@ -0,0 +1,28 @@
import { prisma } from "@/lib/prisma";
export async function getCourseLessonStats(courseId: string) {
const lessons = await prisma.lesson.count({
where: { published: true, module: { courseId } },
});
return { totalLessons: lessons };
}
export async function getUserCourseProgress(userId: string, courseId: string) {
const lessons = await prisma.lesson.findMany({
where: { published: true, module: { courseId } },
select: { id: true },
});
const total = lessons.length;
if (total === 0) return { total: 0, completed: 0, percent: 0 };
const completed = await prisma.lessonProgress.count({
where: {
userId,
completedAt: { not: null },
lessonId: { in: lessons.map((l) => l.id) },
},
});
const percent = Math.round((completed / total) * 100);
return { total, completed, percent };
}

68
lib/course-queries.ts Normal file
View File

@@ -0,0 +1,68 @@
import { prisma } from "@/lib/prisma";
export async function listPublishedCourses() {
return prisma.course.findMany({
where: { published: true },
orderBy: { updatedAt: "desc" },
include: {
categories: { include: { category: true } },
modules: {
orderBy: { sortOrder: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { sortOrder: "asc" },
select: { slug: true },
},
},
},
},
});
}
export async function getCourseBySlug(slug: string) {
return prisma.course.findFirst({
where: { slug, published: true },
include: {
categories: { include: { category: true } },
modules: {
orderBy: { sortOrder: "asc" },
include: {
lessons: { where: { published: true }, orderBy: { sortOrder: "asc" } },
},
},
},
});
}
export async function getLessonContext(courseSlug: string, lessonSlug: string) {
const course = await prisma.course.findFirst({
where: { slug: courseSlug, published: true },
include: {
modules: {
orderBy: { sortOrder: "asc" },
include: {
lessons: { where: { published: true }, orderBy: { sortOrder: "asc" } },
},
},
},
});
if (!course) return null;
for (const mod of course.modules) {
const lesson = mod.lessons.find((l) => l.slug === lessonSlug);
if (lesson) return { course, module: mod, lesson };
}
return null;
}
export function firstLessonPath(course: {
slug: string;
modules: { lessons: { slug: string }[] }[];
}): string | null {
for (const m of course.modules) {
const first = m.lessons[0];
if (first) return `/kurse/${course.slug}/lektionen/${first.slug}`;
}
return null;
}

21
lib/format.ts Normal file
View File

@@ -0,0 +1,21 @@
import { BillingInterval } from "@prisma/client";
export function formatMoney(cents: number, currency: string): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: currency || "EUR",
}).format(cents / 100);
}
export function billingLabel(interval: BillingInterval): string | null {
switch (interval) {
case "MONTH":
return "/ Monat";
case "QUARTER":
return "/ 4 Monate";
case "YEAR":
return "/ Jahr";
default:
return null;
}
}

95
lib/landing.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export type LandingContentV1 = {
version: 1;
heroTitle: string;
heroLead: string;
primaryCta: { label: string; href: string };
secondaryCta?: { label: string; href: string };
benefitSectionTitle: string;
benefits: { title: string; body: string }[];
};
export const defaultLandingContent = (): LandingContentV1 => ({
version: 1,
heroTitle: "Motorrad fahren von AZ",
heroLead:
"Von der Fahrschule zur ersten Serpentine. Tipps, Übungen und Kurse strukturiert auf einer Plattform.",
primaryCta: { label: "Zu den Kursen", href: "/kurse" },
secondaryCta: { label: "Mitgliederbereich", href: "/portal" },
benefitSectionTitle: "Deine Vorteile",
benefits: [
{
title: "Praxisorientiert",
body: "Inhalte, die du auf den Platz und in den Alltag übernehmen kannst Schritt für Schritt.",
},
{
title: "Von A bis Z",
body: "Klare Module statt Wildwuchs: vom ersten Gedanken ans Motorradfahren bis zu gezielten Übungen.",
},
{
title: "Fortschritt & Zertifikat",
body: "Behalte deinen Lernstand im Blick und sichere dir nach Abschluss eine Teilnahmebestätigung.",
},
],
});
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}
export function parseLandingContent(raw: unknown): LandingContentV1 {
if (!isRecord(raw) || raw.version !== 1) return defaultLandingContent();
const heroTitle = typeof raw.heroTitle === "string" ? raw.heroTitle : "";
const heroLead = typeof raw.heroLead === "string" ? raw.heroLead : "";
const benefitSectionTitle =
typeof raw.benefitSectionTitle === "string" ? raw.benefitSectionTitle : "Deine Vorteile";
const primaryCta = isRecord(raw.primaryCta)
? {
label: typeof raw.primaryCta.label === "string" ? raw.primaryCta.label : "Mehr",
href: typeof raw.primaryCta.href === "string" ? raw.primaryCta.href : "/kurse",
}
: { label: "Zu den Kursen", href: "/kurse" };
let secondaryCta: LandingContentV1["secondaryCta"];
if (isRecord(raw.secondaryCta)) {
const l = typeof raw.secondaryCta.label === "string" ? raw.secondaryCta.label : "";
const h = typeof raw.secondaryCta.href === "string" ? raw.secondaryCta.href : "";
if (l && h) secondaryCta = { label: l, href: h };
}
const benefitsRaw = Array.isArray(raw.benefits) ? raw.benefits : [];
const benefits = benefitsRaw
.map((b) => {
if (!isRecord(b)) return null;
const title = typeof b.title === "string" ? b.title : "";
const body = typeof b.body === "string" ? b.body : "";
if (!title && !body) return null;
return { title, body };
})
.filter(Boolean) as { title: string; body: string }[];
return {
version: 1,
heroTitle: heroTitle || defaultLandingContent().heroTitle,
heroLead: heroLead || defaultLandingContent().heroLead,
primaryCta,
secondaryCta,
benefitSectionTitle,
benefits: benefits.length ? benefits : defaultLandingContent().benefits,
};
}
export async function getLandingContent(): Promise<LandingContentV1> {
const row = await prisma.landingPage.findUnique({ where: { id: "default" } });
if (!row) return defaultLandingContent();
return parseLandingContent(row.content);
}
export async function saveLandingContent(content: LandingContentV1) {
const json = content as unknown as Prisma.InputJsonValue;
await prisma.landingPage.upsert({
where: { id: "default" },
create: { id: "default", content: json },
update: { content: json },
});
}

11
lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

15
lib/session-helpers.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/lib/auth-options";
export async function requireSession() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) redirect("/login");
return session;
}
export async function requireAdmin() {
const session = await requireSession();
if (session.user.role !== "ADMIN") redirect("/portal");
return session;
}

12
lib/slug.ts Normal file
View File

@@ -0,0 +1,12 @@
export function slugify(input: string): string {
const s = input
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const slug = s
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 96);
return slug || "kurs";
}