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

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
.git
.env
.env.*
!.env.example

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL="postgresql://akademie:devsecret@localhost:5433/akademie"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="replace-with-random-32-chars-minimum-in-production"

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
.env
.env.local
*.log
.DS_Store

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --ignore-scripts
COPY . .
RUN npx prisma generate
RUN chmod +x scripts/docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# FL-Akademie (Motorrad-Akademie LMS)
Private Lernplattform als **Ersatz für Tutor LMS** für die [Fahrlässig Motorrad Akademie](https://akademie.fahrlaessig.com/) Next.js, PostgreSQL, Prisma, NextAuth. Ziel: eigene Kontrolle über Kurse, Nutzer, Landing Page und Zertifikate ohne WordPress-Plugin-Abhängigkeit.
**Remote-Repository:** `https://git.loepperts.com/loepperts/FL-Akademie.git`
---
## Schnellstart (Docker)
Voraussetzung: Docker & Docker Compose.
```bash
git clone https://git.loepperts.com/loepperts/FL-Akademie.git
cd FL-Akademie
docker compose up --build -d
```
- **App:** [http://localhost:3000](http://localhost:3000)
- **PostgreSQL (vom Host):** `localhost:5433` → Container-Port 5432 (weniger Konflikte mit lokalem Postgres)
Beim Start führt der Web-Container aus: `prisma generate`, `prisma migrate deploy`, `prisma db seed`, danach **`next dev`**.
---
## Umgebungsvariablen
Siehe `.env.example`. In `docker-compose.yml` sind für die Entwicklung bereits Werte gesetzt (`DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`). Für Produktion **eigenes starkes `NEXTAUTH_SECRET`** und korrekte **`NEXTAUTH_URL`** (öffentliche HTTPS-URL) setzen.
---
## Demo-Zugänge (Seed)
| Rolle | E-Mail | Passwort |
|------------|---------------------------|--------------|
| Admin | `admin@akademie.local` | `devpassword` |
| Dozent | `matze@akademie.local` | `devpassword` |
| Lernender | `lernender@akademie.local`| `devpassword` |
Der Lernende ist im Demo-Kurs „Modul 1 Die Fahrschule“ eingeschrieben.
---
## Wichtige Routen
| Bereich | Pfad | Beschreibung |
|--------|------|----------------|
| Öffentlich | `/` | Startseite (Inhalte aus DB, bearbeitbar im Admin) |
| Kurse | `/kurse`, `/kurse/[slug]` | Katalog, Kurssdetail, Einschreibung (kostenlose Kurse) |
| Lektionen | `/kurse/[slug]/lektionen/[lessonSlug]` | Lernansicht (Login + Einschreibung nötig) |
| Login | `/login` | Anmeldung |
| Mitgliederbereich | `/portal` | Fortschritt, Kurse wiederholen, Link zu Zertifikaten |
| Konto | `/portal/account` | Passwort ändern |
| Zertifikate (Liste) | `/portal/certificates` | Eigene Teilnahmebestätigungen |
| Zertifikat (öffentlich) | `/zertifikat/[code]` | Anzeige/Druck mit Verifikationscode |
| Administration | `/admin` | Nur Rolle `ADMIN`: Kurse, Nutzer, Landing Page |
`/dashboard` leitet nach `/portal` um.
---
## Dokumentation im Repo
| Datei | Inhalt |
|--------|--------|
| [docs/PLAN.md](docs/PLAN.md) | Produktvision, Architektur, Phasen, Sicherheit/Monitoring |
| [docs/HANDBUCH.md](docs/HANDBUCH.md) | Betrieb: Admin, Portal, Zertifikate, Datenmodell, Docker-Details |
---
## Projektstruktur (kurz)
```
app/ # Next.js App Router (Seiten, API-Routen)
components/ # UI-Komponenten
lib/ # Prisma-Client, Auth, Landing-Parsing, Zertifikate, Fortschritt
prisma/ # schema.prisma, Migrationen, seed.ts
scripts/ # docker-entrypoint.sh
```
---
## NPM-Skripte (ohne Docker)
Node 20+, lokales PostgreSQL, `.env` aus `.env.example`:
```bash
npm install
npx prisma generate
npx prisma migrate dev
npm run dev
```
`postinstall` führt kein `prisma generate` aus (Docker/CI-freundlich); nach `npm install` immer **`npx prisma generate`** ausführen.
---
## Lizenz / Nutzung
Internes Projekt für die Akademie keine allgemeine Open-Source-Lizenz festgelegt, sofern nicht separat ergänzt.

View File

@@ -0,0 +1,148 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import {
createLessonAction,
createModuleAction,
updateCourseAction,
} from "@/app/admin/courses/actions";
type Props = { params: Promise<{ id: string }> };
export default async function AdminEditCoursePage({ params }: Props) {
const { id } = await params;
const course = await prisma.course.findUnique({
where: { id },
include: {
modules: {
orderBy: { sortOrder: "asc" },
include: { lessons: { orderBy: { sortOrder: "asc" } } },
},
},
});
if (!course) notFound();
const priceEuros = (course.priceCents / 100).toFixed(2);
return (
<div>
<p className="muted">
<Link href="/admin/courses"> Alle Kurse</Link> ·{" "}
<Link href={`/kurse/${course.slug}`}>Öffentliche Kursseite</Link>
</p>
<h1 className="page-title">Kurs bearbeiten</h1>
<p className="muted subtitle">{course.title}</p>
<form action={updateCourseAction.bind(null, course.id)} className="panel form" style={{ maxWidth: 820 }}>
<h2 style={{ marginTop: 0 }}>Stammdaten</h2>
<label>
Titel
<input name="title" defaultValue={course.title} required />
</label>
<label>
Slug
<input name="slug" defaultValue={course.slug} required />
</label>
<label>
Beschreibung
<textarea name="description" defaultValue={course.description} />
</label>
<label>
Anzeige-Autor
<input name="authorName" defaultValue={course.authorName} required />
</label>
<label>
Preis (EUR)
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue={priceEuros} />
</label>
<label>
Abrechnungsintervall
<select name="billingInterval" defaultValue={course.billingInterval}>
<option value="NONE">Einmal / kein Abo</option>
<option value="MONTH">Monatlich</option>
<option value="QUARTER">Vierteljährlich</option>
<option value="YEAR">Jährlich</option>
</select>
</label>
<label className="stack" style={{ alignItems: "center" }}>
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input type="checkbox" name="published" defaultChecked={course.published} /> Veröffentlicht
</span>
</label>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</form>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Neues Modul</h2>
<form action={createModuleAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
<label>
Modultitel
<input name="title" required />
</label>
<button type="submit" className="btn btn-ghost">
Modul hinzufügen
</button>
</form>
</div>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Neue Lektion</h2>
<form action={createLessonAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
<label>
Modul
<select name="moduleId" required defaultValue={course.modules[0]?.id ?? ""}>
<option value="" disabled>
Modul wählen
</option>
{course.modules.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</select>
</label>
<label>
Lektionstitel
<input name="lessonTitle" required />
</label>
<label>
Slug (optional)
<input name="lessonSlug" placeholder="wird sonst aus dem Titel erzeugt" />
</label>
<label>
Inhalt (HTML)
<textarea name="contentHtml" placeholder="<p>…</p>" />
</label>
<button type="submit" className="btn btn-ghost">
Lektion hinzufügen
</button>
</form>
</div>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Kurrikulum</h2>
{course.modules.length === 0 ? (
<p className="muted">Noch keine Module.</p>
) : (
<ul className="curriculum">
{course.modules.map((m) => (
<li key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((l) => (
<li key={l.id}>
<Link href={`/kurse/${course.slug}/lektionen/${l.slug}`}>{l.title}</Link>{" "}
<span className="muted">({l.slug})</span>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { slugify } from "@/lib/slug";
import { BillingInterval } from "@prisma/client";
async function assertAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.id || session.user.role !== "ADMIN") {
throw new Error("Keine Berechtigung.");
}
return session;
}
function parseBilling(v: FormDataEntryValue | null): BillingInterval {
const s = String(v ?? "NONE");
if (s === "MONTH" || s === "QUARTER" || s === "YEAR") return s;
return BillingInterval.NONE;
}
export async function createCourseAction(formData: FormData) {
const session = await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect("/admin/courses/new?error=title");
let slug = String(formData.get("slug") ?? "").trim();
if (!slug) slug = slugify(title);
const exists = await prisma.course.findUnique({ where: { slug } });
if (exists) redirect(`/admin/courses/new?error=slug`);
const description = String(formData.get("description") ?? "").trim();
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
const published = String(formData.get("published") ?? "") === "on";
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
const course = await prisma.course.create({
data: {
slug,
title,
description,
published,
priceCents,
billingInterval: parseBilling(formData.get("billingInterval")),
authorId: session.user.id,
authorName,
},
});
revalidatePath("/kurse");
revalidatePath("/admin/courses");
redirect(`/admin/courses/${course.id}/edit`);
}
export async function updateCourseAction(courseId: string, formData: FormData) {
const session = await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect(`/admin/courses/${courseId}/edit?error=title`);
let slug = String(formData.get("slug") ?? "").trim();
if (!slug) slug = slugify(title);
const clash = await prisma.course.findFirst({
where: { slug, NOT: { id: courseId } },
});
if (clash) redirect(`/admin/courses/${courseId}/edit?error=slug`);
const description = String(formData.get("description") ?? "").trim();
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
const published = String(formData.get("published") ?? "") === "on";
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
await prisma.course.update({
where: { id: courseId },
data: {
slug,
title,
description,
published,
priceCents,
billingInterval: parseBilling(formData.get("billingInterval")),
authorName,
},
});
revalidatePath("/kurse");
revalidatePath("/admin/courses");
revalidatePath(`/kurse/${slug}`);
redirect(`/admin/courses/${courseId}/edit?saved=1`);
}
export async function createModuleAction(courseId: string, formData: FormData) {
await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect(`/admin/courses/${courseId}/edit?error=module`);
const max = await prisma.courseModule.aggregate({
where: { courseId },
_max: { sortOrder: true },
});
const sortOrder = (max._max.sortOrder ?? -1) + 1;
await prisma.courseModule.create({
data: { courseId, title, sortOrder },
});
revalidatePath(`/admin/courses/${courseId}/edit`);
redirect(`/admin/courses/${courseId}/edit`);
}
export async function createLessonAction(courseId: string, formData: FormData) {
await assertAdmin();
const moduleId = String(formData.get("moduleId") ?? "").trim();
const title = String(formData.get("lessonTitle") ?? "").trim();
if (!moduleId || !title) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
const mod = await prisma.courseModule.findFirst({
where: { id: moduleId, courseId },
});
if (!mod) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
let slug = String(formData.get("lessonSlug") ?? "").trim();
if (!slug) slug = slugify(title);
const contentHtml = String(formData.get("contentHtml") ?? "").trim();
const max = await prisma.lesson.aggregate({
where: { moduleId },
_max: { sortOrder: true },
});
const sortOrder = (max._max.sortOrder ?? -1) + 1;
const existing = await prisma.lesson.findUnique({
where: { moduleId_slug: { moduleId, slug } },
});
if (existing) redirect(`/admin/courses/${courseId}/edit?error=lessonSlug`);
await prisma.lesson.create({
data: { moduleId, slug, title, contentHtml, sortOrder, published: true },
});
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { slug: true } });
if (course?.slug) revalidatePath(`/kurse/${course.slug}`);
revalidatePath(`/admin/courses/${courseId}/edit`);
redirect(`/admin/courses/${courseId}/edit`);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { createCourseAction } from "@/app/admin/courses/actions";
export default function AdminNewCoursePage() {
return (
<div>
<p className="muted">
<Link href="/admin/courses"> Zurück</Link>
</p>
<h1 className="page-title">Neuer Kurs</h1>
<p className="muted subtitle">Nach dem Anlegen kannst du Module und Lektionen hinzufügen.</p>
<form action={createCourseAction} className="panel form">
<label>
Titel
<input name="title" required />
</label>
<label>
Slug (optional, sonst aus Titel)
<input name="slug" placeholder="z.b. modul-3-sicherheit" />
</label>
<label>
Kurzbeschreibung
<textarea name="description" />
</label>
<label>
Anzeige-Autor
<input name="authorName" placeholder="z.B. MatzeFix" />
</label>
<label>
Preis (EUR, 0 = kostenlos)
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue="0" />
</label>
<label>
Abrechnungsintervall
<select name="billingInterval" defaultValue="NONE">
<option value="NONE">Einmal / kein Abo</option>
<option value="MONTH">Monatlich</option>
<option value="QUARTER">Vierteljährlich</option>
<option value="YEAR">Jährlich</option>
</select>
</label>
<label className="stack" style={{ alignItems: "center" }}>
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input type="checkbox" name="published" /> Veröffentlicht
</span>
</label>
<button type="submit" className="btn btn-primary">
Kurs anlegen
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { formatMoney, billingLabel } from "@/lib/format";
export default async function AdminCoursesPage() {
const courses = await prisma.course.findMany({
orderBy: { updatedAt: "desc" },
include: { _count: { select: { enrollments: true, modules: true } } },
});
return (
<div>
<div className="stack" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
<div>
<h1 className="page-title">Kurse</h1>
<p className="muted subtitle">Alle Kurse inkl. Entwürfe</p>
</div>
<Link href="/admin/courses/new" className="btn btn-primary">
Neuer Kurs
</Link>
</div>
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Titel</th>
<th>Slug</th>
<th>Status</th>
<th>Preis</th>
<th>Module</th>
<th>Einschreibungen</th>
<th />
</tr>
</thead>
<tbody>
{courses.map((c) => (
<tr key={c.id}>
<td>{c.title}</td>
<td className="muted">{c.slug}</td>
<td>{c.published ? "Live" : "Entwurf"}</td>
<td>
{c.priceCents === 0 ? (
"kostenlos"
) : (
<>
{formatMoney(c.priceCents, c.currency)}
{billingLabel(c.billingInterval) ? ` ${billingLabel(c.billingInterval)}` : ""}
</>
)}
</td>
<td>{c._count.modules}</td>
<td>{c._count.enrollments}</td>
<td>
<Link href={`/admin/courses/${c.id}/edit`}>Bearbeiten</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import type { LandingContentV1 } from "@/lib/landing";
import { saveLandingContent } from "@/lib/landing";
async function assertAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.id || session.user.role !== "ADMIN") {
throw new Error("Keine Berechtigung.");
}
}
export async function updateLandingAction(formData: FormData) {
await assertAdmin();
const heroTitle = String(formData.get("heroTitle") ?? "").trim();
const heroLead = String(formData.get("heroLead") ?? "").trim();
const primaryLabel = String(formData.get("primaryLabel") ?? "").trim();
const primaryHref = String(formData.get("primaryHref") ?? "").trim();
const secondaryLabel = String(formData.get("secondaryLabel") ?? "").trim();
const secondaryHref = String(formData.get("secondaryHref") ?? "").trim();
const benefitSectionTitle = String(formData.get("benefitSectionTitle") ?? "").trim();
const benefits: { title: string; body: string }[] = [];
for (let i = 1; i <= 6; i++) {
const title = String(formData.get(`benefit${i}Title`) ?? "").trim();
const body = String(formData.get(`benefit${i}Body`) ?? "").trim();
if (title || body) benefits.push({ title, body });
}
const content: LandingContentV1 = {
version: 1,
heroTitle,
heroLead,
primaryCta: {
label: primaryLabel || "Zu den Kursen",
href: primaryHref || "/kurse",
},
secondaryCta:
secondaryLabel && secondaryHref ? { label: secondaryLabel, href: secondaryHref } : undefined,
benefitSectionTitle: benefitSectionTitle || "Deine Vorteile",
benefits,
};
await saveLandingContent(content);
revalidatePath("/");
revalidatePath("/admin/landing");
}

View File

@@ -0,0 +1,75 @@
import { getLandingContent } from "@/lib/landing";
import { updateLandingAction } from "@/app/admin/landing/actions";
export default async function AdminLandingPage() {
const c = await getLandingContent();
const benefits = [...c.benefits];
while (benefits.length < 6) benefits.push({ title: "", body: "" });
return (
<div>
<h1 className="page-title">Startseite bearbeiten</h1>
<p className="muted subtitle">
Änderungen sind nach dem Speichern sofort auf der öffentlichen Startseite sichtbar.
</p>
<form action={updateLandingAction} className="panel form" style={{ maxWidth: 820 }}>
<label>
Hero-Titel
<input name="heroTitle" defaultValue={c.heroTitle} required />
</label>
<label>
Hero-Text
<textarea name="heroLead" defaultValue={c.heroLead} required />
</label>
<div className="stack" style={{ gap: "1rem" }}>
<label style={{ flex: "1 1 220px" }}>
Primär-Button Text
<input name="primaryLabel" defaultValue={c.primaryCta.label} required />
</label>
<label style={{ flex: "1 1 220px" }}>
Primär-Button Link
<input name="primaryHref" defaultValue={c.primaryCta.href} required />
</label>
</div>
<div className="stack" style={{ gap: "1rem" }}>
<label style={{ flex: "1 1 220px" }}>
Sekundär-Button Text (optional)
<input name="secondaryLabel" defaultValue={c.secondaryCta?.label ?? ""} />
</label>
<label style={{ flex: "1 1 220px" }}>
Sekundär-Button Link (optional)
<input name="secondaryHref" defaultValue={c.secondaryCta?.href ?? ""} />
</label>
</div>
<label>
Abschnittstitel Vorteile
<input name="benefitSectionTitle" defaultValue={c.benefitSectionTitle} required />
</label>
{benefits.map((b, idx) => (
<div key={idx} className="panel" style={{ padding: "1rem" }}>
<div className="muted" style={{ marginBottom: "0.5rem", fontWeight: 700 }}>
Vorteil {idx + 1}
</div>
<label>
Titel
<input name={`benefit${idx + 1}Title`} defaultValue={b.title} />
</label>
<label>
Text
<textarea name={`benefit${idx + 1}Body`} defaultValue={b.body} />
</label>
</div>
))}
<button type="submit" className="btn btn-primary">
Speichern
</button>
</form>
</div>
);
}

25
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { requireAdmin } from "@/lib/session-helpers";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin();
return (
<section className="section">
<div className="container layout-split">
<aside className="side-nav" aria-label="Administration">
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
Admin
</strong>
<Link href="/admin">Übersicht</Link>
<Link href="/admin/users">Nutzer</Link>
<Link href="/admin/landing">Startseite</Link>
<Link href="/admin/courses">Kurse</Link>
<Link href="/portal">Mitgliederbereich</Link>
<Link href="/">Öffentliche Website</Link>
</aside>
<div>{children}</div>
</div>
</section>
);
}

24
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
export default function AdminHomePage() {
return (
<div>
<h1 className="page-title">Administration</h1>
<p className="muted subtitle">Verwaltung der Akademie-Inhalte und Nutzer.</p>
<div className="course-grid">
<Link href="/admin/courses" className="panel" style={{ display: "block" }}>
<h2 style={{ marginTop: 0 }}>Kurse</h2>
<p className="muted">Neue Kurse anlegen und bestehende bearbeiten.</p>
</Link>
<Link href="/admin/landing" className="panel" style={{ display: "block" }}>
<h2 style={{ marginTop: 0 }}>Startseite</h2>
<p className="muted">Hero, Texte und Vorteils-Kacheln der Landing Page.</p>
</Link>
<Link href="/admin/users" className="panel" style={{ display: "block" }}>
<h2 style={{ marginTop: 0 }}>Nutzer</h2>
<p className="muted">Übersicht über registrierte Accounts.</p>
</Link>
</div>
</div>
);
}

50
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { prisma } from "@/lib/prisma";
export default async function AdminUsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
_count: { select: { enrollments: true, certificates: true } },
},
});
return (
<div>
<h1 className="page-title">Nutzer</h1>
<p className="muted subtitle">{users.length} Accounts</p>
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Kurse</th>
<th>Zertifikate</th>
<th>Seit</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>{u.email}</td>
<td>
<span className="badge">{u.role}</span>
</td>
<td>{u._count.enrollments}</td>
<td>{u._count.certificates}</td>
<td className="muted">{u.createdAt.toLocaleDateString("de-DE")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

38
app/api/enroll/route.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as { courseSlug?: string } | null;
const courseSlug = body?.courseSlug?.trim();
if (!courseSlug) {
return NextResponse.json({ error: "courseSlug fehlt." }, { status: 400 });
}
const course = await prisma.course.findFirst({
where: { slug: courseSlug, published: true },
});
if (!course) {
return NextResponse.json({ error: "Kurs nicht gefunden." }, { status: 404 });
}
if (course.priceCents > 0) {
return NextResponse.json({ error: "payment_required" }, { status: 402 });
}
await prisma.enrollment.upsert({
where: {
userId_courseId: { userId: session.user.id, courseId: course.id },
},
update: {},
create: { userId: session.user.id, courseId: course.id },
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import bcrypt from "bcryptjs";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as {
currentPassword?: string;
newPassword?: string;
} | null;
const currentPassword = body?.currentPassword ?? "";
const newPassword = body?.newPassword ?? "";
if (!currentPassword || newPassword.length < 8) {
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 });
const ok = await bcrypt.compare(currentPassword, user.passwordHash);
if (!ok) return NextResponse.json({ error: "Aktuelles Passwort ist falsch." }, { status: 403 });
const passwordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { clearCourseProgress } from "@/lib/certificates";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as { courseId?: string } | null;
const courseId = body?.courseId?.trim();
if (!courseId) return NextResponse.json({ error: "courseId fehlt." }, { status: 400 });
const enrollment = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId } },
});
if (!enrollment) return NextResponse.json({ error: "Nicht eingeschrieben." }, { status: 403 });
await clearCourseProgress(session.user.id, courseId);
return NextResponse.json({ ok: true });
}

57
app/api/progress/route.ts Normal file
View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { syncCertificateForCourse } from "@/lib/certificates";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as {
lessonId?: string;
completed?: boolean;
} | null;
const lessonId = body?.lessonId?.trim();
if (!lessonId) {
return NextResponse.json({ error: "lessonId fehlt." }, { status: 400 });
}
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
include: { module: { include: { course: true } } },
});
if (!lesson) {
return NextResponse.json({ error: "Lektion nicht gefunden." }, { status: 404 });
}
const courseId = lesson.module.course.id;
const enrollment = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId } },
});
if (!enrollment) {
return NextResponse.json({ error: "Nicht eingeschrieben." }, { status: 403 });
}
const completed =
typeof body?.completed === "boolean" ? body.completed : true;
if (completed) {
await prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
update: { completedAt: new Date() },
create: { userId: session.user.id, lessonId, completedAt: new Date() },
});
await syncCertificateForCourse(session.user.id, courseId);
} else {
await prisma.lessonProgress.deleteMany({
where: { userId: session.user.id, lessonId },
});
await syncCertificateForCourse(session.user.id, courseId);
}
return NextResponse.json({ ok: true });
}

5
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function LegacyDashboardRedirect() {
redirect("/portal");
}

505
app/globals.css Normal file
View File

@@ -0,0 +1,505 @@
:root {
--bg: #ffffff;
--bg-muted: #f4f6f8;
--bg-elevated: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #475569;
--accent: #1d4ed8;
--accent-soft: #eff6ff;
--accent-hover: #1e40af;
--radius: 14px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-md: 0 10px 30px rgba(15, 23, 42, 0.08);
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans",
sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--bg-muted);
color: var(--text);
font-family: var(--font);
line-height: 1.65;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: var(--accent-hover);
}
.container {
width: min(1120px, 100% - 2rem);
margin-inline: auto;
}
.site-header {
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
position: sticky;
top: 0;
z-index: 20;
box-shadow: var(--shadow-sm);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 0;
}
.logo {
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text);
}
.logo span {
font-weight: 600;
color: var(--muted);
}
.nav {
display: flex;
align-items: center;
gap: 1.1rem;
font-size: 0.95rem;
}
.nav a:not(.btn) {
color: var(--text);
}
.nav a:not(.btn):hover {
color: var(--accent);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.55rem 1.05rem;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
}
.btn-primary {
background: var(--text);
color: #ffffff;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: #111c33;
color: #ffffff;
}
.btn-ghost {
border-color: var(--border);
background: #ffffff;
color: var(--text);
}
.btn-ghost:hover {
border-color: #cbd5e1;
background: var(--bg-muted);
}
.btn-danger {
border-color: #fecaca;
background: #fff1f2;
color: #9f1239;
}
.btn-danger:hover {
border-color: #fca5a5;
background: #ffe4e6;
}
.hero {
padding: 3.25rem 0 2rem;
text-align: center;
background: linear-gradient(180deg, #ffffff 0%, #f4f6f8 100%);
border-bottom: 1px solid var(--border);
}
.hero h1 {
font-size: clamp(2rem, 4vw, 2.65rem);
margin: 0 0 1rem;
letter-spacing: -0.03em;
color: var(--text);
}
.hero .lead {
color: var(--muted);
font-size: 1.12rem;
max-width: 44rem;
margin: 0 auto 1.5rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.section {
padding: 2.5rem 0 3rem;
}
.section h2 {
margin: 0 0 1.25rem;
font-size: 1.45rem;
letter-spacing: -0.02em;
}
.course-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.course-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
display: flex;
flex-direction: column;
min-height: 100%;
box-shadow: var(--shadow-sm);
}
.course-card-body {
padding: 1.25rem;
flex: 1;
}
.course-card-body h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.course-meta {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 0.9rem;
}
.course-rating {
font-size: 0.9rem;
}
.stars {
color: #ca8a04;
}
.course-price {
margin: 0.75rem 0 0;
font-weight: 700;
}
.course-card-actions {
padding: 0 1.25rem 1.25rem;
}
.course-card-actions .btn-primary {
width: 100%;
}
.muted {
color: var(--muted);
}
.site-footer {
border-top: 1px solid var(--border);
margin-top: 2rem;
padding: 2rem 0;
font-size: 0.9rem;
background: #ffffff;
}
.footer-inner {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: center;
}
.footer-nav .sep {
margin: 0 0.5rem;
color: var(--muted);
}
.panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
padding: 1.5rem;
box-shadow: var(--shadow-sm);
}
.two-col {
display: grid;
gap: 1.5rem;
}
@media (min-width: 900px) {
.two-col {
grid-template-columns: 280px 1fr;
align-items: start;
}
}
.curriculum {
list-style: none;
margin: 0;
padding: 0;
}
.curriculum li {
margin-bottom: 0.35rem;
}
.curriculum a {
display: block;
padding: 0.35rem 0.5rem;
border-radius: 8px;
color: var(--text);
}
.curriculum a:hover {
background: var(--accent-soft);
}
.curriculum a.active {
background: var(--accent-soft);
color: var(--accent-hover);
font-weight: 600;
}
.module-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--muted);
margin: 1rem 0 0.35rem;
}
.module-title:first-child {
margin-top: 0;
}
.prose {
max-width: 65ch;
}
.prose ul {
padding-left: 1.25rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 520px;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--text);
}
.form input,
.form textarea,
.form select {
padding: 0.6rem 0.75rem;
border-radius: 10px;
border: 1px solid var(--border);
background: #ffffff;
color: var(--text);
font: inherit;
}
.form textarea {
min-height: 120px;
resize: vertical;
}
.form .error,
.error {
color: #b91c1c;
font-size: 0.85rem;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #ffffff;
}
table.simple {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
table.simple th,
table.simple td {
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
table.simple th {
background: var(--bg-muted);
font-weight: 700;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--border);
background: #ffffff;
}
.badge-admin {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.layout-split {
display: grid;
gap: 1.25rem;
}
@media (min-width: 960px) {
.layout-split {
grid-template-columns: 240px 1fr;
align-items: start;
}
}
.side-nav {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #ffffff;
padding: 0.75rem;
box-shadow: var(--shadow-sm);
}
.side-nav a {
display: block;
padding: 0.55rem 0.65rem;
border-radius: 10px;
color: var(--text);
}
.side-nav a:hover {
background: var(--bg-muted);
}
.side-nav a.active {
background: var(--accent-soft);
color: var(--accent-hover);
font-weight: 700;
}
.progress {
height: 10px;
border-radius: 999px;
background: var(--bg-muted);
border: 1px solid var(--border);
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.cert-print {
max-width: 900px;
margin: 0 auto;
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2.25rem;
box-shadow: var(--shadow-md);
}
.cert-print h1 {
margin: 0 0 0.5rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 1.1rem;
color: var(--muted);
}
.cert-print .name {
font-size: 2rem;
margin: 0.75rem 0;
letter-spacing: -0.02em;
}
.stack {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.page-title {
margin: 0 0 0.35rem;
letter-spacing: -0.02em;
}
.subtitle {
margin: 0 0 1.25rem;
}
@media print {
.site-header,
.site-footer,
.no-print {
display: none !important;
}
body {
background: #ffffff !important;
}
.section {
padding: 0 !important;
}
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getLessonContext } from "@/lib/course-queries";
import { prisma } from "@/lib/prisma";
import { CompleteLessonButton } from "@/components/complete-lesson-button";
type Props = { params: Promise<{ slug: string; lessonSlug: string }> };
export default async function LessonPage({ params }: Props) {
const { slug: courseSlug, lessonSlug } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
redirect(`/login?callbackUrl=/kurse/${courseSlug}/lektionen/${lessonSlug}`);
}
const ctx = await getLessonContext(courseSlug, lessonSlug);
if (!ctx) notFound();
const enrollment = await prisma.enrollment.findUnique({
where: {
userId_courseId: { userId: session.user.id, courseId: ctx.course.id },
},
});
if (!enrollment) {
redirect(`/kurse/${ctx.course.slug}`);
}
const lessonIds = ctx.course.modules.flatMap((m) => m.lessons.map((l) => l.id));
const progressRows = await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: lessonIds } },
});
const completed = new Set(
progressRows.filter((p) => p.completedAt).map((p) => p.lessonId),
);
const currentDone = !!completed.has(ctx.lesson.id);
return (
<section className="section">
<div className="container">
<p className="muted">
<Link href={`/kurse/${ctx.course.slug}`}> {ctx.course.title}</Link>
</p>
<div className="two-col">
<nav className="panel" aria-label="Kurrikulum">
{ctx.course.modules.map((m) => (
<div key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((l) => {
const href = `/kurse/${ctx.course.slug}/lektionen/${l.slug}`;
const active = l.id === ctx.lesson.id;
return (
<li key={l.id}>
<Link href={href} className={active ? "active" : undefined}>
{completed.has(l.id) ? "✓ " : ""}
{l.title}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<article className="panel prose">
<h1 style={{ marginTop: 0 }}>{ctx.lesson.title}</h1>
{ctx.lesson.videoUrl ? (
<p className="muted">
Video: <a href={ctx.lesson.videoUrl}>{ctx.lesson.videoUrl}</a>
</p>
) : null}
<div dangerouslySetInnerHTML={{ __html: ctx.lesson.contentHtml }} />
<CompleteLessonButton lessonId={ctx.lesson.id} initialCompleted={currentDone} />
</article>
</div>
</div>
</section>
);
}

90
app/kurse/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getCourseBySlug, firstLessonPath } from "@/lib/course-queries";
import { formatMoney, billingLabel } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { EnrollButton } from "@/components/enroll-button";
type Props = { params: Promise<{ slug: string }> };
export default async function CoursePage({ params }: Props) {
const { slug } = await params;
const course = await getCourseBySlug(slug);
if (!course) notFound();
const session = await getServerSession(authOptions);
const enrollment =
session?.user?.id &&
(await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
}));
const startPath = firstLessonPath(course);
const cats = course.categories.map((c) => c.category.name).join(", ");
const priceSuffix = billingLabel(course.billingInterval);
const isFree = course.priceCents === 0;
return (
<section className="section">
<div className="container">
<p className="muted">
<Link href="/kurse"> Alle Kurse</Link>
</p>
<h1>{course.title}</h1>
<p className="course-meta">
Von <strong>{course.authorName}</strong>
{cats ? <> · {cats}</> : null}
</p>
{!isFree && (
<p className="course-price">
{formatMoney(course.priceCents, course.currency)}
{priceSuffix ? ` ${priceSuffix}` : ""}{" "}
<span className="muted">(Zahlungsanbindung in Arbeit aktuell nicht kaufbar)</span>
</p>
)}
<div style={{ margin: "1.5rem 0" }}>
{!session ? (
<Link href={`/login?callbackUrl=/kurse/${course.slug}`} className="btn btn-primary">
Anmelden, um fortzufahren
</Link>
) : enrollment && startPath ? (
<Link href={startPath} className="btn btn-primary">
Mit dem Lernen beginnen
</Link>
) : enrollment ? (
<span className="muted">Noch keine Lektionen.</span>
) : isFree ? (
<EnrollButton courseSlug={course.slug} />
) : (
<p className="muted">Kostenpflichtige Kurse werden über Stripe angebunden.</p>
)}
</div>
<div className="panel">
<h2>Kurrikulum</h2>
{course.description ? <p>{course.description}</p> : null}
<ul className="curriculum">
{course.modules.map((m) => (
<li key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((lesson) => {
const href = `/kurse/${course.slug}/lektionen/${lesson.slug}`;
return (
<li key={lesson.id}>
<Link href={href}>{lesson.title}</Link>
</li>
);
})}
</ul>
</li>
))}
</ul>
</div>
</div>
</section>
);
}

35
app/kurse/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { listPublishedCourses } from "@/lib/course-queries";
import { CourseCard } from "@/components/course-card";
import { prisma } from "@/lib/prisma";
export default async function CoursesPage() {
const session = await getServerSession(authOptions);
const courses = await listPublishedCourses();
let enrolledIds = new Set<string>();
if (session?.user?.id) {
const rows = await prisma.enrollment.findMany({
where: { userId: session.user.id },
select: { courseId: true },
});
enrolledIds = new Set(rows.map((r) => r.courseId));
}
return (
<section className="section">
<div className="container">
<h1>Kursinhalte</h1>
<p className="muted" style={{ marginBottom: "1.5rem" }}>
Alle veröffentlichten Kurse Einschreibung und Lektionen wie auf der Live-Akademie vorgesehen.
</p>
<div className="course-grid">
{courses.map((c) => (
<CourseCard key={c.id} course={c} enrolled={enrolledIds.has(c.id)} />
))}
</div>
</div>
</section>
);
}

24
app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/providers";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
title: "Motorrad Akademie LMS (Dev)",
description: "Private Lernplattform Entwicklungsumgebung",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<body>
<Providers>
<SiteHeader />
<main>{children}</main>
<SiteFooter />
</Providers>
</body>
</html>
);
}

16
app/login/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Suspense } from "react";
import { LoginForm } from "@/components/login-form";
export default function LoginPage() {
return (
<section className="section">
<div className="container">
<h1>Anmelden</h1>
<p className="muted">Entwicklung: Demo-Zugänge siehe docs/PLAN.md</p>
<Suspense fallback={<p className="muted">Laden</p>}>
<LoginForm />
</Suspense>
</div>
</section>
);
}

68
app/page.tsx Normal file
View File

@@ -0,0 +1,68 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { listPublishedCourses } from "@/lib/course-queries";
import { CourseCard } from "@/components/course-card";
import { prisma } from "@/lib/prisma";
import { getLandingContent } from "@/lib/landing";
export default async function HomePage() {
const session = await getServerSession(authOptions);
const courses = await listPublishedCourses();
const landing = await getLandingContent();
let enrolledIds = new Set<string>();
if (session?.user?.id) {
const rows = await prisma.enrollment.findMany({
where: { userId: session.user.id },
select: { courseId: true },
});
enrolledIds = new Set(rows.map((r) => r.courseId));
}
return (
<>
<section className="hero">
<div className="container">
<h1>{landing.heroTitle}</h1>
<p className="lead">{landing.heroLead}</p>
<div className="hero-actions">
<Link href={landing.primaryCta.href} className="btn btn-primary">
{landing.primaryCta.label}
</Link>
{landing.secondaryCta ? (
<Link href={landing.secondaryCta.href} className="btn btn-ghost">
{landing.secondaryCta.label}
</Link>
) : null}
</div>
</div>
</section>
<section className="section">
<div className="container">
<h2>Unsere Kurse &amp; Module</h2>
<div className="course-grid">
{courses.map((c) => (
<CourseCard key={c.id} course={c} enrolled={enrolledIds.has(c.id)} />
))}
</div>
</div>
</section>
<section className="section">
<div className="container">
<h2>{landing.benefitSectionTitle}</h2>
<div className="course-grid">
{landing.benefits.map((b, idx) => (
<div key={idx} className="panel">
<h3>{b.title}</h3>
<p className="muted">{b.body}</p>
</div>
))}
</div>
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,18 @@
import { PasswordChangeForm } from "@/components/password-change-form";
import { requireSession } from "@/lib/session-helpers";
export default async function PortalAccountPage() {
const session = await requireSession();
return (
<div>
<h1 className="page-title">Konto</h1>
<p className="muted subtitle">
Angemeldet als <strong>{session.user.email}</strong>
</p>
<h2 style={{ marginTop: "2rem" }}>Passwort ändern</h2>
<PasswordChangeForm />
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/session-helpers";
export default async function PortalCertificatesPage() {
const session = await requireSession();
const rows = await prisma.certificate.findMany({
where: { userId: session.user.id },
orderBy: { issuedAt: "desc" },
include: { course: { select: { title: true, slug: true } } },
});
return (
<div>
<h1 className="page-title">Zertifikate</h1>
<p className="muted subtitle">Teilnahmebestätigungen nach vollständigem Kursabschluss.</p>
{rows.length === 0 ? (
<div className="panel">
<p className="muted">Noch keine Zertifikate schließe zuerst alle Lektionen eines Kurses ab.</p>
<Link href="/portal" className="btn btn-primary">
Zu deinen Kursen
</Link>
</div>
) : (
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Kurs</th>
<th>Ausgestellt</th>
<th>Code</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.course.title}</td>
<td className="muted">{r.issuedAt.toLocaleDateString("de-DE")}</td>
<td className="muted">{r.code}</td>
<td>
<Link href={`/zertifikat/${r.code}`}>Ansehen / Drucken</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

25
app/portal/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { requireSession } from "@/lib/session-helpers";
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession();
return (
<section className="section">
<div className="container layout-split">
<aside className="side-nav" aria-label="Mitgliederbereich">
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
Mitgliederbereich
</strong>
<Link href="/portal">Übersicht</Link>
<Link href="/portal/account">Konto &amp; Passwort</Link>
<Link href="/portal/certificates">Zertifikate</Link>
<Link href="/kurse">Kurskatalog</Link>
{session.user.role === "ADMIN" ? <Link href="/admin">Administration</Link> : null}
<Link href="/">Startseite</Link>
</aside>
<div>{children}</div>
</div>
</section>
);
}

93
app/portal/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/session-helpers";
import { firstLessonPath } from "@/lib/course-queries";
import { getUserCourseProgress } from "@/lib/course-progress";
import { RestartCourseButton } from "@/components/restart-course-button";
export default async function PortalHomePage() {
const session = await requireSession();
const enrollments = await prisma.enrollment.findMany({
where: { userId: session.user.id },
include: {
course: {
include: {
modules: {
orderBy: { sortOrder: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { sortOrder: "asc" },
select: { slug: true },
},
},
},
},
},
},
orderBy: { createdAt: "desc" },
});
const certs = await prisma.certificate.findMany({
where: { userId: session.user.id },
select: { courseId: true, code: true },
});
const certByCourse = new Map(certs.map((c) => [c.courseId, c.code]));
return (
<div>
<h1 className="page-title">Hallo{session.user.name ? `, ${session.user.name}` : ""}!</h1>
<p className="muted subtitle">Deine Kurse, Fortschritt und nächste Schritte.</p>
{enrollments.length === 0 ? (
<div className="panel">
<p>Du bist noch in keinem Kurs eingeschrieben.</p>
<Link href="/kurse" className="btn btn-primary">
Kurse entdecken
</Link>
</div>
) : (
<div className="course-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))" }}>
{await Promise.all(
enrollments.map(async (e) => {
const p = await getUserCourseProgress(session.user.id, e.course.id);
const start = firstLessonPath(e.course);
const cert = certByCourse.get(e.course.id);
return (
<div key={e.id} className="panel">
<h2 style={{ marginTop: 0, fontSize: "1.15rem" }}>{e.course.title}</h2>
<p className="muted" style={{ marginTop: 0 }}>
Fortschritt: {p.completed}/{p.total} Lektionen ({p.percent}%)
</p>
<div className="progress" aria-hidden="true">
<span style={{ width: `${p.percent}%` }} />
</div>
<div className="stack" style={{ marginTop: "1rem" }}>
{start ? (
<Link href={start} className="btn btn-primary">
{p.percent >= 100 ? "Kurs wiederholen" : "Weiterlernen"}
</Link>
) : (
<span className="muted">Keine Lektionen</span>
)}
<Link href={`/kurse/${e.course.slug}`} className="btn btn-ghost">
Kurrikulum
</Link>
{cert ? (
<Link href={`/zertifikat/${cert}`} className="btn btn-ghost">
Zertifikat
</Link>
) : null}
<RestartCourseButton courseId={e.course.id} />
</div>
</div>
);
}),
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { PrintButton } from "@/components/print-button";
type Props = { params: Promise<{ code: string }> };
export default async function CertificatePublicPage({ params }: Props) {
const { code } = await params;
const cert = await prisma.certificate.findUnique({
where: { code },
include: {
course: { select: { title: true } },
user: { select: { name: true } },
},
});
if (!cert) notFound();
return (
<section className="section">
<div className="container">
<div className="cert-print">
<p className="muted" style={{ margin: 0 }}>
Fahrlässig Motorrad Akademie
</p>
<h1>Teilnahmebestätigung</h1>
<p className="muted" style={{ marginTop: "0.75rem" }}>
Hiermit bestätigen wir, dass
</p>
<div className="name">{cert.user.name}</div>
<p style={{ margin: "0.75rem 0 0", fontSize: "1.1rem" }}>
den Online-Kurs <strong>{cert.course.title}</strong> vollständig absolviert hat.
</p>
<p className="muted" style={{ marginTop: "1.25rem" }}>
Ausstellungsdatum: {cert.issuedAt.toLocaleDateString("de-DE")}
</p>
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
Verifikationscode: <span style={{ fontFamily: "ui-monospace, monospace" }}>{cert.code}</span>
</p>
<div style={{ marginTop: "1.5rem" }}>
<PrintButton />
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function CompleteLessonButton({
lessonId,
initialCompleted,
}: {
lessonId: string;
initialCompleted: boolean;
}) {
const router = useRouter();
const [completed, setCompleted] = useState(initialCompleted);
const [loading, setLoading] = useState(false);
async function toggle() {
setLoading(true);
const res = await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lessonId, completed: !completed }),
});
setLoading(false);
if (res.ok) {
setCompleted(!completed);
router.refresh();
}
}
return (
<div style={{ marginTop: "2rem" }}>
<button type="button" className="btn btn-ghost" disabled={loading} onClick={toggle}>
{loading ? "…" : completed ? "Als nicht abgeschlossen markieren" : "Lektion abschließen"}
</button>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import Link from "next/link";
import type { Category, Course, CourseCategory } from "@prisma/client";
import { formatMoney, billingLabel } from "@/lib/format";
import { firstLessonPath } from "@/lib/course-queries";
type CourseWithCats = Course & {
categories: (CourseCategory & { category: Category })[];
modules: { lessons: { slug: string }[] }[];
};
export function CourseCard({
course,
enrolled,
}: {
course: CourseWithCats;
enrolled: boolean;
}) {
const cats = course.categories.map((c) => c.category.name).join(", ");
const first = firstLessonPath(course);
const isFree = course.priceCents === 0;
const priceSuffix = billingLabel(course.billingInterval);
return (
<article className="course-card">
<div className="course-card-body">
<h3>{course.title}</h3>
<p className="course-meta">
Von <strong>{course.authorName}</strong>
{cats ? <> · {cats}</> : null}
</p>
<div className="course-rating" aria-label="Bewertung">
{course.ratingCount > 0 ? (
<>
<span className="stars"></span> {course.ratingAverage.toFixed(2)} ({course.ratingCount})
</>
) : (
<span className="muted">Noch keine Bewertungen</span>
)}
</div>
{!isFree && (
<p className="course-price">
{formatMoney(course.priceCents, course.currency)}
{priceSuffix ? ` ${priceSuffix}` : ""}
</p>
)}
</div>
<div className="course-card-actions">
{enrolled && first ? (
<Link href={first} className="btn btn-primary">
Mit dem Lernen beginnen
</Link>
) : enrolled ? (
<span className="muted">Keine Lektionen</span>
) : isFree ? (
<Link href={`/kurse/${course.slug}`} className="btn btn-primary">
In diesen Kurs einschreiben
</Link>
) : (
<Link href={`/kurse/${course.slug}`} className="btn btn-primary">
Details &amp; Kauf
</Link>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function EnrollButton({ courseSlug }: { courseSlug: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onClick() {
setLoading(true);
setError(null);
const res = await fetch("/api/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseSlug }),
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
setLoading(false);
if (!res.ok) {
setError(data.error ?? "Einschreibung fehlgeschlagen.");
return;
}
router.refresh();
}
return (
<div>
<button type="button" className="btn btn-primary" disabled={loading} onClick={onClick}>
{loading ? "…" : "In diesen Kurs einschreiben"}
</button>
{error ? <p className="error">{error}</p> : null}
</div>
);
}

64
components/login-form.tsx Normal file
View File

@@ -0,0 +1,64 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { useState } from "react";
export function LoginForm() {
const search = useSearchParams();
const router = useRouter();
const callbackUrl = search.get("callbackUrl") || "/dashboard";
const [email, setEmail] = useState("lernender@akademie.local");
const [password, setPassword] = useState("devpassword");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await signIn("credentials", {
email,
password,
redirect: false,
callbackUrl,
});
setLoading(false);
if (res?.error) {
setError("Anmeldung fehlgeschlagen.");
return;
}
router.push(callbackUrl);
router.refresh();
}
return (
<form className="form panel" onSubmit={onSubmit}>
<label>
E-Mail
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p className="error">{error}</p> : null}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "…" : "Anmelden"}
</button>
</form>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
export function PasswordChangeForm() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setErr(null);
setMsg(null);
const res = await fetch("/api/portal/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
setLoading(false);
if (!res.ok) {
setErr(data.error ?? "Änderung fehlgeschlagen.");
return;
}
setMsg("Passwort wurde aktualisiert.");
setCurrentPassword("");
setNewPassword("");
}
return (
<form className="panel form" onSubmit={onSubmit}>
<label>
Aktuelles Passwort
<input
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
Neues Passwort (mind. 8 Zeichen)
<input
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</label>
{err ? <p className="error">{err}</p> : null}
{msg ? <p className="muted">{msg}</p> : null}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "…" : "Passwort speichern"}
</button>
</form>
);
}

View File

@@ -0,0 +1,9 @@
"use client";
export function PrintButton() {
return (
<button type="button" className="btn btn-primary no-print" onClick={() => window.print()}>
Drucken / PDF speichern
</button>
);
}

8
components/providers.tsx Normal file
View File

@@ -0,0 +1,8 @@
"use client";
import { SessionProvider } from "next-auth/react";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function RestartCourseButton({ courseId }: { courseId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function onClick() {
if (
!confirm(
"Fortschritt und Zertifikat für diesen Kurs zurücksetzen? Du kannst danach von vorn beginnen.",
)
) {
return;
}
setLoading(true);
const res = await fetch("/api/portal/restart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId }),
});
setLoading(false);
if (!res.ok) return;
router.refresh();
}
return (
<button type="button" className="btn btn-danger" disabled={loading} onClick={onClick}>
{loading ? "…" : "Fortschritt zurücksetzen"}
</button>
);
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export function SiteFooter() {
return (
<footer className="site-footer">
<div className="container footer-inner">
<p className="muted">Entwicklungsumgebung Akademie LMS (Tutor-Ersatz).</p>
<nav className="footer-nav">
<Link href="/kurse">Kurse</Link>
<span className="sep">·</span>
<a href="https://akademie.fahrlaessig.com/" target="_blank" rel="noreferrer">
Live-Akademie
</a>
</nav>
</div>
</footer>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
export async function SiteHeader() {
const session = await getServerSession(authOptions);
const isAdmin = session?.user?.role === "ADMIN";
return (
<header className="site-header">
<div className="container header-inner">
<Link href="/" className="logo">
Fahrlässig <span>Motorrad Akademie</span>
</Link>
<nav className="nav">
<Link href="/">Startseite</Link>
<Link href="/kurse">Kurse</Link>
{session ? (
<>
<Link href="/portal">Mitgliederbereich</Link>
{isAdmin ? (
<Link href="/admin" className="badge badge-admin" style={{ padding: "0.45rem 0.75rem" }}>
Admin
</Link>
) : null}
<a href="/api/auth/signout?callbackUrl=/" className="btn btn-ghost">
Abmelden
</a>
</>
) : (
<Link href="/login" className="btn btn-primary">
Login
</Link>
)}
</nav>
</div>
</header>
);
}

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: akademie
POSTGRES_PASSWORD: devsecret
POSTGRES_DB: akademie
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U akademie -d akademie"]
interval: 5s
timeout: 5s
retries: 12
web:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://akademie:devsecret@db:5432/akademie
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: dev-secret-min-32-chars-for-next-auth-docker-compose
NEXT_TELEMETRY_DISABLED: "1"
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:

108
docs/HANDBUCH.md Normal file
View File

@@ -0,0 +1,108 @@
# Handbuch FL-Akademie LMS
Technische und organisatorische Dokumentation des **implementierten Stands**. Ergänzt [PLAN.md](PLAN.md) (Vision & Roadmap).
---
## 1. Rollen (NextAuth + Prisma)
| Rolle (`Role`) | Zweck |
|----------------|--------|
| `LEARNER` | Standard-Lernende |
| `INSTRUCTOR` | vorbereitet für spätere Dozenten-Features |
| `ADMIN` | voller Zugriff auf `/admin` |
Die Rolle liegt im **JWT** und wird über `middleware.ts` für `/admin` geprüft. `/portal` ist für **alle angemeldeten** Nutzer.
---
## 2. Öffentliche Website
- **Startseite `/`:** Inhalte (Hero, CTAs, Vorteils-Kacheln) kommen aus der Tabelle **`LandingPage`** (`id = "default"`, Feld `content` als JSON). Fallback-Logik in `lib/landing.ts`, wenn keine oder ungültige Daten vorliegen.
- **Kurse:** Veröffentlichte Kurse (`published = true`), Einschreibung über API nur bei **Preis 0** (kostenpflichtige Kurse: Hinweis in UI, Zahlung noch nicht angebunden).
---
## 3. Mitgliederbereich (`/portal`)
- **Übersicht:** Eingeschriebene Kurse, Fortschrittsbalken (abgeschlossene vs. gesamte veröffentlichte Lektionen), Links zum Lernen, zum Kurrikulum, zum Zertifikat (falls vorhanden).
- **Kurs wiederholen:** Link startet wieder bei der ersten Lektion; optional **„Fortschritt zurücksetzen“** (löscht `LessonProgress` und ggf. `Certificate` für diesen Kurs).
- **Konto `/portal/account`:** Passwort ändern (`POST /api/portal/password` aktuelles Passwort prüfen, neues hashen mit bcrypt).
- **Zertifikate `/portal/certificates`:** Liste ausgestellter Urkunden mit Link zur öffentlichen Ansicht.
---
## 4. Administration (`/admin`)
Nur mit Rolle **`ADMIN`** erreichbar (sonst Redirect nach `/portal`).
| Route | Funktion |
|-------|----------|
| `/admin` | Übersicht mit Verweisen |
| `/admin/courses` | Alle Kurse (inkl. Entwürfe), Link „Neuer Kurs“ |
| `/admin/courses/new` | Neuanlage: Titel, Slug, Beschreibung, Autor-Name, Preis (EUR), Abo-Intervall, Veröffentlicht |
| `/admin/courses/[id]/edit` | Stammdaten bearbeiten; **Module** und **Lektionen** hinzufügen (Lektion: optionaler Slug, HTML-Inhalt) |
| `/admin/users` | Nutzerliste (ohne Passwort): E-Mail, Name, Rolle, Anzahl Einschreibungen & Zertifikate |
| `/admin/landing` | Startseiten-Texte und bis zu 6 Vorteils-Kacheln; **Speichern wirkt sofort** auf `/` |
---
## 5. Zertifikate (Certificate of completion)
- **Ausstellung:** Sobald ein Nutzer **alle veröffentlichten Lektionen** eines Kurses als abgeschlossen markiert hat, legt `lib/certificates.ts` (`syncCertificateForCourse`) einen **`Certificate`**-Datensatz mit eindeutigem **`code`** an (Format z.B. `FA-…`).
- **Widerruf:** Entfernen einzelner Lektions-Abschlüsse oder „Fortschritt zurücksetzen“ löscht das Zertifikat wieder, sobald der Kurs nicht mehr vollständig ist.
- **Öffentliche Seite:** `/zertifikat/[code]` Name, Kurstitel, Datum, Verifikationscode; Drucklayout blendet Kopf-/Fußzeile aus (`@media print` in `app/globals.css`).
Auslöser nach Abschluss: `POST /api/progress` ruft `syncCertificateForCourse` auf.
---
## 6. Datenmodell (Prisma)
Wesentliche Modelle:
- `User`, `Course`, `CourseModule`, `Lesson`, `Enrollment`, `LessonProgress`
- `Category`, `CourseCategory` (Seed / spätere Filter)
- **`LandingPage`** eine Zeile `default` für Startseiten-JSON
- **`Certificate`** `@@unique([userId, courseId])`, `code` global eindeutig
Migrationen unter `prisma/migrations/`.
---
## 7. Docker Compose
| Service | Beschreibung |
|---------|----------------|
| `db` | `postgres:16-alpine`, Volume `postgres_data` |
| `web` | Build aus `Dockerfile`, Entrypoint `scripts/docker-entrypoint.sh` |
Umgebungsvariablen im Compose u.a. `NEXT_TELEMETRY_DISABLED=1`.
**Befehle:**
```bash
docker compose up --build -d # im Hintergrund
docker compose logs -f web # Logs
docker compose down # stoppen
```
---
## 8. Sicherheitshinweise (Produktion)
- `NEXTAUTH_SECRET` und `NEXTAUTH_URL` korrekt setzen.
- Credentials-Login ist für Dev/MVP gedacht für Produktion OAuth oder stärkere Passwortrichtlinien erwägen.
- Admin-Oberfläche nicht ohne Absicherung (TLS, Netzwerk) exponieren.
---
## 9. Git-Remote
Standard-Remote nach Klon/Push:
```text
https://git.loepperts.com/loepperts/FL-Akademie.git
```
Branch: `main` (empfohlen).

125
docs/PLAN.md Normal file
View File

@@ -0,0 +1,125 @@
# Motorrad-Akademie LMS Ersatz für Tutor LMS
Dieses Dokument ist die **Produkt- und Architekturgrundlage** für eine private, zielorientierte Lernplattform. Sie ersetzt schrittweise [Tutor LMS](https://tutorlms.com/) auf [akademie.fahrlaessig.com](https://akademie.fahrlaessig.com/).
---
## 1. Zielbild
- **Eigentümerschaft:** Code und Daten bei euch, keine Plugin-Lizenzkosten für das LMS-Kernstück.
- **Bedienung:** Reduzierte Admin-Oberfläche auf echte Workflows (Kurs anlegen, Lektionen, Zugang, Zahlung).
- **Kontinuität:** URLs, Kurslogik und Look sollen zur bestehenden Akademie passen (Migration/Redirects später).
- **Skalierung:** Produktionsfähige Basis (API, DB, Jobs), ohne WordPress als Laufzeit für den Lernbereich.
---
## 2. Ist-Analyse Akademie (öffentlich)
| Bereich | Anforderung |
|--------|-------------|
| Kurse | Karten mit Titel, Autor, Kategorien (kostenlos, Module, Übung der Woche). |
| Kurrikulum | `/kurse/.../lektionen/...` Module/Themen und Lektionen. |
| Zugang | Einschreiben vs. „Mit dem Lernen beginnen“. |
| Monetarisierung | Abo-Preise, Warenkorb/Demo, kostenpflichtig vs. kostenlos. |
| Vertrauen | Bewertungen, Dozentenprofile. |
| Marketing | Startseite, Roadmap, Blog/Shop teils außerhalb LMS. |
---
## 3. Tutor-LMS-Feature-Parität (phasenweise)
### Phase A MVP (Betrieb Akademie)
- Nutzer: Registrierung, Login, Passwort, Rollen (Lernender, Admin).
- Kurse: Slug, Titel, Beschreibung, Thumbnail, Kategorien, veröffentlicht.
- Kurrikulum: Kurs → Modul → Lektion (Reihenfolge), Rich-Text-Inhalt.
- Einschreibung + Fortschritt (Lektion abgeschlossen).
- Zahlungen: vorbereitet (Stripe o. Ä.); **Dev:** kostenlose Einschreibung + Preisfelder in DB.
- Öffentliche Kursliste + Kurssicht + Lektionsplayer-Layout.
### Phase B später
- Quizze, Aufgaben, Zertifikate, Prerequisites, Content Drip, Gradebook, erweiterte Analytics, Bundles, Live-Sessions, KI-Inhalte nur bei konkretem Bedarf.
---
## 4. Technische Architektur (Umsetzung in diesem Repo)
| Komponente | Wahl |
|------------|------|
| App | Next.js (App Router), TypeScript |
| Datenbank | PostgreSQL 16 |
| ORM | Prisma |
| Auth | NextAuth.js (Credentials, erweiterbar auf OAuth) |
| Deployment lokal | Docker Compose (`web` + `db`) |
Verzeichnisüberblick:
- `app/` Routen: Marketing (`/`), Kurse, Lektionen, Login, **Mitgliederbereich `/portal`**, **Admin `/admin`**, öffentliche Zertifikatsseite `/zertifikat/[code]`, API (`/api/...`).
- `components/` UI-Bausteine (Header, Kurskarten, Formulare).
- `lib/` Prisma-Client, Auth, Landing-Content, Zertifikate, Fortschritt, Slugs.
- `prisma/schema.prisma` Datenmodell inkl. `LandingPage`, `Certificate`.
- `prisma/seed.ts` Demo-Daten + Standard-Landing.
- `docker-compose.yml` Postgres (Hostport **5433**) + Web.
- `Dockerfile` + `scripts/docker-entrypoint.sh` Migration, Seed, `next dev`.
- `README.md` Einstieg & Schnellstart.
- `docs/HANDBUCH.md` **implementierter Stand** (Admin, Portal, Zertifikate, Docker).
---
## 5. Migration (später, produktiv)
- Export aus WordPress/Tutor → Import-Skripte ins Zielschema.
- Nutzer: Einladung/Passwort-Reset statt unsicherer Passwort-Übernahme.
- SEO: 301 von alten `/kurse/`-URLs.
- Zahlungen: Abgleich mit Payment-Provider (Kunden-IDs).
---
## 6. Sicherheit (Produktion)
- TLS, Security-Header (CSP iterativ), Rate-Limits auf Login.
- OWASP-Basics, keine Secrets im Image, regelmäßige Updates.
- RBAC für Admin/Dozent/Support.
---
## 7. Monitoring (sekundär, aber vorgesehen)
- Strukturierte Logs, Metriken (Latenz, 5xx, Jobs), Alerts bei Zahlungs-Webhooks.
- Healthchecks für App, DB, Queue.
---
## 8. Ausführung Entwicklung mit Docker Compose
Voraussetzung: Docker und Docker Compose installiert.
```bash
cd /home/lo/Dokumente/real-akademie-replacement
docker compose up --build
```
- App: **http://localhost:3000**
- Demo-Login (Seed): `admin@akademie.local` / `devpassword`
Lernender: `lernender@akademie.local` / `devpassword`
Umgebungsvariablen siehe `.env.example`. Für reine lokale Entwicklung ohne Docker: Node 20+, `npm install`, Postgres starten, `DATABASE_URL` setzen, `npx prisma generate`, `npx prisma migrate dev`, `npm run dev`.
**Hinweis Next.js:** Unter demselben dynamischen Pfadsegment (z.B. `/kurse/[slug]/…`) müssen die Parameternamen in allen verschachtelten Routen übereinstimmen (hier durchgängig `slug` für den Kurs).
---
## 9. Roadmap dieses Repos
1. ~~MVP-UI und Datenmodell~~; ~~Admin-UI (Kurse, Nutzer, Landing Page)~~; ~~Portal mit Passwort & Zertifikat~~ umgesetzt (siehe `docs/HANDBUCH.md`).
2. Stripe/Webhooks + echte Abos.
3. Migrationstools von Tutor-Export (Inhalte, Nutzer, Zahlungen).
4. Phase-B-Features nach Priorität (Quizze, Drip, …).
---
## 10. Repository
- **Git:** `https://git.loepperts.com/loepperts/FL-Akademie.git`
- Einstieg für neue Entwickler: zuerst **`README.md`**, dann **`docs/HANDBUCH.md`** und dieses **`docs/PLAN.md`**.

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";
}

47
middleware.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const secret = process.env.NEXTAUTH_SECRET;
if (!secret) return NextResponse.next();
const token = await getToken({ req, secret });
if (pathname.startsWith("/admin")) {
if (!token) {
const url = new URL("/login", req.url);
url.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(url);
}
if (token.role !== "ADMIN") {
return NextResponse.redirect(new URL("/portal", req.url));
}
return NextResponse.next();
}
if (pathname.startsWith("/portal")) {
if (!token) {
const url = new URL("/login", req.url);
url.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
if (pathname.startsWith("/dashboard")) {
if (!token) {
const url = new URL("/login", req.url);
url.searchParams.set("callbackUrl", "/portal");
return NextResponse.redirect(url);
}
return NextResponse.redirect(new URL("/portal", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/portal/:path*", "/dashboard/:path*"],
};

2
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "akademie-lms",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:migrate": "prisma migrate deploy",
"db:seed": "prisma db seed",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.3.1",
"bcryptjs": "^2.4.3",
"next": "^15.1.6",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.13.1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"eslint": "^9.19.0",
"eslint-config-next": "^15.1.6",
"prisma": "^6.3.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

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);
});

View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
cd /app
echo "Waiting for database..."
npx prisma generate
npx prisma migrate deploy
npx prisma db seed
exec npm run dev -- --hostname 0.0.0.0 --port 3000

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

21
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string;
role: string;
};
}
interface User {
role?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: string;
}
}