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

View File

@@ -0,0 +1,141 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('LEARNER', 'INSTRUCTOR', 'ADMIN');
-- CreateEnum
CREATE TYPE "BillingInterval" AS ENUM ('NONE', 'MONTH', 'QUARTER', 'YEAR');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'LEARNER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Course" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"thumbnailUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"priceCents" INTEGER NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"billingInterval" "BillingInterval" NOT NULL DEFAULT 'NONE',
"ratingAverage" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"authorId" TEXT NOT NULL,
"authorName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CourseCategory" (
"courseId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
CONSTRAINT "CourseCategory_pkey" PRIMARY KEY ("courseId","categoryId")
);
-- CreateTable
CREATE TABLE "CourseModule" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "CourseModule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lesson" (
"id" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"contentHtml" TEXT NOT NULL DEFAULT '',
"videoUrl" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"published" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Enrollment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Enrollment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LessonProgress" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"completedAt" TIMESTAMP(3),
CONSTRAINT "LessonProgress_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Course_slug_key" ON "Course"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Lesson_moduleId_slug_key" ON "Lesson"("moduleId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "Enrollment_userId_courseId_key" ON "Enrollment"("userId", "courseId");
-- CreateIndex
CREATE UNIQUE INDEX "LessonProgress_userId_lessonId_key" ON "LessonProgress"("userId", "lessonId");
-- AddForeignKey
ALTER TABLE "CourseCategory" ADD CONSTRAINT "CourseCategory_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CourseCategory" ADD CONSTRAINT "CourseCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CourseModule" ADD CONSTRAINT "CourseModule_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "CourseModule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Enrollment" ADD CONSTRAINT "Enrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Enrollment" ADD CONSTRAINT "Enrollment_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,27 @@
-- LandingPage (singleton-style id)
CREATE TABLE "LandingPage" (
"id" TEXT NOT NULL,
"content" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LandingPage_pkey" PRIMARY KEY ("id")
);
-- Certificate
CREATE TABLE "Certificate" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code");
CREATE UNIQUE INDEX "Certificate_userId_courseId_key" ON "Certificate"("userId", "courseId");
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

135
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,135 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
LEARNER
INSTRUCTOR
ADMIN
}
enum BillingInterval {
NONE
MONTH
QUARTER
YEAR
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role Role @default(LEARNER)
createdAt DateTime @default(now())
enrollments Enrollment[]
progress LessonProgress[]
certificates Certificate[]
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
courses CourseCategory[]
}
model Course {
id String @id @default(cuid())
slug String @unique
title String
description String @default("")
thumbnailUrl String?
published Boolean @default(false)
priceCents Int @default(0)
currency String @default("EUR")
billingInterval BillingInterval @default(NONE)
ratingAverage Float @default(0)
ratingCount Int @default(0)
authorId String
authorName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
modules CourseModule[]
enrollments Enrollment[]
categories CourseCategory[]
certificates Certificate[]
}
model LandingPage {
id String @id
content Json
updatedAt DateTime @updatedAt
}
model Certificate {
id String @id @default(cuid())
code String @unique
userId String
courseId String
issuedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
}
model CourseCategory {
courseId String
categoryId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([courseId, categoryId])
}
model CourseModule {
id String @id @default(cuid())
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
title String
sortOrder Int @default(0)
lessons Lesson[]
}
model Lesson {
id String @id @default(cuid())
moduleId String
module CourseModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
slug String
title String
contentHtml String @default("")
videoUrl String?
sortOrder Int @default(0)
published Boolean @default(true)
progress LessonProgress[]
@@unique([moduleId, slug])
}
model Enrollment {
id String @id @default(cuid())
userId String
courseId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, courseId])
}
model LessonProgress {
id String @id @default(cuid())
userId String
lessonId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
completedAt DateTime?
@@unique([userId, lessonId])
}

203
prisma/seed.ts Normal file
View File

@@ -0,0 +1,203 @@
import { PrismaClient, BillingInterval, Role } from "@prisma/client";
import bcrypt from "bcryptjs";
import { defaultLandingContent } from "../lib/landing";
const prisma = new PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash("devpassword", 10);
const admin = await prisma.user.upsert({
where: { email: "admin@akademie.local" },
update: {},
create: {
email: "admin@akademie.local",
name: "MatzeFix",
passwordHash,
role: Role.ADMIN,
},
});
const instructor = await prisma.user.upsert({
where: { email: "matze@akademie.local" },
update: {},
create: {
email: "matze@akademie.local",
name: "MatzeFix",
passwordHash,
role: Role.INSTRUCTOR,
},
});
const learner = await prisma.user.upsert({
where: { email: "lernender@akademie.local" },
update: {},
create: {
email: "lernender@akademie.local",
name: "Demo Lernender",
passwordHash,
role: Role.LEARNER,
},
});
const catKostenlos = await prisma.category.upsert({
where: { slug: "kostenlos" },
update: {},
create: { slug: "kostenlos", name: "kostenlos" },
});
const catModul1 = await prisma.category.upsert({
where: { slug: "modul-1" },
update: {},
create: { slug: "modul-1", name: "Modul 1" },
});
const catUebung = await prisma.category.upsert({
where: { slug: "uebung-der-woche" },
update: {},
create: { slug: "uebung-der-woche", name: "Übung der Woche" },
});
const courseSlug = "modul-1-die-fahrschule";
let course = await prisma.course.findUnique({ where: { slug: courseSlug } });
if (!course) {
course = await prisma.course.create({
data: {
slug: courseSlug,
title: "Modul 1 Die Fahrschule",
description:
"Von der Fahrschulauswahl bis zu den ersten Übungen strukturiert und praxisnah.",
published: true,
priceCents: 0,
billingInterval: BillingInterval.NONE,
ratingAverage: 4.8,
ratingCount: 12,
authorId: instructor.id,
authorName: "MatzeFix",
categories: {
create: [
{ categoryId: catKostenlos.id },
{ categoryId: catModul1.id },
],
},
modules: {
create: [
{
title: "Einstieg",
sortOrder: 0,
lessons: {
create: [
{
slug: "wie-waehle-ich-die-richtige-fahrschule-aus",
title: "Wie wähle ich die richtige Fahrschule aus?",
sortOrder: 0,
contentHtml: `
<p>Die Wahl der Fahrschule prägt deinen gesamten Lernweg. Achte auf:</p>
<ul>
<li>Kommunikation und Transparenz bei Kosten</li>
<li>Motorrad-spezifische Erfahrung der Trainer</li>
<li>Übungsflächen und realistische Sonderfahrten</li>
</ul>
`,
},
{
slug: "ausruestung-und-erste-schritte",
title: "Ausrüstung und erste Schritte",
sortOrder: 1,
contentHtml:
"<p>Helm, Handschuhe, Protektoren passend zur Jahreszeit und zum Übungsplatz.</p>",
},
],
},
},
{
title: "Grundlagen auf dem Platz",
sortOrder: 1,
lessons: {
create: [
{
slug: "langsames-fahren-und-balance",
title: "Langsames Fahren und Balance",
sortOrder: 0,
contentHtml:
"<p>Übe Schrittgeschwindigkeit, Kupplungsfeinfühlen und Blickführung.</p>",
},
],
},
},
],
},
},
});
}
const demoPaidSlug = "demo-ubung-der-woche";
let paid = await prisma.course.findUnique({ where: { slug: demoPaidSlug } });
if (!paid) {
paid = await prisma.course.create({
data: {
slug: demoPaidSlug,
title: "Demo Übung der Woche",
description: "Beispiel für wiederkehrende Inhalte (Preisfeld für späteres Abo).",
published: true,
priceCents: 2396,
billingInterval: BillingInterval.QUARTER,
ratingAverage: 4,
ratingCount: 1,
authorId: instructor.id,
authorName: "MatzeFix",
categories: {
create: [
{ categoryId: catKostenlos.id },
{ categoryId: catUebung.id },
],
},
modules: {
create: [
{
title: "Woche 1",
sortOrder: 0,
lessons: {
create: [
{
slug: "uebung-saubere-blickfuehrung",
title: "Übung: Saubere Blickführung",
sortOrder: 0,
contentHtml:
"<p>Blick früh in die Kurve, Kurveneinlage planen, ruhige Handgelenke.</p>",
},
],
},
},
],
},
},
});
}
await prisma.enrollment.upsert({
where: {
userId_courseId: { userId: learner.id, courseId: course.id },
},
update: {},
create: { userId: learner.id, courseId: course.id },
});
await prisma.landingPage.upsert({
where: { id: "default" },
update: {},
create: {
id: "default",
content: defaultLandingContent() as object,
},
});
console.log("Seed OK:", { admin: admin.email, learner: learner.email });
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});