Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.
Made-with: Cursor
This commit is contained in:
141
prisma/migrations/20250413120000_init/migration.sql
Normal file
141
prisma/migrations/20250413120000_init/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
135
prisma/schema.prisma
Normal 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
203
prisma/seed.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user