Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.
Made-with: Cursor
This commit is contained in:
54
lib/auth-options.ts
Normal file
54
lib/auth-options.ts
Normal 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
50
lib/certificates.ts
Normal 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
28
lib/course-progress.ts
Normal 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
68
lib/course-queries.ts
Normal 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
21
lib/format.ts
Normal 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
95
lib/landing.ts
Normal 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 A–Z",
|
||||
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
11
lib/prisma.ts
Normal 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
15
lib/session-helpers.ts
Normal 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
12
lib/slug.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user