first commit

This commit is contained in:
DerJesen
2025-12-10 20:20:39 +01:00
commit c24927fab1
136 changed files with 32253 additions and 0 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Database Connection
DATABASE_URL="postgresql://user:password@host:5432/database?schema=public"
# Security
JWT_SECRET="change_this_to_a_secure_secret"
# SMTP Configuration
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_USER="user@example.com"
SMTP_PASS="password"
SMTP_FROM="noreply@smartes-klassenzimmer.de"

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build outputs
dist/
build/
out/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
logs
*.log
# IDEs
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage/
# NestJS specific
*.tsbuildinfo
# SvelteKit specific
.svelte-kit/

View File

@@ -0,0 +1,16 @@
# PostgreSQL Database Connection
# Format: postgresql://username:password@host:port/database?schema=public
DATABASE_URL="postgresql://username:password@localhost:5432/smartes_klassenzimmer?schema=public"
# JWT Secret Key (MUST be changed in production!)
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
# Server Port
PORT=3000
# SMTP Configuration
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_USER="user@example.com"
SMTP_PASS="password"
SMTP_FROM="noreply@smartes-klassenzimmer.de"

View File

@@ -0,0 +1,75 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
*.spec.ts
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Claude
.claude/*
# Test files
/test
test-websocket.html
*.html
# Prisma
/generated/prisma
# Database
*.db
*.db-journal
# Proton Drive sync conflicts
*# Name clash*

View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,20 @@
FROM node:22-alpine
WORKDIR /app
# Install OpenSSL for Prisma
RUN apk add --no-cache openssl
COPY package*.json ./
RUN npm ci
COPY . .
# Generate Prisma Client
RUN npx prisma generate
RUN npm run build
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]

View File

@@ -0,0 +1,19 @@
# Smartes Klassenzimmer - Backend
Vollständige Projekt-Dokumentation befindet sich in der [CLAUDE.md](../CLAUDE.md) im Root-Verzeichnis.
## Quick Start
```bash
# Dependencies installieren
npm install
# Datenbank initialisieren
npx prisma migrate dev
npx prisma db seed
# Development Server starten
npm run start:dev
```
Der Server läuft auf `http://localhost:3000` mit API-Präfix `/api`.

View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["mail/templates/**/*"]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
{
"name": "Smartes-Klassenzimmer-Backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\" --fix",
"seed": "ts-node prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/common": "^11.1.8",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.8",
"@nestjs/platform-socket.io": "^11.1.8",
"@nestjs/schedule": "^6.1.0",
"@nestjs/websockets": "^11.1.8",
"@prisma/client": "^6.19.0",
"@types/cron": "^2.0.1",
"@types/multer": "^2.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"dotenv": "^17.2.3",
"handlebars": "^4.7.8",
"multer": "^2.0.2",
"nodemailer": "^7.0.11",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.4",
"@types/passport-jwt": "^4.0.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
}
}

View File

@@ -0,0 +1,13 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,255 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'Student',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subject" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"abbreviation" TEXT,
"color" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subject_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Room" (
"id" SERIAL NOT NULL,
"number" TEXT NOT NULL,
"building" TEXT,
"capacity" INTEGER,
"equipment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Room_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TimetableEntry" (
"id" SERIAL NOT NULL,
"dayOfWeek" INTEGER NOT NULL,
"startTime" TEXT NOT NULL,
"endTime" TEXT NOT NULL,
"subjectId" INTEGER NOT NULL,
"teacherId" INTEGER,
"roomId" INTEGER,
"weekNumber" INTEGER,
"year" INTEGER,
"isRecurring" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TimetableEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Feedback" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"teacherId" INTEGER NOT NULL,
"lessonId" INTEGER NOT NULL,
"lessonDate" TIMESTAMP(3) NOT NULL,
"overallRating" INTEGER NOT NULL,
"categories" JSONB,
"whatWasGood" TEXT,
"whatCanImprove" TEXT,
"additionalComments" TEXT,
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
"allowTeacherResponse" BOOLEAN NOT NULL DEFAULT true,
"teacherResponse" TEXT,
"teacherRespondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Grade" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"timetableEntryId" INTEGER NOT NULL,
"weekNumber" INTEGER NOT NULL,
"year" INTEGER NOT NULL,
"grade" DOUBLE PRECISION NOT NULL,
"gradeType" TEXT NOT NULL,
"weight" DOUBLE PRECISION DEFAULT 1.0,
"teacherId" INTEGER NOT NULL,
"title" TEXT,
"description" TEXT,
"maxPoints" DOUBLE PRECISION,
"achievedPoints" DOUBLE PRECISION,
"date" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Grade_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatGroup" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatGroupMember" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"groupId" INTEGER NOT NULL,
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatGroupMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"groupId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Subject_name_key" ON "Subject"("name");
-- CreateIndex
CREATE INDEX "Subject_name_idx" ON "Subject"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Room_number_key" ON "Room"("number");
-- CreateIndex
CREATE INDEX "Room_number_idx" ON "Room"("number");
-- CreateIndex
CREATE INDEX "TimetableEntry_dayOfWeek_idx" ON "TimetableEntry"("dayOfWeek");
-- CreateIndex
CREATE INDEX "TimetableEntry_weekNumber_year_idx" ON "TimetableEntry"("weekNumber", "year");
-- CreateIndex
CREATE INDEX "TimetableEntry_subjectId_idx" ON "TimetableEntry"("subjectId");
-- CreateIndex
CREATE INDEX "TimetableEntry_teacherId_idx" ON "TimetableEntry"("teacherId");
-- CreateIndex
CREATE INDEX "TimetableEntry_roomId_idx" ON "TimetableEntry"("roomId");
-- CreateIndex
CREATE INDEX "Feedback_studentId_idx" ON "Feedback"("studentId");
-- CreateIndex
CREATE INDEX "Feedback_teacherId_idx" ON "Feedback"("teacherId");
-- CreateIndex
CREATE INDEX "Feedback_lessonId_idx" ON "Feedback"("lessonId");
-- CreateIndex
CREATE UNIQUE INDEX "Feedback_studentId_lessonId_key" ON "Feedback"("studentId", "lessonId");
-- CreateIndex
CREATE INDEX "Grade_studentId_idx" ON "Grade"("studentId");
-- CreateIndex
CREATE INDEX "Grade_timetableEntryId_idx" ON "Grade"("timetableEntryId");
-- CreateIndex
CREATE INDEX "Grade_teacherId_idx" ON "Grade"("teacherId");
-- CreateIndex
CREATE INDEX "Grade_weekNumber_year_idx" ON "Grade"("weekNumber", "year");
-- CreateIndex
CREATE UNIQUE INDEX "Grade_studentId_timetableEntryId_weekNumber_year_gradeType_key" ON "Grade"("studentId", "timetableEntryId", "weekNumber", "year", "gradeType");
-- CreateIndex
CREATE INDEX "ChatGroup_createdById_idx" ON "ChatGroup"("createdById");
-- CreateIndex
CREATE INDEX "ChatGroupMember_userId_idx" ON "ChatGroupMember"("userId");
-- CreateIndex
CREATE INDEX "ChatGroupMember_groupId_idx" ON "ChatGroupMember"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "ChatGroupMember_userId_groupId_key" ON "ChatGroupMember"("userId", "groupId");
-- CreateIndex
CREATE INDEX "ChatMessage_groupId_idx" ON "ChatMessage"("groupId");
-- CreateIndex
CREATE INDEX "ChatMessage_userId_idx" ON "ChatMessage"("userId");
-- CreateIndex
CREATE INDEX "ChatMessage_createdAt_idx" ON "ChatMessage"("createdAt");
-- AddForeignKey
ALTER TABLE "TimetableEntry" ADD CONSTRAINT "TimetableEntry_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TimetableEntry" ADD CONSTRAINT "TimetableEntry_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TimetableEntry" ADD CONSTRAINT "TimetableEntry_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "TimetableEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Grade" ADD CONSTRAINT "Grade_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Grade" ADD CONSTRAINT "Grade_timetableEntryId_fkey" FOREIGN KEY ("timetableEntryId") REFERENCES "TimetableEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Grade" ADD CONSTRAINT "Grade_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatGroup" ADD CONSTRAINT "ChatGroup_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatGroupMember" ADD CONSTRAINT "ChatGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatGroupMember" ADD CONSTRAINT "ChatGroupMember_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ChatGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ChatGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "passwordResetExpires" TIMESTAMP(3),
ADD COLUMN "passwordResetToken" TEXT;

View File

@@ -0,0 +1,28 @@
-- AlterTable
ALTER TABLE "TimetableEntry" ADD COLUMN "allowStudentUploads" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "File" (
"id" SERIAL NOT NULL,
"filename" TEXT NOT NULL,
"path" TEXT NOT NULL,
"mimetype" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"uploadedById" INTEGER NOT NULL,
"timetableEntryId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "File_uploadedById_idx" ON "File"("uploadedById");
-- CreateIndex
CREATE INDEX "File_timetableEntryId_idx" ON "File"("timetableEntryId");
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_timetableEntryId_fkey" FOREIGN KEY ("timetableEntryId") REFERENCES "TimetableEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "students" (
"student_id" SERIAL NOT NULL,
"student_first_name" TEXT NOT NULL,
"student_last_name" TEXT NOT NULL,
"student_class_name" TEXT,
"student_rfid_card_uid" TEXT NOT NULL,
"student_card_is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "students_pkey" PRIMARY KEY ("student_id")
);
-- CreateTable
CREATE TABLE "attendance_logs" (
"attendance_log_id" SERIAL NOT NULL,
"student_id" INTEGER NOT NULL,
"attendance_scanned_at" TIMESTAMP(3) NOT NULL,
"attendance_event_type" TEXT NOT NULL,
"was_manual_entry" BOOLEAN NOT NULL DEFAULT false,
"manual_entry_reason" TEXT,
CONSTRAINT "attendance_logs_pkey" PRIMARY KEY ("attendance_log_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "students_student_rfid_card_uid_key" ON "students"("student_rfid_card_uid");
-- CreateIndex
CREATE INDEX "attendance_logs_student_id_attendance_scanned_at_idx" ON "attendance_logs"("student_id", "attendance_scanned_at");
-- AddForeignKey
ALTER TABLE "attendance_logs" ADD CONSTRAINT "attendance_logs_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students"("student_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 (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,279 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
username String @unique
password String
role String @default("Student")
// Password Reset
passwordResetToken String?
passwordResetExpires DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
receivedGrades Grade[] @relation("ReceivedGrades") // Noten, die Schüler erhalten haben
assignedGrades Grade[] @relation("AssignedGrades") // Noten, die Lehrer vergeben haben
chatMessages ChatMessage[]
chatGroups ChatGroupMember[]
studentFeedbacks Feedback[] @relation("StudentFeedbacks")
teacherFeedbacks Feedback[] @relation("TeacherFeedbacks")
taughtTimetableEntries TimetableEntry[] @relation("TeacherTimetableEntries")
createdChatGroups ChatGroup[] @relation("ChatGroupCreator")
uploadedFiles File[]
}
// Neue Tabelle: File (Dateien)
model File {
id Int @id @default(autoincrement())
filename String
path String
mimetype String
size Int
uploadedById Int
uploadedBy User @relation(fields: [uploadedById], references: [id], onDelete: Cascade)
timetableEntryId Int?
timetableEntry TimetableEntry? @relation(fields: [timetableEntryId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([uploadedById])
@@index([timetableEntryId])
}
// Neue Tabelle: Subject (Fächer)
model Subject {
id Int @id @default(autoincrement())
name String @unique // z.B. "Mathematik", "Deutsch", "Englisch"
abbreviation String? // z.B. "Mathe", "DE", "EN"
color String? // Farbe für UI (z.B. "#FF5733")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
timetableEntries TimetableEntry[]
@@index([name])
}
// Neue Tabelle: Room (Räume)
model Room {
id Int @id @default(autoincrement())
number String @unique // z.B. "A101", "B203"
building String? // z.B. "Hauptgebäude", "Neubau"
capacity Int? // Anzahl Sitzplätze
equipment String? // z.B. "Beamer, Whiteboard"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
timetableEntries TimetableEntry[]
@@index([number])
}
model TimetableEntry {
id Int @id @default(autoincrement())
dayOfWeek Int // 1 = Montag, 2 = Dienstag, ..., 5 = Freitag
startTime String // Format: "08:00"
endTime String // Format: "09:30"
// Normalisierte Relationen
subjectId Int
subject Subject @relation(fields: [subjectId], references: [id], onDelete: Restrict)
teacherId Int?
teacher User? @relation("TeacherTimetableEntries", fields: [teacherId], references: [id], onDelete: SetNull)
roomId Int?
room Room? @relation(fields: [roomId], references: [id], onDelete: SetNull)
// Wochenbasierte Planung
weekNumber Int? // Kalenderwoche (1-53), null = alle Wochen
year Int? // Jahr (z.B. 2025), null = alle Jahre
isRecurring Boolean @default(true) // true = wiederholt sich jede Woche, false = nur für spezifische Woche
allowStudentUploads Boolean @default(false)
files File[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
feedbacks Feedback[]
grades Grade[]
@@index([dayOfWeek])
@@index([weekNumber, year])
@@index([subjectId])
@@index([teacherId])
@@index([roomId])
}
model Feedback {
id Int @id @default(autoincrement())
// Foreign Key Relations zu User-Tabelle
studentId Int
student User @relation("StudentFeedbacks", fields: [studentId], references: [id], onDelete: Cascade)
teacherId Int
teacher User @relation("TeacherFeedbacks", fields: [teacherId], references: [id], onDelete: Cascade)
// Foreign Key Relation zu TimetableEntry
lessonId Int
timetableEntry TimetableEntry @relation(fields: [lessonId], references: [id], onDelete: Cascade)
lessonDate DateTime
overallRating Int
// Prisma Json-Typ für Kategorien
categories Json?
whatWasGood String?
whatCanImprove String?
additionalComments String?
isAnonymous Boolean @default(false)
allowTeacherResponse Boolean @default(true)
teacherResponse String?
teacherRespondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Unique Constraint: Ein Schüler kann nur einmal pro Stunde Feedback geben
@@unique([studentId, lessonId])
@@index([studentId])
@@index([teacherId])
@@index([lessonId])
}
model Grade {
id Int @id @default(autoincrement())
// Schüler-Information (erhält die Note)
studentId Int
student User @relation("ReceivedGrades", fields: [studentId], references: [id], onDelete: Cascade)
// Stundenplan-Bezug
timetableEntryId Int
timetableEntry TimetableEntry @relation(fields: [timetableEntryId], references: [id], onDelete: Cascade)
// Wochenbezug (Note gilt für eine spezifische Woche)
weekNumber Int // Kalenderwoche (1-53)
year Int // Jahr (z.B. 2025)
// Noten-Details
grade Float // z.B. 1.0, 2.5, 3.0 (deutsches Notensystem)
gradeType String // z.B. "Klausur", "Mitarbeit", "Hausaufgabe", "Mündlich"
weight Float? @default(1.0) // Gewichtung der Note (z.B. Klausur = 2.0, Mitarbeit = 1.0)
// Lehrer-Information (vergibt die Note)
teacherId Int
teacher User @relation("AssignedGrades", fields: [teacherId], references: [id], onDelete: Restrict)
// Optional: Detailbeschreibung
title String? // z.B. "Mathearbeit Kapitel 3"
description String? // Zusätzliche Informationen
maxPoints Float? // Maximale Punktzahl
achievedPoints Float? // Erreichte Punktzahl
// Datum
date DateTime // Datum der Leistung
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([studentId, timetableEntryId, weekNumber, year, gradeType])
@@index([studentId])
@@index([timetableEntryId])
@@index([teacherId])
@@index([weekNumber, year])
}
model ChatGroup {
id Int @id @default(autoincrement())
name String
description String?
createdById Int // Lehrer, der die Gruppe erstellt hat
createdBy User @relation("ChatGroupCreator", fields: [createdById], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members ChatGroupMember[]
messages ChatMessage[]
@@index([createdById])
}
model ChatGroupMember {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
groupId Int
group ChatGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
joinedAt DateTime @default(now())
@@unique([userId, groupId])
@@index([userId])
@@index([groupId])
}
model ChatMessage {
id Int @id @default(autoincrement())
content String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
groupId Int
group ChatGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([groupId])
@@index([userId])
@@index([createdAt])
}
model Student {
studentId Int @id @default(autoincrement()) @map("student_id")
studentFirstName String @map("student_first_name")
studentLastName String @map("student_last_name")
studentClassName String? @map("student_class_name")
studentRfidCardUid String @unique @map("student_rfid_card_uid")
studentCardIsActive Boolean @default(true) @map("student_card_is_active")
attendanceLogs AttendanceLog[]
@@map("students")
}
model AttendanceLog {
attendanceLogId Int @id @default(autoincrement()) @map("attendance_log_id")
studentId Int @map("student_id")
student Student @relation(fields: [studentId], references: [studentId], onDelete: Cascade)
attendanceScannedAt DateTime @map("attendance_scanned_at")
attendanceEventType String @map("attendance_event_type")
wasManualEntry Boolean @default(false) @map("was_manual_entry")
manualEntryReason String? @map("manual_entry_reason")
@@index([studentId, attendanceScannedAt])
@@map("attendance_logs")
}

View File

@@ -0,0 +1,236 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Datenbank wird mit Testdaten befüllt...\n');
// 1. Benutzer erstellen (Lehrer und Schüler)
console.log('📝 Erstelle Benutzer...');
const admin = await prisma.user.upsert({
where: { email: 'admin@smartesklassenzimmer.de' },
update: {},
create: {
email: 'admin@smartesklassenzimmer.de',
username: 'admin',
password: '$2b$10$8I9F3DPBvqzm.tBz7yMgfur4z1gMyaHsPyhmL4VAGJ2OvnmTTn5KW', // admin123
role: 'Lehrer',
},
});
const teacher1 = await prisma.user.upsert({
where: { email: 'mueller@schule.de' },
update: {},
create: {
email: 'mueller@schule.de',
username: 'Frau Müller',
password: '$2b$10$8I9F3DPBvqzm.tBz7yMgfur4z1gMyaHsPyhmL4VAGJ2OvnmTTn5KW', // admin123
role: 'Lehrer',
},
});
const teacher2 = await prisma.user.upsert({
where: { email: 'schmidt@schule.de' },
update: {},
create: {
email: 'schmidt@schule.de',
username: 'Herr Schmidt',
password: '$2b$10$8I9F3DPBvqzm.tBz7yMgfur4z1gMyaHsPyhmL4VAGJ2OvnmTTn5KW', // admin123
role: 'Lehrer',
},
});
const student1 = await prisma.user.upsert({
where: { email: 'max@student.de' },
update: {},
create: {
email: 'max@student.de',
username: 'Max Mustermann',
password: '$2b$10$8I9F3DPBvqzm.tBz7yMgfur4z1gMyaHsPyhmL4VAGJ2OvnmTTn5KW', // admin123
role: 'Student',
},
});
const student2 = await prisma.user.upsert({
where: { email: 'anna@student.de' },
update: {},
create: {
email: 'anna@student.de',
username: 'Anna Schmidt',
password: '$2b$10$8I9F3DPBvqzm.tBz7yMgfur4z1gMyaHsPyhmL4VAGJ2OvnmTTn5KW', // admin123
role: 'Student',
},
});
console.log('✅ Benutzer erstellt\n');
// 2. Fächer erstellen
console.log('📚 Erstelle Fächer...');
const mathSubject = await prisma.subject.upsert({
where: { name: 'Mathematik' },
update: {},
create: {
name: 'Mathematik',
abbreviation: 'Mathe',
color: '#FF5733',
},
});
const germanSubject = await prisma.subject.upsert({
where: { name: 'Deutsch' },
update: {},
create: {
name: 'Deutsch',
abbreviation: 'DE',
color: '#33FF57',
},
});
const englishSubject = await prisma.subject.upsert({
where: { name: 'Englisch' },
update: {},
create: {
name: 'Englisch',
abbreviation: 'EN',
color: '#3357FF',
},
});
const physicsSubject = await prisma.subject.upsert({
where: { name: 'Physik' },
update: {},
create: {
name: 'Physik',
abbreviation: 'Phy',
color: '#FF33F5',
},
});
console.log('✅ Fächer erstellt\n');
// 3. Räume erstellen
console.log('🏫 Erstelle Räume...');
const roomA101 = await prisma.room.upsert({
where: { number: 'A101' },
update: {},
create: {
number: 'A101',
building: 'Hauptgebäude',
capacity: 30,
equipment: 'Beamer, Whiteboard',
},
});
const roomB203 = await prisma.room.upsert({
where: { number: 'B203' },
update: {},
create: {
number: 'B203',
building: 'Neubau',
capacity: 25,
equipment: 'Smartboard, Laptop',
},
});
const roomC105 = await prisma.room.upsert({
where: { number: 'C105' },
update: {},
create: {
number: 'C105',
building: 'Hauptgebäude',
capacity: 20,
equipment: 'Beamer',
},
});
console.log('✅ Räume erstellt\n');
// 4. Stundenplan-Einträge erstellen
console.log('📅 Erstelle Stundenplan-Einträge...');
// Montag
await prisma.timetableEntry.create({
data: {
dayOfWeek: 1,
startTime: '08:00',
endTime: '09:30',
subjectId: mathSubject.id,
teacherId: teacher1.id,
roomId: roomA101.id,
isRecurring: true,
},
});
await prisma.timetableEntry.create({
data: {
dayOfWeek: 1,
startTime: '10:00',
endTime: '11:30',
subjectId: germanSubject.id,
teacherId: teacher2.id,
roomId: roomB203.id,
isRecurring: true,
},
});
// Dienstag
await prisma.timetableEntry.create({
data: {
dayOfWeek: 2,
startTime: '08:00',
endTime: '09:30',
subjectId: englishSubject.id,
teacherId: teacher1.id,
roomId: roomC105.id,
isRecurring: true,
},
});
await prisma.timetableEntry.create({
data: {
dayOfWeek: 2,
startTime: '10:00',
endTime: '11:30',
subjectId: physicsSubject.id,
teacherId: teacher2.id,
roomId: roomA101.id,
isRecurring: true,
},
});
// Mittwoch
await prisma.timetableEntry.create({
data: {
dayOfWeek: 3,
startTime: '08:00',
endTime: '09:30',
subjectId: mathSubject.id,
teacherId: teacher1.id,
roomId: roomA101.id,
isRecurring: true,
},
});
console.log('✅ Stundenplan-Einträge erstellt\n');
console.log('\n=== LOGIN-DATEN ===');
console.log('👨‍🏫 Lehrer:');
console.log(' - Benutzername: admin, Passwort: admin123');
console.log(' - Benutzername: Frau Müller, Passwort: admin123');
console.log(' - Benutzername: Herr Schmidt, Passwort: admin123');
console.log('\n👨🎓 Schüler:');
console.log(' - Benutzername: Max Mustermann, Passwort: admin123');
console.log(' - Benutzername: Anna Schmidt, Passwort: admin123');
console.log('==================\n');
console.log('🎉 Datenbank erfolgreich mit Testdaten befüllt!\n');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('api')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('health')
healthCheck() {
return this.appService.healthCheck();
}
}

View File

@@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ChatModule } from './chat/chat.module';
import { AuthModule } from './auth/auth.module';
import { PrismaModule } from './prisma/prisma.module';
import { FeedbackModule } from './feedback/feedback.module';
import { UsersModule } from './users/users.module';
import { TimetableModule } from './timetable/timetable.module';
import { WhiteboardModule } from './whiteboard/whiteboard.module';
import { GradesModule } from './grades/grades.module';
import { SubjectsModule } from './subjects/subjects.module';
import { RoomsModule } from './rooms/rooms.module';
import { ClassroomModule } from './classroom/classroom.module';
import { MailModule } from './mail/mail.module';
import { FilesModule } from './files/files.module';
import { IotModule } from './iot/iot.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
ScheduleModule.forRoot(),
MailModule,
PrismaModule,
AuthModule,
ChatModule,
FeedbackModule,
UsersModule,
TimetableModule,
WhiteboardModule,
GradesModule,
SubjectsModule,
RoomsModule,
ClassroomModule, FilesModule,
IotModule
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
healthCheck() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'Smartes Klassenzimmer API',
};
}
}

View File

@@ -0,0 +1,88 @@
import {
Controller,
Post,
Body,
Res,
HttpCode,
HttpStatus,
Get,
UseGuards,
Req,
} from '@nestjs/common';
import type { Response, Request } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, ForgotPasswordDto, ResetPasswordDto } from './dto';
import { JwtAuthGuard } from './guards';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
async register(
@Body() registerDto: RegisterDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.authService.register(registerDto);
// JWT als httpOnly Cookie setzen
res.cookie('access_token', result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // nur über HTTPS in Produktion
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // lax für Cross-Origin in Dev
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
});
return {
message: result.message,
user: result.user,
};
}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body() loginDto: LoginDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.authService.login(loginDto);
// JWT als httpOnly Cookie setzen
res.cookie('access_token', result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // lax für Cross-Origin in Dev
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
});
return {
message: result.message,
user: result.user,
};
}
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
return this.authService.forgotPassword(forgotPasswordDto.email);
}
@Post('reset-password')
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.authService.resetPassword(resetPasswordDto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('access_token');
return { message: 'Logout erfolgreich' };
}
@Get('me')
@UseGuards(JwtAuthGuard)
getProfile(@Req() req: Request) {
return req.user;
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PrismaModule } from '../prisma/prisma.module';
import { MailModule } from '../mail/mail.module';
@Module({
imports: [
PrismaModule,
MailModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
signOptions: {
expiresIn: '7d'
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,193 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { RegisterDto, LoginDto } from './dto';
import { MailService } from '../mail/mail.service';
import { ResetPasswordDto } from './dto/reset-password.dto';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private mailService: MailService,
) {}
async register(registerDto: RegisterDto) {
const { email, username, password, role } = registerDto;
// Prüfen ob User bereits existiert
const existingUser = await this.prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
if (existingUser) {
if (existingUser.email === email) {
throw new ConflictException('Email bereits registriert');
}
if (existingUser.username === username) {
throw new ConflictException('Username bereits vergeben');
}
}
// Password hashen
const hashedPassword = await bcrypt.hash(password, 10);
// User erstellen
const user = await this.prisma.user.create({
data: {
email,
username,
password: hashedPassword,
role: role || 'Student',
},
});
// Willkommens-Email senden
try {
await this.mailService.sendUserConfirmation(user);
} catch (error) {
// Log error but don't fail registration
console.error('Email sending failed:', error);
}
// JWT Token generieren
const token = await this.generateToken(user.id, user.username, user.role, user.email);
return {
message: 'Registrierung erfolgreich',
user: {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
},
token,
};
}
async login(loginDto: LoginDto) {
const { username, password } = loginDto;
// User finden
const user = await this.prisma.user.findUnique({
where: { username },
});
if (!user) {
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
// Password vergleichen
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
// JWT Token generieren
const token = await this.generateToken(user.id, user.username, user.role, user.email);
return {
message: 'Login erfolgreich',
user: {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
},
token,
};
}
async forgotPassword(email: string) {
const user = await this.prisma.user.findUnique({
where: { email },
});
if (user) {
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date();
expires.setHours(expires.getHours() + 1); // 1 Stunde gültig
await this.prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpires: expires,
},
});
try {
await this.mailService.sendPasswordReset(user, token);
} catch (error) {
console.error('Email sending failed:', error);
}
}
// Immer die gleiche Nachricht zurückgeben, egal ob User existiert oder nicht
return { message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.' };
}
async resetPassword(resetPasswordDto: ResetPasswordDto) {
const { token, newPassword } = resetPasswordDto;
const user = await this.prisma.user.findFirst({
where: {
passwordResetToken: token,
passwordResetExpires: {
gt: new Date(),
},
},
});
if (!user) {
throw new BadRequestException('Ungültiger oder abgelaufener Token');
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await this.prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
passwordResetToken: null,
passwordResetExpires: null,
},
});
return { message: 'Passwort erfolgreich geändert' };
}
async validateUser(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException('User nicht gefunden');
}
return {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
};
}
private async generateToken(userId: number, username: string, role: string, email?: string) {
const payload = { sub: userId, username, role, email };
return this.jwtService.sign(payload);
}
}

View File

@@ -0,0 +1,7 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
export class ForgotPasswordDto {
@IsEmail()
@IsNotEmpty()
email: string;
}

View File

@@ -0,0 +1,4 @@
export * from './login.dto';
export * from './register.dto';
export * from './forgot-password.dto';
export * from './reset-password.dto';

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
}

View File

@@ -0,0 +1,22 @@
import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional, IsIn } from 'class-validator';
export class RegisterDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(3)
username: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
@IsOptional()
@IsString()
@IsIn(['Student', 'Lehrer'])
role?: string = 'Student';
}

View File

@@ -0,0 +1,12 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@IsString()
@IsNotEmpty()
token: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
newPassword: string;
}

View File

@@ -0,0 +1,3 @@
export * from './jwt-auth.guard';
export * from './ws-jwt.guard';
export * from './roles.guard';

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get(Roles, context.getHandler());
if (!requiredRoles) {
return true; // No role required
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.role) {
throw new ForbiddenException('Zugriff verweigert - Keine Rolle gesetzt');
}
if (!requiredRoles.includes(user.role)) {
throw new ForbiddenException(
`Zugriff verweigert - Erforderliche Rolle(n): ${requiredRoles.join(', ')}`
);
}
return true;
}
}

View File

@@ -0,0 +1,58 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
@Injectable()
export class WsJwtGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
try {
const client: Socket = context.switchToWs().getClient<Socket>();
const token = this.extractTokenFromHandshake(client);
if (!token) {
throw new WsException('Unauthorized: No token provided');
}
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
});
// User-Daten zum Socket hinzufügen
client.data.user = payload;
return true;
} catch (error) {
throw new WsException('Unauthorized: Invalid token');
}
}
private extractTokenFromHandshake(client: Socket): string | null {
// Token aus Cookie extrahieren
const cookies = client.handshake.headers.cookie;
if (cookies) {
const cookieArray = cookies.split(';');
for (const cookie of cookieArray) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
return value;
}
}
}
// Fallback: Token aus auth object (wenn vom Client übergeben)
const token = client.handshake.auth?.token;
if (token) {
return token;
}
// Fallback: Token aus query parameter
const queryToken = client.handshake.query?.token as string;
if (queryToken) {
return queryToken;
}
return null;
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
import { AuthService } from '../auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
// JWT aus Cookie extrahieren
return request?.cookies?.access_token;
},
ExtractJwt.fromAuthHeaderAsBearerToken(), // Fallback: Authorization Header
]),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
});
}
async validate(payload: any) {
const user = await this.authService.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
// Rückgabe des Payloads mit User-Daten
return {
id: payload.sub,
sub: payload.sub,
username: payload.username,
email: payload.email,
role: payload.role,
};
}
}

View File

@@ -0,0 +1,96 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
import { ChatService } from './chat.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
import { AddMembersDto } from './dto/add-members.dto';
import { JwtAuthGuard } from '../auth/guards';
@Controller('live')
@UseGuards(JwtAuthGuard)
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get()
getChatInfo(): object {
return {
message: 'Chat API is running',
websocketPath: '/api/live',
};
}
// Gruppen-Verwaltung
@Post('groups')
createGroup(@Request() req, @Body() dto: CreateGroupDto) {
return this.chatService.createGroup(req.user.sub, dto);
}
@Get('groups')
getAllGroups() {
return this.chatService.getAllGroups();
}
@Get('groups/my')
getUserGroups(@Request() req) {
return this.chatService.getUserGroups(req.user.sub);
}
@Get('groups/:id')
getGroupById(@Param('id', ParseIntPipe) id: number) {
return this.chatService.getGroupById(id);
}
@Put('groups/:id')
updateGroup(
@Param('id', ParseIntPipe) id: number,
@Request() req,
@Body() dto: UpdateGroupDto,
) {
return this.chatService.updateGroup(id, req.user.sub, req.user.role, dto);
}
@Delete('groups/:id')
deleteGroup(@Param('id', ParseIntPipe) id: number, @Request() req) {
return this.chatService.deleteGroup(id, req.user.sub, req.user.role);
}
@Post('groups/:id/members')
addMembers(
@Param('id', ParseIntPipe) id: number,
@Request() req,
@Body() dto: AddMembersDto,
) {
return this.chatService.addMembers(id, req.user.sub, req.user.role, dto);
}
@Delete('groups/:id/members/:userId')
removeMember(
@Param('id', ParseIntPipe) id: number,
@Param('userId', ParseIntPipe) userId: number,
@Request() req,
) {
return this.chatService.removeMember(id, userId, req.user.sub, req.user.role);
}
// Nachrichten
@Get('groups/:id/messages')
getGroupMessages(
@Param('id', ParseIntPipe) id: number,
@Request() req,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.chatService.getGroupMessages(id, req.user.sub, limit, offset);
}
}

View File

@@ -0,0 +1,233 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { WsJwtGuard } from '../auth/guards';
import { ChatService } from './chat.service';
@WebSocketGateway({
namespace: '/api/live',
cors: {
origin: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
},
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(
private readonly jwtService: JwtService,
private readonly chatService: ChatService,
) {}
// ... (handleConnection stays same)
handleConnection(client: Socket) {
try {
const token = this.extractTokenFromHandshake(client);
if (!token) {
console.log(`Client ${client.id} rejected: No token provided`);
client.emit('error', { message: 'Unauthorized: No token provided' });
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
});
// User-Daten zum Socket hinzufügen
client.data.user = payload;
console.log(`Client connected: ${client.id}, User: ${payload.username}`);
// Willkommensnachricht an den verbundenen Client senden
client.emit('message', {
type: 'system',
text: `Willkommen im Chat, ${payload.username}!`,
timestamp: Date.now(),
});
} catch (error) {
console.log(`Client ${client.id} rejected: Invalid token`);
client.emit('error', { message: 'Unauthorized: Invalid token' });
client.disconnect();
}
}
private extractTokenFromHandshake(client: Socket): string | null {
// Token aus Cookie extrahieren
const cookies = client.handshake.headers.cookie;
if (cookies) {
const cookieArray = cookies.split(';');
for (const cookie of cookieArray) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
return value;
}
}
}
// Fallback: Token aus auth object
const token = client.handshake.auth?.token;
if (token) {
return token;
}
// Fallback: Token aus query parameter
const queryToken = client.handshake.query?.token as string;
if (queryToken) {
return queryToken;
}
return null;
}
handleDisconnect(client: Socket) {
const user = client.data.user;
if (user) {
console.log(`Client disconnected: ${client.id} (${user.username})`);
// Notify all rooms the user was in
for (const room of client.rooms) {
if (room.startsWith('group-')) {
const groupId = parseInt(room.replace('group-', ''));
if (!isNaN(groupId)) {
this.server.to(room).emit('message', {
type: 'system',
text: `${user.username} hat den Chat verlassen`,
timestamp: Date.now(),
groupId,
});
// Also emit specific event if needed
// client.to(room).emit('user-left', { userId: user.sub, groupId });
}
}
}
} else {
console.log(`Client disconnected: ${client.id}`);
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('join-group')
async handleJoinGroup(
@MessageBody() data: { groupId: number },
@ConnectedSocket() client: Socket,
) {
const user = client.data.user;
const groupId = data.groupId;
try {
// Prüfen ob User Mitglied der Gruppe ist
const group = await this.chatService.getGroupById(groupId);
const isMember = group.members.some((m) => m.userId === user.sub);
if (!isMember) {
client.emit('error', { message: 'Sie sind kein Mitglied dieser Gruppe' });
return;
}
// Socket der Gruppe beitreten (für Room-basierte Nachrichten)
client.join(`group-${groupId}`);
console.log(`User ${user.username} joined group ${groupId}`);
// Bestätigung senden
client.emit('joined-group', { groupId });
// System-Nachricht an die Gruppe senden
this.server.to(`group-${groupId}`).emit('message', {
type: 'system',
text: `${user.username} ist dem Chat beigetreten`,
timestamp: Date.now(),
groupId,
});
} catch (error) {
console.error('Error joining group:', error);
client.emit('error', { message: 'Fehler beim Beitreten zur Gruppe' });
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('leave-group')
handleLeaveGroup(
@MessageBody() data: { groupId: number },
@ConnectedSocket() client: Socket,
) {
const user = client.data.user;
const groupId = data.groupId;
client.leave(`group-${groupId}`);
console.log(`User ${user.username} left group ${groupId}`);
// System-Nachricht an die Gruppe senden
this.server.to(`group-${groupId}`).emit('message', {
type: 'system',
text: `${user.username} hat den Chat verlassen`,
timestamp: Date.now(),
groupId,
});
client.emit('left-group', { groupId });
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('message')
async handleMessage(
@MessageBody() data: { message: string; groupId: number },
@ConnectedSocket() client: Socket,
): Promise<void> {
const user = client.data.user;
console.log(`Message from ${client.id} (${user?.username}) to group ${data.groupId}:`, data.message);
try {
// Nachricht in DB speichern
const savedMessage = await this.chatService.createMessage(
data.groupId,
user.sub,
data.message,
);
// Nachricht an alle Clients in der Gruppe senden
this.server.to(`group-${data.groupId}`).emit('message', {
type: 'chat',
id: savedMessage.id,
message: savedMessage.content,
userId: savedMessage.userId,
username: savedMessage.user.username,
groupId: data.groupId,
timestamp: savedMessage.createdAt.getTime(),
});
} catch (error) {
console.error('Error saving message:', error);
client.emit('error', {
message: error.message || 'Fehler beim Senden der Nachricht',
});
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('typing')
handleTyping(
@MessageBody() data: { groupId: number; isTyping: boolean },
@ConnectedSocket() client: Socket,
): void {
const user = client.data.user;
// Typing-Indikator an andere Clients in der Gruppe senden (nicht an sich selbst)
client.to(`group-${data.groupId}`).emit('user-typing', {
userId: user.sub,
username: user.username,
groupId: data.groupId,
isTyping: data.isTyping,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ChatService } from './chat.service';
import { ChatGateway } from './chat.gateway';
import { ChatController } from './chat.controller';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [ChatController],
providers: [ChatGateway, ChatService],
})
export class ChatModule {}

View File

@@ -0,0 +1,332 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
import { AddMembersDto } from './dto/add-members.dto';
@Injectable()
export class ChatService {
constructor(private prisma: PrismaService) {}
// Gruppen-Verwaltung
async createGroup(createdById: number, dto: CreateGroupDto) {
const group = await this.prisma.chatGroup.create({
data: {
name: dto.name,
description: dto.description,
createdById,
members: {
create: [
// Ersteller ist automatisch Mitglied
{ userId: createdById },
// Weitere Mitglieder hinzufügen
...(dto.memberIds || []).map((userId) => ({ userId })),
],
},
},
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
},
},
});
return group;
}
async getAllGroups() {
return this.prisma.chatGroup.findMany({
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
},
_count: {
select: {
messages: true,
members: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
});
}
async getGroupById(groupId: number) {
const group = await this.prisma.chatGroup.findUnique({
where: { id: groupId },
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
},
_count: {
select: {
messages: true,
},
},
},
});
if (!group) {
throw new NotFoundException('Gruppe nicht gefunden');
}
return group;
}
async getUserGroups(userId: number) {
const memberships = await this.prisma.chatGroupMember.findMany({
where: { userId },
include: {
group: {
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
},
_count: {
select: {
messages: true,
members: true,
},
},
},
},
},
orderBy: {
group: {
updatedAt: 'desc',
},
},
});
return memberships.map((m) => m.group);
}
async updateGroup(groupId: number, userId: number, userRole: string, dto: UpdateGroupDto) {
const group = await this.getGroupById(groupId);
// Nur Lehrer oder der Ersteller kann die Gruppe bearbeiten
if (userRole !== 'Lehrer' && group.createdById !== userId) {
throw new ForbiddenException('Keine Berechtigung zum Bearbeiten dieser Gruppe');
}
return this.prisma.chatGroup.update({
where: { id: groupId },
data: {
name: dto.name,
description: dto.description,
},
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
},
},
});
}
async deleteGroup(groupId: number, userId: number, userRole: string) {
const group = await this.getGroupById(groupId);
// Nur Lehrer können Gruppen löschen
if (userRole !== 'Lehrer') {
throw new ForbiddenException('Nur Lehrer können Gruppen löschen');
}
await this.prisma.chatGroup.delete({
where: { id: groupId },
});
return { message: 'Gruppe erfolgreich gelöscht' };
}
async addMembers(groupId: number, userId: number, userRole: string, dto: AddMembersDto) {
const group = await this.getGroupById(groupId);
// Nur Lehrer können Mitglieder hinzufügen
if (userRole !== 'Lehrer') {
throw new ForbiddenException('Nur Lehrer können Mitglieder hinzufügen');
}
// Mitglieder hinzufügen (ignoriert bereits existierende)
const newMembers = await Promise.all(
dto.userIds.map((userId) =>
this.prisma.chatGroupMember.upsert({
where: {
userId_groupId: {
userId,
groupId,
},
},
create: {
userId,
groupId,
},
update: {},
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
}),
),
);
return newMembers;
}
async removeMember(groupId: number, memberUserId: number, requestUserId: number, userRole: string) {
const group = await this.getGroupById(groupId);
// Nur Lehrer können Mitglieder entfernen
if (userRole !== 'Lehrer') {
throw new ForbiddenException('Nur Lehrer können Mitglieder entfernen');
}
// Ersteller kann nicht entfernt werden
if (memberUserId === group.createdById) {
throw new ForbiddenException('Der Ersteller der Gruppe kann nicht entfernt werden');
}
await this.prisma.chatGroupMember.delete({
where: {
userId_groupId: {
userId: memberUserId,
groupId,
},
},
});
return { message: 'Mitglied erfolgreich entfernt' };
}
// Nachrichten-Verwaltung
async getGroupMessages(groupId: number, userId: number, limit = 100, offset = 0) {
// Prüfen ob User Mitglied der Gruppe ist
const membership = await this.prisma.chatGroupMember.findUnique({
where: {
userId_groupId: {
userId,
groupId,
},
},
});
if (!membership) {
throw new ForbiddenException('Sie sind kein Mitglied dieser Gruppe');
}
const messages = await this.prisma.chatMessage.findMany({
where: { groupId },
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: limit,
skip: offset,
});
return messages.reverse(); // Älteste zuerst
}
async createMessage(groupId: number, userId: number, content: string) {
// Prüfen ob User Mitglied der Gruppe ist
const membership = await this.prisma.chatGroupMember.findUnique({
where: {
userId_groupId: {
userId,
groupId,
},
},
});
if (!membership) {
throw new ForbiddenException('Sie sind kein Mitglied dieser Gruppe');
}
const message = await this.prisma.chatMessage.create({
data: {
content,
userId,
groupId,
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
role: true,
},
},
},
});
// Update group's updatedAt
await this.prisma.chatGroup.update({
where: { id: groupId },
data: { updatedAt: new Date() },
});
return message;
}
}

View File

@@ -0,0 +1,7 @@
import { IsArray, IsNumber } from 'class-validator';
export class AddMembersDto {
@IsArray()
@IsNumber({}, { each: true })
userIds: number[];
}

View File

@@ -0,0 +1,15 @@
import { IsString, IsOptional, IsArray, IsNumber } from 'class-validator';
export class CreateGroupDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
memberIds?: number[];
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsOptional } from 'class-validator';
export class UpdateGroupDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,178 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { WsJwtGuard } from '../auth/guards';
import { ClassroomService } from './classroom.service';
@WebSocketGateway({
namespace: '/api/classroom',
cors: {
origin: [
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://localhost:5500',
'http://127.0.0.1:5500',
'http://localhost:3000'
],
credentials: true,
},
})
export class ClassroomGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(
private readonly jwtService: JwtService,
private readonly classroomService: ClassroomService,
) {}
handleConnection(client: Socket) {
try {
const token = this.extractTokenFromHandshake(client);
if (!token) {
console.log(`Classroom: Client ${client.id} rejected: No token`);
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
});
client.data.user = payload;
// Send current state to newly connected user
client.emit('state-update', {
raisedHands: this.classroomService.getRaisedHands(),
activeStudent: this.classroomService.getActiveStudent(),
});
} catch (error) {
console.log(`Classroom: Client ${client.id} rejected: Invalid token`);
client.disconnect();
}
}
handleDisconnect(client: Socket) {
// Optional: automatically lower hand on disconnect?
// For now, let's keep it persistent for 10 mins as per requirements.
}
private extractTokenFromHandshake(client: Socket): string | null {
const cookies = client.handshake.headers.cookie;
if (cookies) {
const cookieArray = cookies.split(';');
for (const cookie of cookieArray) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
return value;
}
}
}
return client.handshake.auth?.token || (client.handshake.query?.token as string) || null;
}
private broadcastState() {
this.server.emit('state-update', {
raisedHands: this.classroomService.getRaisedHands(),
activeStudent: this.classroomService.getActiveStudent(),
});
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('raise-hand')
handleRaiseHand(
@MessageBody() data: { type: 'normal' | 'question' },
@ConnectedSocket() client: Socket,
) {
const user = client.data.user;
this.classroomService.raiseHand(user.sub, user.username, data.type, (userId) => {
// Callback for timeout
this.classroomService.lowerHand(userId);
this.broadcastState();
});
this.broadcastState();
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('lower-hand')
handleLowerHand(@ConnectedSocket() client: Socket) {
const user = client.data.user;
this.classroomService.lowerHand(user.sub);
this.broadcastState();
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('lower-all-hands')
handleLowerAllHands(@ConnectedSocket() client: Socket) {
const user = client.data.user;
if (user.role !== 'Lehrer') {
client.emit('error', { message: 'Nur Lehrer können alle Hände senken.' });
return;
}
this.classroomService.lowerAllHands();
this.broadcastState();
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('pick-student')
handlePickStudent(
@MessageBody() data: { userId: number },
@ConnectedSocket() client: Socket,
) {
const user = client.data.user;
if (user.role !== 'Lehrer') {
client.emit('error', { message: 'Nur Lehrer können Schüler aufrufen.' });
return;
}
const hands = this.classroomService.getRaisedHands();
const target = hands.find(h => h.userId === data.userId);
if (target) {
this.classroomService.setActiveStudent(target.userId, target.username);
this.broadcastState();
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('pick-random')
handlePickRandom(@ConnectedSocket() client: Socket) {
const user = client.data.user;
if (user.role !== 'Lehrer') {
client.emit('error', { message: 'Nur Lehrer können Zufallsauswahl nutzen.' });
return;
}
this.classroomService.pickRandomStudent();
this.broadcastState();
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('reset-active')
handleResetActive(@ConnectedSocket() client: Socket) {
const user = client.data.user;
const activeStudent = this.classroomService.getActiveStudent();
// Allow if teacher OR if the requesting user is the active student
const isActiveStudent = activeStudent && activeStudent.userId === user.sub;
if (user.role !== 'Lehrer' && !isActiveStudent) {
client.emit('error', { message: 'Nur Lehrer oder der aktive Schüler können die Auswahl zurücksetzen.' });
return;
}
this.classroomService.clearActiveStudent();
this.broadcastState();
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ClassroomGateway } from './classroom.gateway';
import { ClassroomService } from './classroom.service';
import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
AuthModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
signOptions: { expiresIn: '7d' },
}),
],
providers: [ClassroomGateway, ClassroomService],
exports: [ClassroomService],
})
export class ClassroomModule {}

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
export interface RaisedHand {
userId: number;
username: string;
type: 'normal' | 'question';
raisedAt: Date;
timeoutId?: NodeJS.Timeout;
}
@Injectable()
export class ClassroomService {
private raisedHands: Map<number, RaisedHand> = new Map();
private activeStudent: { userId: number; username: string } | null = null;
raiseHand(userId: number, username: string, type: 'normal' | 'question', onTimeout: (userId: number) => void) {
// Clear existing timeout if re-raising or changing type
const existingHand = this.raisedHands.get(userId);
if (existingHand) {
clearTimeout(existingHand.timeoutId);
}
const timeoutId = setTimeout(() => {
onTimeout(userId);
}, 10 * 60 * 1000); // 10 minutes
this.raisedHands.set(userId, {
userId,
username,
type,
raisedAt: new Date(),
timeoutId,
});
}
lowerHand(userId: number) {
const hand = this.raisedHands.get(userId);
if (hand) {
clearTimeout(hand.timeoutId);
this.raisedHands.delete(userId);
return true;
}
return false;
}
lowerAllHands() {
this.raisedHands.forEach((hand) => clearTimeout(hand.timeoutId));
this.raisedHands.clear();
}
getRaisedHands() {
// Return array without timeoutId (not serializable/needed for frontend)
return Array.from(this.raisedHands.values()).map(({ timeoutId, ...rest }) => rest);
}
setActiveStudent(userId: number, username: string) {
this.activeStudent = { userId, username };
// Also lower the hand of the picked student
this.lowerHand(userId);
}
clearActiveStudent() {
this.activeStudent = null;
}
getActiveStudent() {
return this.activeStudent;
}
pickRandomStudent(): { userId: number; username: string } | null {
const hands = Array.from(this.raisedHands.values());
if (hands.length === 0) return null;
const randomIndex = Math.floor(Math.random() * hands.length);
const selected = hands[randomIndex];
this.setActiveStudent(selected.userId, selected.username);
return { userId: selected.userId, username: selected.username };
}
}

View File

@@ -0,0 +1,96 @@
import {
IsNumber,
IsBoolean,
IsOptional,
IsDateString,
Min,
Max,
ValidateNested,
IsInt,
IsString,
MaxLength
} from 'class-validator';
import { Type } from 'class-transformer';
class FeedbackCategories {
@IsOptional()
@IsNumber()
@Min(1, { message: 'Clarity rating must be between 1 and 10' })
@Max(10, { message: 'Clarity rating must be between 1 and 10' })
clarity?: number;
@IsOptional()
@IsNumber()
@Min(1, { message: 'Pace rating must be between 1 and 10' })
@Max(10, { message: 'Pace rating must be between 1 and 10' })
pace?: number;
@IsOptional()
@IsNumber()
@Min(1, { message: 'Interaction rating must be between 1 and 10' })
@Max(10, { message: 'Interaction rating must be between 1 and 10' })
interaction?: number;
@IsOptional()
@IsNumber()
@Min(1, { message: 'Materials rating must be between 1 and 10' })
@Max(10, { message: 'Materials rating must be between 1 and 10' })
materials?: number;
@IsOptional()
@IsNumber()
@Min(1, { message: 'Atmosphere rating must be between 1 and 10' })
@Max(10, { message: 'Atmosphere rating must be between 1 and 10' })
atmosphere?: number;
}
export class CreateFeedbackDto {
@Type(() => Number)
@IsInt({ message: 'Student ID must be an integer' })
studentId: number;
@Type(() => Number)
@IsInt({ message: 'Teacher ID must be an integer' })
teacherId: number;
@Type(() => Number)
@IsInt({ message: 'Lesson ID must be an integer' })
lessonId: number;
@IsDateString({}, { message: 'Lesson date must be a valid ISO 8601 date string' })
lessonDate: Date;
@Type(() => Number)
@IsNumber()
@Min(1, { message: 'Overall rating must be between 1 and 10' })
@Max(10, { message: 'Overall rating must be between 1 and 10' })
overallRating: number;
@IsOptional()
@ValidateNested()
@Type(() => FeedbackCategories)
categories?: FeedbackCategories;
@IsOptional()
@IsString()
@MaxLength(2000, { message: 'What was good text cannot exceed 2000 characters' })
whatWasGood?: string;
@IsOptional()
@IsString()
@MaxLength(2000, { message: 'What can improve text cannot exceed 2000 characters' })
whatCanImprove?: string;
@IsOptional()
@IsString()
@MaxLength(2000, { message: 'Additional comments cannot exceed 2000 characters' })
additionalComments?: string;
@IsOptional()
@IsBoolean()
isAnonymous?: boolean;
@IsOptional()
@IsBoolean()
allowTeacherResponse?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFeedbackDto } from './create-feedback.dto';
export class UpdateFeedbackDto extends PartialType(CreateFeedbackDto) {}

View File

@@ -0,0 +1,133 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards, Req, BadRequestException } from '@nestjs/common';
import type { Request } from 'express';
import { FeedbackService } from './feedback.service';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
import { UpdateFeedbackDto } from './dto/update-feedback.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
@Controller('feedback')
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
// Feedback einreichen (nur von Schülern)
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Student'])
create(@Body() createFeedbackDto: CreateFeedbackDto) {
return this.feedbackService.create(createFeedbackDto);
}
@Get()
getChatInfo() :object {
return{
message: 'Feedback API is running'
}
}
// Alle Feedbacks abrufen - NUR FÜR DEN EINGELOGGTEN LEHRER
@Get('all')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
findAll(@Req() req: Request) {
const user = req.user as any;
const teacherId = Number(user.id);
if (isNaN(teacherId)) {
throw new BadRequestException('Ungültige Lehrer-ID');
}
return this.feedbackService.findByTeacher(teacherId);
}
// Einzelnes Feedback abrufen - NUR FÜR LEHRER
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
findOne(@Param('id') id: string) {
return this.feedbackService.findOne(id);
}
// Feedbacks nach Lehrer-ID - NUR FÜR LEHRER
@Get('teacher/:teacherId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
findByTeacher(@Param('teacherId') teacherId: string) {
const id = parseInt(teacherId);
if (isNaN(id)) {
throw new BadRequestException('Ungültige Lehrer-ID');
}
return this.feedbackService.findByTeacher(id);
}
// Feedbacks nach Schüler-ID - Authentifizierung erforderlich
@Get('student/:studentId')
@UseGuards(JwtAuthGuard)
findByStudent(@Param('studentId') studentId: string) {
const id = parseInt(studentId);
if (isNaN(id)) {
throw new BadRequestException('Ungültige Schüler-ID');
}
return this.feedbackService.findByStudent(id);
}
// Feedbacks nach Unterrichtsstunden-ID - Authentifizierung erforderlich
@Get('lesson/:lessonId')
@UseGuards(JwtAuthGuard)
findByLesson(@Param('lessonId') lessonId: string) {
const id = parseInt(lessonId);
if (isNaN(id)) {
throw new BadRequestException('Ungültige Unterrichtsstunden-ID');
}
return this.feedbackService.findByLesson(id);
}
// Statistiken für einen Lehrer - NUR FÜR LEHRER
@Get('statistics/teacher/:teacherId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
getTeacherStatistics(@Param('teacherId') teacherId: string) {
const id = parseInt(teacherId);
if (isNaN(id)) {
throw new BadRequestException('Ungültige Lehrer-ID');
}
return this.feedbackService.getTeacherStatistics(id);
}
// Statistiken für eine Unterrichtsstunde - NUR FÜR LEHRER
@Get('statistics/lesson/:lessonId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
getLessonStatistics(@Param('lessonId') lessonId: string) {
const id = parseInt(lessonId);
if (isNaN(id)) {
throw new BadRequestException('Ungültige Unterrichtsstunden-ID');
}
return this.feedbackService.getLessonStatistics(id);
}
// Lehrer antwortet auf Feedback - NUR FÜR LEHRER
@Post(':id/response')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
addTeacherResponse(
@Param('id') id: string,
@Body() body: { response: string },
) {
return this.feedbackService.addTeacherResponse(id, body.response);
}
// Feedback aktualisieren - NUR FÜR LEHRER
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
update(@Param('id') id: string, @Body() updateFeedbackDto: UpdateFeedbackDto) {
return this.feedbackService.update(id, updateFeedbackDto);
}
// Feedback löschen - NUR FÜR LEHRER
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(['Lehrer'])
async remove(@Param('id') id: string) {
await this.feedbackService.remove(id);
return { success: true, message: 'Feedback erfolgreich gelöscht' };
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FeedbackService } from './feedback.service';
import { FeedbackController } from './feedback.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [FeedbackController],
providers: [FeedbackService],
})
export class FeedbackModule {}

View File

@@ -0,0 +1,623 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
import { UpdateFeedbackDto } from './dto/update-feedback.dto';
import { Prisma } from '@prisma/client';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
// Custom Feedback Interface für API Response (mit Relationen)
export interface Feedback {
id: string;
studentId: number;
student?: {
id: number;
username: string;
email: string;
};
teacherId: number;
teacher?: {
id: number;
username: string;
email: string;
};
lessonId: number;
timetableEntry?: {
id: number;
subject: {
id: number;
name: string;
abbreviation?: string | null;
color?: string | null;
};
teacher?: {
id: number;
username: string;
email: string;
} | null;
room?: {
id: number;
number: string;
} | null;
dayOfWeek: number;
startTime: string;
endTime: string;
};
lessonDate: Date;
overallRating: number;
categories?: any;
whatWasGood?: string | null;
whatCanImprove?: string | null;
additionalComments?: string | null;
isAnonymous: boolean;
allowTeacherResponse: boolean;
teacherResponse?: string | null;
teacherRespondedAt?: Date | null;
createdAt?: Date;
updatedAt?: Date;
}
@Injectable()
export class FeedbackService {
constructor(private prisma: PrismaService) {}
/**
* Hilfsfunktion: Entfernt student-Details bei anonymen Feedbacks
*/
private sanitizeFeedback(feedback: any): Feedback {
if (feedback.isAnonymous) {
const { student, studentId, ...sanitizedFeedback } = feedback;
return this.convertToFeedback(sanitizedFeedback);
}
return this.convertToFeedback(feedback);
}
/**
* Hilfsfunktion: Sanitiert ein Array von Feedbacks
*/
private sanitizeFeedbacks(feedbacks: any[]): Feedback[] {
return feedbacks.map(fb => this.sanitizeFeedback(fb));
}
/**
* Konvertiert Prisma Feedback zu API Feedback
*/
private convertToFeedback(prismaFeedback: any): Feedback {
return {
id: prismaFeedback.id?.toString(),
studentId: prismaFeedback.studentId,
student: prismaFeedback.student,
teacherId: prismaFeedback.teacherId,
teacher: prismaFeedback.teacher,
lessonId: prismaFeedback.lessonId,
timetableEntry: prismaFeedback.timetableEntry,
lessonDate: prismaFeedback.lessonDate,
overallRating: prismaFeedback.overallRating,
categories: prismaFeedback.categories as any,
whatWasGood: prismaFeedback.whatWasGood,
whatCanImprove: prismaFeedback.whatCanImprove,
additionalComments: prismaFeedback.additionalComments,
isAnonymous: prismaFeedback.isAnonymous,
allowTeacherResponse: prismaFeedback.allowTeacherResponse,
teacherResponse: prismaFeedback.teacherResponse,
teacherRespondedAt: prismaFeedback.teacherRespondedAt,
createdAt: prismaFeedback.createdAt,
updatedAt: prismaFeedback.updatedAt,
};
}
async create(createFeedbackDto: CreateFeedbackDto): Promise<Feedback> {
try {
// Validiere Foreign Key Beziehungen
const [student, teacher, lesson] = await Promise.all([
this.prisma.user.findUnique({ where: { id: createFeedbackDto.studentId } }),
this.prisma.user.findUnique({ where: { id: createFeedbackDto.teacherId } }),
this.prisma.timetableEntry.findUnique({ where: { id: createFeedbackDto.lessonId } }),
]);
if (!student) {
throw new NotFoundException(`Schüler mit ID ${createFeedbackDto.studentId} nicht gefunden`);
}
if (!teacher) {
throw new NotFoundException(`Lehrer mit ID ${createFeedbackDto.teacherId} nicht gefunden`);
}
if (!lesson) {
throw new NotFoundException(`Unterrichtsstunde mit ID ${createFeedbackDto.lessonId} nicht gefunden`);
}
// Erstelle Feedback mit Transaction für Datenintegrität
const feedback = await this.prisma.$transaction(async (tx) => {
return await tx.feedback.create({
data: {
studentId: createFeedbackDto.studentId,
teacherId: createFeedbackDto.teacherId,
lessonId: createFeedbackDto.lessonId,
lessonDate: new Date(createFeedbackDto.lessonDate),
overallRating: createFeedbackDto.overallRating,
categories: createFeedbackDto.categories ? JSON.parse(JSON.stringify(createFeedbackDto.categories)) : null,
whatWasGood: createFeedbackDto.whatWasGood,
whatCanImprove: createFeedbackDto.whatCanImprove,
additionalComments: createFeedbackDto.additionalComments,
isAnonymous: createFeedbackDto.isAnonymous || false,
allowTeacherResponse: createFeedbackDto.allowTeacherResponse !== undefined
? createFeedbackDto.allowTeacherResponse
: true,
},
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: {
select: {
id: true,
number: true,
},
},
},
},
},
});
});
return this.sanitizeFeedback(feedback);
} catch (error) {
// Handle Prisma Unique Constraint Error
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new ConflictException('Sie haben für diese Stunde bereits Feedback abgegeben');
}
if (error.code === 'P2003') {
throw new BadRequestException('Ungültige Referenz: Schüler, Lehrer oder Unterrichtsstunde existiert nicht');
}
}
throw error;
}
}
async findAll(): Promise<Feedback[]> {
const feedbacks = await this.prisma.feedback.findMany({
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: {
select: {
id: true,
number: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return this.sanitizeFeedbacks(feedbacks);
}
async findOne(id: string): Promise<Feedback> {
const feedback = await this.prisma.feedback.findUnique({
where: { id: parseInt(id) },
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: {
select: {
id: true,
number: true,
},
},
},
},
},
});
if (!feedback) {
throw new NotFoundException(`Feedback mit ID ${id} nicht gefunden`);
}
return this.sanitizeFeedback(feedback);
}
async findByTeacher(teacherId: number): Promise<Feedback[]> {
const feedbacks = await this.prisma.feedback.findMany({
where: { teacherId },
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: {
select: {
id: true,
number: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return this.sanitizeFeedbacks(feedbacks);
}
async findByStudent(studentId: number): Promise<Feedback[]> {
const feedbacks = await this.prisma.feedback.findMany({
where: { studentId },
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: {
select: {
id: true,
number: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return this.sanitizeFeedbacks(feedbacks);
}
async findByLesson(lessonId: number): Promise<Feedback[]> {
const feedbacks = await this.prisma.feedback.findMany({
where: { lessonId },
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: {
select: {
id: true,
number: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return this.sanitizeFeedbacks(feedbacks);
}
async update(id: string, updateFeedbackDto: UpdateFeedbackDto): Promise<Feedback> {
const feedbackId = parseInt(id);
if (isNaN(feedbackId)) {
throw new BadRequestException('Ungültige Feedback-ID');
}
try {
const updatedFeedback = await this.prisma.$transaction(async (tx) => {
const feedback = await tx.feedback.findUnique({
where: { id: feedbackId },
});
if (!feedback) {
throw new NotFoundException(`Feedback mit ID ${id} nicht gefunden`);
}
return await tx.feedback.update({
where: { id: feedbackId },
data: {
...updateFeedbackDto,
categories: updateFeedbackDto.categories
? JSON.parse(JSON.stringify(updateFeedbackDto.categories))
: undefined,
},
});
});
return this.sanitizeFeedback(updatedFeedback);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
throw new BadRequestException('Fehler beim Aktualisieren des Feedbacks');
}
}
async remove(id: string): Promise<void> {
const feedbackId = parseInt(id);
if (isNaN(feedbackId)) {
throw new BadRequestException('Ungültige Feedback-ID');
}
try {
await this.prisma.$transaction(async (tx) => {
const feedback = await tx.feedback.findUnique({
where: { id: feedbackId },
});
if (!feedback) {
throw new NotFoundException(`Feedback mit ID ${id} nicht gefunden`);
}
await tx.feedback.delete({
where: { id: feedbackId },
});
});
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
throw new BadRequestException('Fehler beim Löschen des Feedbacks');
}
}
async addTeacherResponse(id: string, response: string): Promise<Feedback> {
const feedbackId = parseInt(id);
if (isNaN(feedbackId)) {
throw new BadRequestException('Ungültige Feedback-ID');
}
if (!response || response.trim().length === 0) {
throw new BadRequestException('Die Antwort darf nicht leer sein');
}
try {
const updatedFeedback = await this.prisma.$transaction(async (tx) => {
const feedback = await tx.feedback.findUnique({
where: { id: feedbackId },
});
if (!feedback) {
throw new NotFoundException(`Feedback mit ID ${id} nicht gefunden`);
}
if (!feedback.allowTeacherResponse) {
throw new BadRequestException('Lehrer-Antwort ist für dieses Feedback nicht erlaubt');
}
return await tx.feedback.update({
where: { id: feedbackId },
data: {
teacherResponse: response,
teacherRespondedAt: new Date(),
},
});
});
return this.sanitizeFeedback(updatedFeedback);
} catch (error) {
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Fehler beim Hinzufügen der Lehrer-Antwort');
}
}
// Statistiken für Lehrer
async getTeacherStatistics(teacherId: number) {
const teacherFeedbacks = await this.findByTeacher(teacherId);
if (teacherFeedbacks.length === 0) {
return {
totalFeedbacks: 0,
averageRating: 0,
categoryAverages: {
clarity: 0,
pace: 0,
interaction: 0,
materials: 0,
atmosphere: 0,
},
ratingDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 },
};
}
// Durchschnittliche Gesamtbewertung
const averageRating =
teacherFeedbacks.reduce((sum, fb) => sum + fb.overallRating, 0) /
teacherFeedbacks.length;
// Kategorien-Durchschnitte
const categories: {
clarity: number[];
pace: number[];
interaction: number[];
materials: number[];
atmosphere: number[];
} = {
clarity: [],
pace: [],
interaction: [],
materials: [],
atmosphere: [],
};
teacherFeedbacks.forEach((fb) => {
if (fb.categories) {
if (fb.categories.clarity) categories.clarity.push(fb.categories.clarity);
if (fb.categories.pace) categories.pace.push(fb.categories.pace);
if (fb.categories.interaction) categories.interaction.push(fb.categories.interaction);
if (fb.categories.materials) categories.materials.push(fb.categories.materials);
if (fb.categories.atmosphere) categories.atmosphere.push(fb.categories.atmosphere);
}
});
const categoryAverages = {
clarity: categories.clarity.length > 0
? categories.clarity.reduce((a, b) => a + b, 0) / categories.clarity.length
: 0,
pace: categories.pace.length > 0
? categories.pace.reduce((a, b) => a + b, 0) / categories.pace.length
: 0,
interaction: categories.interaction.length > 0
? categories.interaction.reduce((a, b) => a + b, 0) / categories.interaction.length
: 0,
materials: categories.materials.length > 0
? categories.materials.reduce((a, b) => a + b, 0) / categories.materials.length
: 0,
atmosphere: categories.atmosphere.length > 0
? categories.atmosphere.reduce((a, b) => a + b, 0) / categories.atmosphere.length
: 0,
};
// Verteilung der Bewertungen (1-10)
const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
teacherFeedbacks.forEach((fb) => {
if (fb.overallRating >= 1 && fb.overallRating <= 10) {
ratingDistribution[fb.overallRating]++;
}
});
return {
totalFeedbacks: teacherFeedbacks.length,
averageRating: Math.round(averageRating * 10) / 10,
categoryAverages,
ratingDistribution,
};
}
// Statistiken für eine Unterrichtsstunde
async getLessonStatistics(lessonId: number) {
const lessonFeedbacks = await this.findByLesson(lessonId);
if (lessonFeedbacks.length === 0) {
return {
totalFeedbacks: 0,
averageRating: 0,
feedbacks: [],
};
}
const averageRating =
lessonFeedbacks.reduce((sum, fb) => sum + fb.overallRating, 0) /
lessonFeedbacks.length;
return {
totalFeedbacks: lessonFeedbacks.length,
averageRating: Math.round(averageRating * 10) / 10,
feedbacks: lessonFeedbacks, // bereits sanitiert durch findByLesson
};
}
}

View File

@@ -0,0 +1,87 @@
import { Controller, Get, Post, Param, Delete, UseGuards, UploadedFile, UseInterceptors, Body, ForbiddenException, NotFoundException, Req, Res, ParseIntPipe } from '@nestjs/common';
import { FilesService } from './files.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import type { Response } from 'express';
import { PrismaService } from '../prisma/prisma.service';
import * as fs from 'fs';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FilesController {
constructor(
private readonly filesService: FilesService,
private readonly prisma: PrismaService
) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (req, file, callback) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = extname(file.originalname);
callback(null, `${uniqueSuffix}${ext}`);
},
}),
}))
async uploadFile(@UploadedFile() file: Express.Multer.File, @Body('timetableEntryId') timetableEntryId: string, @Req() req: any) {
const user = req.user;
const entryId = timetableEntryId ? parseInt(timetableEntryId) : undefined;
if (!file) {
throw new NotFoundException('No file uploaded');
}
try {
if (entryId) {
// Lesson Upload
const lesson = await this.prisma.timetableEntry.findUnique({ where: { id: entryId } });
if (!lesson) throw new NotFoundException('Lesson not found');
if (user.role !== 'Lehrer') {
// Student uploading to lesson
if (!lesson.allowStudentUploads) {
throw new ForbiddenException('Uploads not allowed for this lesson');
}
}
} else {
// General Upload
if (user.role !== 'Lehrer') {
throw new ForbiddenException('Only teachers can upload general files');
}
}
return await this.filesService.saveFile(file, user.id, entryId);
} catch (error) {
// Cleanup file if error
if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
throw error;
}
}
@Get()
async findAllGeneral() {
return this.filesService.findAllGeneral();
}
@Get('lesson/:id')
async findByLesson(@Param('id', ParseIntPipe) id: number, @Req() req: any) {
return this.filesService.findByLesson(id, req.user.id, req.user.role);
}
@Get('download/:id')
async downloadFile(@Param('id', ParseIntPipe) id: number, @Res() res: Response) {
const fileEntity = await this.filesService.getFileEntity(id);
res.download(fileEntity.path, fileEntity.filename);
}
@Delete(':id')
async deleteFile(@Param('id', ParseIntPipe) id: number, @Req() req: any) {
return this.filesService.deleteFile(id, req.user.id, req.user.role);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [FilesController],
providers: [FilesService]
})
export class FilesModule {}

View File

@@ -0,0 +1,93 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class FilesService {
private uploadDir = 'uploads';
constructor(private prisma: PrismaService) {
// Ensure upload directory exists
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
}
async saveFile(file: Express.Multer.File, uploaderId: number, timetableEntryId?: number) {
return this.prisma.file.create({
data: {
filename: file.originalname,
path: file.path,
mimetype: file.mimetype,
size: file.size,
uploadedById: uploaderId,
timetableEntryId: timetableEntryId ? Number(timetableEntryId) : null,
},
include: {
uploadedBy: {
select: {
username: true,
role: true
}
}
}
});
}
async findAllGeneral() {
return this.prisma.file.findMany({
where: { timetableEntryId: null },
include: { uploadedBy: { select: { username: true, role: true } } },
orderBy: { createdAt: 'desc' }
});
}
async findByLesson(lessonId: number, userId: number, userRole: string) {
const lessonIdNum = Number(lessonId);
if (userRole === 'Lehrer') {
return this.prisma.file.findMany({
where: { timetableEntryId: lessonIdNum },
include: { uploadedBy: { select: { id: true, username: true, role: true } } },
orderBy: { createdAt: 'desc' }
});
} else {
return this.prisma.file.findMany({
where: {
timetableEntryId: lessonIdNum,
OR: [
{ uploadedBy: { role: 'Lehrer' } },
{ uploadedById: userId }
]
},
include: { uploadedBy: { select: { id: true, username: true, role: true } } },
orderBy: { createdAt: 'desc' }
});
}
}
async getFileEntity(id: number) {
const file = await this.prisma.file.findUnique({ where: { id } });
if (!file) throw new NotFoundException('File not found');
return file;
}
async deleteFile(id: number, userId: number, userRole: string) {
const file = await this.prisma.file.findUnique({ where: { id } });
if (!file) throw new NotFoundException('File not found');
if (userRole !== 'Lehrer' && file.uploadedById !== userId) {
throw new ForbiddenException('You can only delete your own files');
}
await this.prisma.file.delete({ where: { id } });
try {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
} catch (e) {
console.error("Failed to delete file from disk", e);
}
return { success: true };
}
}

View File

@@ -0,0 +1,51 @@
import { IsInt, IsNumber, IsString, IsOptional, IsDateString, Min, Max } from 'class-validator';
export class CreateGradeDto {
@IsInt()
studentId: number;
@IsInt()
timetableEntryId: number;
@IsInt()
@Min(1)
@Max(53)
weekNumber: number;
@IsInt()
year: number;
@IsNumber()
@Min(1.0)
@Max(6.0)
grade: number;
@IsString()
gradeType: string; // z.B. "Klausur", "Mitarbeit", "Hausaufgabe", "Mündlich"
@IsOptional()
@IsNumber()
weight?: number;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
maxPoints?: number;
@IsOptional()
@IsNumber()
achievedPoints?: number;
@IsDateString()
date: string;
@IsInt()
teacherId: number; // Foreign Key zu User (Lehrer)
}

View File

@@ -0,0 +1,2 @@
export * from './create-grade.dto';
export * from './update-grade.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateGradeDto } from './create-grade.dto';
export class UpdateGradeDto extends PartialType(CreateGradeDto) {}

View File

@@ -0,0 +1,147 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseIntPipe,
ForbiddenException,
} from '@nestjs/common';
import { GradesService } from './grades.service';
import { CreateGradeDto, UpdateGradeDto } from './dto';
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
import { Roles } from '../auth/guards/roles.guard';
@Controller('grades')
@UseGuards(JwtAuthGuard, RolesGuard)
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
// Lehrer: Note erstellen
@Post()
@Roles(['Lehrer'])
async createGrade(@Body() createGradeDto: CreateGradeDto) {
return this.gradesService.createGrade(createGradeDto);
}
// Alle Noten für einen Stundenplan-Eintrag und Woche abrufen
// Lehrer: Alle Noten, Schüler: Nur eigene Noten
@Get('timetable/:timetableEntryId')
async getGradesByTimetableEntry(
@Param('timetableEntryId', ParseIntPipe) timetableEntryId: number,
@Query('weekNumber', ParseIntPipe) weekNumber: number,
@Query('year', ParseIntPipe) year: number,
@Request() req,
) {
const user = req.user;
if (user.role === 'Lehrer') {
// Lehrer können alle Noten für diesen Stundenplan-Eintrag und Woche sehen
return this.gradesService.getGradesByTimetableEntry(timetableEntryId, weekNumber, year);
} else {
// Schüler können nur ihre eigenen Noten sehen
return this.gradesService.getStudentGradesByTimetableEntry(
user.id,
timetableEntryId,
weekNumber,
year,
);
}
}
// Schüler: Alle eigenen Noten abrufen
// Lehrer: Alle Noten abrufen (optional mit Filter)
@Get()
async getGrades(@Request() req, @Query() query: any) {
const user = req.user;
if (user.role === 'Lehrer') {
// Lehrer können alle Noten abrufen (optional mit Filter)
const filters: any = {};
if (query.studentId) filters.studentId = parseInt(query.studentId);
if (query.timetableEntryId) filters.timetableEntryId = parseInt(query.timetableEntryId);
if (query.teacherId) filters.teacherId = query.teacherId;
return this.gradesService.getAllGrades(filters);
} else {
// Schüler können nur ihre eigenen Noten abrufen
return this.gradesService.getStudentGrades(user.id);
}
}
// Schüler: Durchschnittsnote für ein Fach
@Get('average/:timetableEntryId')
async getAverageGrade(
@Param('timetableEntryId', ParseIntPipe) timetableEntryId: number,
@Request() req,
) {
const user = req.user;
// Nur Schüler können ihre eigene Durchschnittsnote abrufen
if (user.role !== 'Student') {
throw new ForbiddenException('Nur Schüler können Durchschnittsnoten abrufen');
}
return this.gradesService.calculateAverageForSubject(user.id, timetableEntryId);
}
// Schüler: Übersicht über alle Fächer
@Get('overview')
async getStudentOverview(@Request() req) {
const user = req.user;
// Nur Schüler können ihre Übersicht abrufen
if (user.role !== 'Student') {
throw new ForbiddenException('Nur Schüler können ihre Übersicht abrufen');
}
return this.gradesService.getStudentOverview(user.id);
}
// Lehrer: Komplette Notenübersicht für alle Fächer
@Get('teacher/overview')
@Roles(['Lehrer'])
async getTeacherGradesOverview() {
return this.gradesService.getTeacherGradesOverview();
}
// Lehrer: Detaillierte Notenansicht für ein spezifisches Fach (einzelner Stundenplan-Eintrag)
@Get('subject/:timetableEntryId')
@Roles(['Lehrer'])
async getSubjectGradeDetails(
@Param('timetableEntryId', ParseIntPipe) timetableEntryId: number,
) {
return this.gradesService.getSubjectGradeDetails(timetableEntryId);
}
// Lehrer: Detaillierte Notenansicht für alle Stundenplan-Einträge mit gleichem Fachnamen
@Get('subject-by-name/:subjectName')
@Roles(['Lehrer'])
async getSubjectGradeDetailsByName(
@Param('subjectName') subjectName: string,
) {
return this.gradesService.getSubjectGradeDetailsByName(subjectName);
}
// Lehrer: Note aktualisieren
@Put(':id')
@Roles(['Lehrer'])
async updateGrade(
@Param('id', ParseIntPipe) id: number,
@Body() updateGradeDto: UpdateGradeDto,
) {
return this.gradesService.updateGrade(id, updateGradeDto);
}
// Lehrer: Note löschen
@Delete(':id')
@Roles(['Lehrer'])
async deleteGrade(@Param('id', ParseIntPipe) id: number) {
return this.gradesService.deleteGrade(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { GradesController } from './grades.controller';
import { GradesService } from './grades.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [GradesController],
providers: [GradesService],
exports: [GradesService],
})
export class GradesModule {}

View File

@@ -0,0 +1,617 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateGradeDto, UpdateGradeDto } from './dto';
@Injectable()
export class GradesService {
constructor(private prisma: PrismaService) {}
// Lehrer: Note erstellen
async createGrade(createGradeDto: CreateGradeDto) {
return this.prisma.grade.create({
data: {
...createGradeDto,
date: new Date(createGradeDto.date),
},
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
},
},
});
}
// Lehrer: Alle Noten für einen bestimmten Stundenplan-Eintrag und Woche abrufen
async getGradesByTimetableEntry(timetableEntryId: number, weekNumber: number, year: number) {
return this.prisma.grade.findMany({
where: {
timetableEntryId,
weekNumber,
year,
},
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
},
orderBy: [
{ student: { username: 'asc' } },
],
});
}
// Schüler: Eigene Noten für einen bestimmten Stundenplan-Eintrag und Woche abrufen
async getStudentGradesByTimetableEntry(studentId: number, timetableEntryId: number, weekNumber: number, year: number) {
return this.prisma.grade.findMany({
where: {
studentId,
timetableEntryId,
weekNumber,
year,
},
include: {
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
},
},
orderBy: {
date: 'desc',
},
});
}
// Schüler: Alle eigenen Noten abrufen
async getStudentGrades(studentId: number) {
return this.prisma.grade.findMany({
where: { studentId },
include: {
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
},
},
orderBy: {
date: 'desc',
},
});
}
// Lehrer: Alle Noten abrufen (optional mit Filter)
async getAllGrades(filters?: { studentId?: number; timetableEntryId?: number; teacherId?: number }) {
return this.prisma.grade.findMany({
where: filters,
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
},
},
orderBy: {
date: 'desc',
},
});
}
// Lehrer: Note aktualisieren
async updateGrade(id: number, updateGradeDto: UpdateGradeDto) {
const grade = await this.prisma.grade.findUnique({ where: { id } });
if (!grade) {
throw new NotFoundException(`Note mit ID ${id} nicht gefunden`);
}
return this.prisma.grade.update({
where: { id },
data: {
...updateGradeDto,
date: updateGradeDto.date ? new Date(updateGradeDto.date) : undefined,
},
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
},
},
});
}
// Lehrer: Note löschen
async deleteGrade(id: number) {
const grade = await this.prisma.grade.findUnique({ where: { id } });
if (!grade) {
throw new NotFoundException(`Note mit ID ${id} nicht gefunden`);
}
return this.prisma.grade.delete({ where: { id } });
}
// Schüler: Durchschnittsnote für ein Fach berechnen
async calculateAverageForSubject(studentId: number, timetableEntryId: number) {
const grades = await this.prisma.grade.findMany({
where: {
studentId,
timetableEntryId,
},
});
if (grades.length === 0) {
return null;
}
// Gewichtete Durchschnittsnote berechnen
const totalWeight = grades.reduce((sum, g) => sum + (g.weight || 1.0), 0);
const weightedSum = grades.reduce((sum, g) => sum + g.grade * (g.weight || 1.0), 0);
return {
average: Number((weightedSum / totalWeight).toFixed(2)),
gradeCount: grades.length,
grades: grades.map(g => ({
id: g.id,
grade: g.grade,
gradeType: g.gradeType,
weight: g.weight,
title: g.title,
date: g.date,
})),
};
}
// Schüler: Gesamtübersicht über alle Fächer
async getStudentOverview(studentId: number) {
const grades = await this.prisma.grade.findMany({
where: { studentId },
include: {
timetableEntry: {
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
},
},
});
// Gruppiere Noten nach Fach
const gradesBySubject = grades.reduce((acc, grade) => {
const key = `${grade.timetableEntryId}`;
if (!acc[key]) {
acc[key] = {
timetableEntryId: grade.timetableEntryId,
subject: grade.timetableEntry.subject,
teacher: grade.timetableEntry.teacher,
grades: [],
};
}
acc[key].grades.push(grade);
return acc;
}, {});
// Berechne Durchschnitt für jedes Fach
const overview = Object.values(gradesBySubject).map((subjectData: any) => {
const totalWeight = subjectData.grades.reduce((sum, g) => sum + (g.weight || 1.0), 0);
const weightedSum = subjectData.grades.reduce((sum, g) => sum + g.grade * (g.weight || 1.0), 0);
return {
timetableEntryId: subjectData.timetableEntryId,
subject: subjectData.subject,
teacher: subjectData.teacher,
average: Number((weightedSum / totalWeight).toFixed(2)),
gradeCount: subjectData.grades.length,
latestGrade: subjectData.grades.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)[0],
};
});
return overview;
}
// Lehrer: Komplette Notenübersicht für alle Fächer
async getTeacherGradesOverview() {
// Alle Stundenplan-Einträge mit Noten abrufen
const timetableEntries = await this.prisma.timetableEntry.findMany({
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
grades: {
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
},
orderBy: [
{ student: { username: 'asc' } },
{ date: 'desc' },
],
},
},
orderBy: {
subject: { name: 'asc' },
},
});
// Gruppiere nach Fachname (subject.name) statt nach timetableEntryId
const subjectGroups = timetableEntries.reduce((acc, entry) => {
const subjectName = entry.subject.name;
if (!acc[subjectName]) {
acc[subjectName] = {
subject: entry.subject,
timetableEntryIds: [],
entries: [],
allGrades: [],
};
}
// Sammle alle timetableEntryIds für dieses Fach
acc[subjectName].timetableEntryIds.push(entry.id);
acc[subjectName].entries.push({
id: entry.id,
teacher: entry.teacher,
dayOfWeek: entry.dayOfWeek,
startTime: entry.startTime,
endTime: entry.endTime,
});
// Sammle alle Noten für dieses Fach
acc[subjectName].allGrades.push(...entry.grades);
return acc;
}, {});
// Berechne Statistiken für jedes gruppierte Fach
return Object.values(subjectGroups).map((subjectData: any) => {
// Gruppiere alle Noten nach Schüler
const gradesByStudent = subjectData.allGrades.reduce((acc, grade) => {
if (!acc[grade.studentId]) {
acc[grade.studentId] = {
student: grade.student,
grades: [],
};
}
acc[grade.studentId].grades.push(grade);
return acc;
}, {});
// Berechne Durchschnitt pro Schüler
const studentAverages = Object.values(gradesByStudent).map((data: any) => {
const totalWeight = data.grades.reduce((sum, g) => sum + (g.weight || 1.0), 0);
const weightedSum = data.grades.reduce((sum, g) => sum + g.grade * (g.weight || 1.0), 0);
const average = totalWeight > 0 ? Number((weightedSum / totalWeight).toFixed(2)) : null;
return {
student: data.student,
average,
gradeCount: data.grades.length,
grades: data.grades,
};
});
return {
subject: subjectData.subject,
timetableEntryIds: subjectData.timetableEntryIds, // Alle IDs für dieses Fach
timetableEntries: subjectData.entries, // Alle Stundenplan-Einträge für dieses Fach
studentCount: studentAverages.length,
totalGrades: subjectData.allGrades.length,
students: studentAverages,
};
}).filter(entry => entry.totalGrades > 0); // Nur Fächer mit Noten anzeigen
}
// Lehrer: Detaillierte Notenansicht für ein spezifisches Fach (einzelner Stundenplan-Eintrag)
async getSubjectGradeDetails(timetableEntryId: number) {
const timetableEntry = await this.prisma.timetableEntry.findUnique({
where: { id: timetableEntryId },
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
grades: {
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
},
orderBy: [
{ student: { username: 'asc' } },
{ date: 'desc' },
],
},
},
});
if (!timetableEntry) {
throw new NotFoundException(`Stundenplan-Eintrag mit ID ${timetableEntryId} nicht gefunden`);
}
// Gruppiere Noten nach Schüler
const gradesByStudent = timetableEntry.grades.reduce((acc, grade) => {
if (!acc[grade.studentId]) {
acc[grade.studentId] = {
student: grade.student,
grades: [],
};
}
acc[grade.studentId].grades.push(grade);
return acc;
}, {});
// Berechne Durchschnitt und Details pro Schüler
const students = Object.values(gradesByStudent).map((data: any) => {
const totalWeight = data.grades.reduce((sum, g) => sum + (g.weight || 1.0), 0);
const weightedSum = data.grades.reduce((sum, g) => sum + g.grade * (g.weight || 1.0), 0);
const average = totalWeight > 0 ? Number((weightedSum / totalWeight).toFixed(2)) : null;
return {
student: data.student,
average,
totalWeight,
gradeCount: data.grades.length,
grades: data.grades.map(g => ({
id: g.id,
grade: g.grade,
gradeType: g.gradeType,
weight: g.weight || 1.0,
title: g.title,
description: g.description,
maxPoints: g.maxPoints,
achievedPoints: g.achievedPoints,
date: g.date,
weekNumber: g.weekNumber,
year: g.year,
})),
};
});
return {
timetableEntry: {
id: timetableEntry.id,
subject: timetableEntry.subject,
teacher: timetableEntry.teacher,
room: timetableEntry.room,
dayOfWeek: timetableEntry.dayOfWeek,
startTime: timetableEntry.startTime,
endTime: timetableEntry.endTime,
},
students,
totalStudents: students.length,
totalGrades: timetableEntry.grades.length,
};
}
// Lehrer: Detaillierte Notenansicht für alle Stundenplan-Einträge mit gleichem Fachnamen
async getSubjectGradeDetailsByName(subjectName: string) {
// Alle Stundenplan-Einträge mit diesem Fachnamen finden
const timetableEntries = await this.prisma.timetableEntry.findMany({
where: {
subject: { name: subjectName }
},
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
grades: {
include: {
student: {
select: {
id: true,
username: true,
email: true,
},
},
},
orderBy: [
{ student: { username: 'asc' } },
{ date: 'desc' },
],
},
},
orderBy: [
{ dayOfWeek: 'asc' },
{ startTime: 'asc' },
],
});
if (timetableEntries.length === 0) {
throw new NotFoundException(`Keine Stundenplan-Einträge für Fach "${subjectName}" gefunden`);
}
// Alle Noten über alle Stundenplan-Einträge sammeln
const allGrades = timetableEntries.flatMap(entry => entry.grades);
// Gruppiere Noten nach Schüler
const gradesByStudent = allGrades.reduce((acc, grade) => {
if (!acc[grade.studentId]) {
acc[grade.studentId] = {
student: grade.student,
grades: [],
};
}
acc[grade.studentId].grades.push(grade);
return acc;
}, {});
// Berechne Durchschnitt und Details pro Schüler
const students = Object.values(gradesByStudent).map((data: any) => {
const totalWeight = data.grades.reduce((sum, g) => sum + (g.weight || 1.0), 0);
const weightedSum = data.grades.reduce((sum, g) => sum + g.grade * (g.weight || 1.0), 0);
const average = totalWeight > 0 ? Number((weightedSum / totalWeight).toFixed(2)) : null;
return {
student: data.student,
average,
totalWeight,
gradeCount: data.grades.length,
grades: data.grades.map(g => ({
id: g.id,
grade: g.grade,
gradeType: g.gradeType,
weight: g.weight || 1.0,
title: g.title,
description: g.description,
maxPoints: g.maxPoints,
achievedPoints: g.achievedPoints,
date: g.date,
weekNumber: g.weekNumber,
year: g.year,
})),
};
});
return {
subject: timetableEntries[0].subject,
timetableEntries: timetableEntries.map(entry => ({
id: entry.id,
teacher: entry.teacher,
room: entry.room,
dayOfWeek: entry.dayOfWeek,
startTime: entry.startTime,
endTime: entry.endTime,
})),
students,
totalStudents: students.length,
totalGrades: allGrades.length,
};
}
}

View File

@@ -0,0 +1,93 @@
import { Controller, Get, Post, Body, Param, ParseIntPipe } from '@nestjs/common';
import { IotService } from './iot.service';
@Controller()
export class IotController {
constructor(private readonly iotService: IotService) {}
@Get()
getHello(): string {
return this.iotService.getHello();
}
@Get('time')
getTime() {
return this.iotService.getTime();
}
@Post('scan')
receiveScan(@Body() body: { uid: string; timestamp: number }) {
return this.iotService.receiveScan(body.uid, body.timestamp);
}
// Student management endpoints
@Post('students')
registerStudent(@Body() body: { firstName: string; lastName: string; className: string; rfidUid: string }) {
return this.iotService.registerStudent(body.firstName, body.lastName, body.className, body.rfidUid);
}
@Get('students')
getAllStudents() {
return this.iotService.getAllStudents();
}
@Get('students/:id')
getStudent(@Param('id', ParseIntPipe) id: number) {
return this.iotService.getStudent(id);
}
@Post('students/:id/assign-card')
assignCard(@Param('id', ParseIntPipe) id: number, @Body() body: { rfidUid: string }) {
return this.iotService.assignCard(id, body.rfidUid);
}
@Post('students/:id/update')
updateStudent(
@Param('id', ParseIntPipe) id: number,
@Body() body: { firstName?: string; lastName?: string; className?: string; rfidUid?: string }
) {
return this.iotService.updateStudent(id, body.firstName, body.lastName, body.className, body.rfidUid);
}
@Post('students/:id/card-active')
setCardActive(@Param('id', ParseIntPipe) id: number, @Body() body: { isActive: boolean }) {
return this.iotService.setCardActive(id, body.isActive);
}
@Post('students/:id/delete')
deleteStudent(@Param('id', ParseIntPipe) id: number) {
return this.iotService.deleteStudent(id);
}
// Attendance endpoints
@Get('attendance/present')
getCurrentlyPresent() {
return this.iotService.getCurrentlyPresent();
}
@Get('attendance/date/:date')
getScansForDate(@Param('date') date: string) {
return this.iotService.getScansForDate(date);
}
@Get('attendance/student/:id')
getStudentAttendance(@Param('id', ParseIntPipe) id: number) {
return this.iotService.getStudentAttendance(id);
}
@Get('attendance/all')
getAllAttendanceLogs() {
return this.iotService.getAllAttendanceLogs();
}
// Admin endpoints
@Post('admin/attendance/manual-entry')
addManualEntry(@Body() body: { studentId: number; scannedAt: string; eventType: string; reason: string }) {
return this.iotService.addManualEntry(body.studentId, new Date(body.scannedAt), body.eventType, body.reason);
}
@Post('admin/attendance/log/:logId/delete')
deleteAttendanceLog(@Param('logId', ParseIntPipe) logId: number) {
return this.iotService.deleteAttendanceLog(logId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { IotController } from './iot.controller';
import { IotService } from './iot.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [PrismaModule, ScheduleModule],
controllers: [IotController],
providers: [IotService],
})
export class IotModule {}

View File

@@ -0,0 +1,461 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Cron } from '@nestjs/schedule';
@Injectable()
export class IotService {
constructor(private prisma: PrismaService) {}
getHello(): string {
return 'Hello World!';
}
getTime() {
// Get current time in Berlin timezone
const now = new Date();
const berlinTime = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
const unixTimestamp = Math.floor(berlinTime.getTime() / 1000);
return {
unix: unixTimestamp,
readable: berlinTime.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })
};
}
async receiveScan(uid: string, timestamp: number) {
const scanTime = new Date(timestamp * 1000);
const berlinTime = scanTime.toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// Find student by RFID UID
const student = await this.prisma.student.findUnique({
where: { studentRfidCardUid: uid }
});
// Check if card is unregistered or disabled
if (!student) {
console.log(`[RFID Scan] UID: ${uid} | Time: ${berlinTime} | WARNING: Unregistered card`);
return 'BLOCKED';
}
if (!student.studentCardIsActive) {
console.log(`[RFID Scan] ${student.studentFirstName} ${student.studentLastName} | WARNING: Card disabled`);
return 'BLOCKED';
}
// Use transaction to prevent race conditions during rapid cached uploads
const result = await this.prisma.$transaction(async (tx) => {
// Get last scan (any day) with row lock
const lastScan = await tx.attendanceLog.findFirst({
where: { studentId: student.studentId },
orderBy: { attendanceScannedAt: 'desc' }
});
let eventType: string;
if (!lastScan) {
// First scan ever = IN
eventType = 'IN';
} else {
// Convert both times to Berlin timezone for comparison
const lastScanBerlin = new Date(lastScan.attendanceScannedAt.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
const currentBerlin = new Date(scanTime.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
console.log(`[Debug] Last scan: ${lastScanBerlin.toISOString()} | Current: ${currentBerlin.toISOString()}`);
console.log(`[Debug] Last event type: ${lastScan.attendanceEventType}`);
// Check if last scan was on a different day (in Berlin timezone)
const isDifferentDay =
lastScanBerlin.getDate() !== currentBerlin.getDate() ||
lastScanBerlin.getMonth() !== currentBerlin.getMonth() ||
lastScanBerlin.getFullYear() !== currentBerlin.getFullYear();
console.log(`[Debug] Is different day? ${isDifferentDay}`);
if (isDifferentDay) {
// Different day = always start with IN
eventType = 'IN';
// Auto-close previous day if still IN
if (lastScan.attendanceEventType === 'IN') {
const endOfLastDay = new Date(lastScanBerlin);
endOfLastDay.setHours(23, 59, 59, 999);
await tx.attendanceLog.create({
data: {
studentId: student.studentId,
attendanceScannedAt: endOfLastDay,
attendanceEventType: 'OUT',
wasManualEntry: true,
manualEntryReason: 'Auto-closed: forgot to sign out'
}
});
console.log(`[Auto Close] ${student.studentFirstName} ${student.studentLastName} at 23:59:59`);
}
} else {
// Same day = toggle
eventType = lastScan.attendanceEventType === 'IN' ? 'OUT' : 'IN';
}
}
// Save scan within transaction
await tx.attendanceLog.create({
data: {
studentId: student.studentId,
attendanceScannedAt: scanTime,
attendanceEventType: eventType,
wasManualEntry: false
}
});
return eventType;
});
const eventType = result;
const action = eventType === 'IN' ? 'SIGN IN' : 'SIGN OUT';
const studentName = `${student.studentFirstName} ${student.studentLastName}`;
console.log(`[Attendance] ${studentName} | ${action} | Time: ${berlinTime} | Saved to DB`);
// Return specific response for ESP32
return eventType === 'IN' ? 'SIGNIN' : 'SIGNOUT';
}
// Register new student
async registerStudent(firstName: string, lastName: string, className: string, rfidUid: string) {
const student = await this.prisma.student.create({
data: {
studentFirstName: firstName,
studentLastName: lastName,
studentClassName: className,
studentRfidCardUid: rfidUid,
studentCardIsActive: true
}
});
console.log(`[Student] Registered: ${firstName} ${lastName} (${className}) with card ${rfidUid}`);
return student;
}
// Get all students
async getAllStudents() {
return await this.prisma.student.findMany({
orderBy: { studentLastName: 'asc' }
});
}
// Get student by ID
async getStudent(id: number) {
return await this.prisma.student.findUnique({
where: { studentId: id },
include: {
attendanceLogs: {
orderBy: { attendanceScannedAt: 'desc' },
take: 20
}
}
});
}
// Update student RFID card
async assignCard(id: number, rfidUid: string) {
const student = await this.prisma.student.update({
where: { studentId: id },
data: { studentRfidCardUid: rfidUid }
});
console.log(`[Student] Assigned card ${rfidUid} to ${student.studentFirstName} ${student.studentLastName}`);
return student;
}
// Update student information
async updateStudent(id: number, firstName?: string, lastName?: string, className?: string, rfidUid?: string) {
const data: any = {};
if (firstName) data.studentFirstName = firstName;
if (lastName) data.studentLastName = lastName;
if (className) data.studentClassName = className;
if (rfidUid) data.studentRfidCardUid = rfidUid;
const student = await this.prisma.student.update({
where: { studentId: id },
data
});
console.log(`[Student] Updated: ${student.studentFirstName} ${student.studentLastName}`);
return student;
}
// Enable/Disable student card
async setCardActive(id: number, isActive: boolean) {
const student = await this.prisma.student.update({
where: { studentId: id },
data: { studentCardIsActive: isActive }
});
const status = isActive ? 'enabled' : 'disabled';
console.log(`[Student] Card ${status} for ${student.studentFirstName} ${student.studentLastName}`);
return student;
}
// Delete student and all attendance logs
async deleteStudent(id: number) {
const student = await this.prisma.student.delete({
where: { studentId: id }
});
console.log(`[Student] Deleted: ${student.studentFirstName} ${student.studentLastName} and all attendance logs`);
return student;
}
// Get currently present students
async getCurrentlyPresent() {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get all active students with their last scan of the day
const students = await this.prisma.student.findMany({
where: { studentCardIsActive: true },
include: {
attendanceLogs: {
where: {
attendanceScannedAt: { gte: today }
},
orderBy: { attendanceScannedAt: 'desc' },
take: 1
}
}
});
// Filter to only those whose last scan was IN
const present = students
.filter(s => s.attendanceLogs.length > 0 && s.attendanceLogs[0].attendanceEventType === 'IN')
.map(s => ({
studentId: s.studentId,
name: `${s.studentFirstName} ${s.studentLastName}`,
className: s.studentClassName,
checkInTime: s.attendanceLogs[0].attendanceScannedAt,
minutesPresent: Math.floor((Date.now() - s.attendanceLogs[0].attendanceScannedAt.getTime()) / 60000)
}));
// Get absent students (no scans today)
const absent = students
.filter(s => s.attendanceLogs.length === 0)
.map(s => ({
studentId: s.studentId,
name: `${s.studentFirstName} ${s.studentLastName}`,
className: s.studentClassName
}));
return {
date: new Date().toISOString().split('T')[0],
time: new Date().toLocaleTimeString('de-DE'),
presentStudents: present,
absentStudents: absent,
totalPresent: present.length,
totalAbsent: absent.length
};
}
// Get all scans for a specific date
async getScansForDate(dateString: string) {
const date = new Date(dateString);
date.setHours(0, 0, 0, 0);
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const logs = await this.prisma.attendanceLog.findMany({
where: {
attendanceScannedAt: {
gte: date,
lt: nextDay
}
},
include: {
student: true
},
orderBy: { attendanceScannedAt: 'asc' }
});
// Group by student
const studentLogs = new Map();
logs.forEach(log => {
const studentId = log.studentId;
if (!studentLogs.has(studentId)) {
studentLogs.set(studentId, {
studentId: studentId,
name: `${log.student.studentFirstName} ${log.student.studentLastName}`,
className: log.student.studentClassName,
logs: []
});
}
studentLogs.get(studentId).logs.push({
time: log.attendanceScannedAt,
action: log.attendanceEventType,
wasManual: log.wasManualEntry,
reason: log.manualEntryReason
});
});
return {
date: dateString,
students: Array.from(studentLogs.values())
};
}
// Get all attendance logs for a specific student
async getStudentAttendance(id: number) {
const student = await this.prisma.student.findUnique({
where: { studentId: id },
include: {
attendanceLogs: {
orderBy: { attendanceScannedAt: 'desc' }
}
}
});
if (!student) {
throw new Error('Student not found');
}
return {
studentId: student.studentId,
name: `${student.studentFirstName} ${student.studentLastName}`,
className: student.studentClassName,
logs: student.attendanceLogs.map(log => ({
id: log.attendanceLogId,
time: log.attendanceScannedAt,
action: log.attendanceEventType,
wasManual: log.wasManualEntry,
reason: log.manualEntryReason
}))
};
}
// Get all attendance logs (for sorting/filtering)
async getAllAttendanceLogs() {
const logs = await this.prisma.attendanceLog.findMany({
include: {
student: true
},
orderBy: { attendanceScannedAt: 'desc' }
});
return logs.map(log => ({
id: log.attendanceLogId,
studentId: log.studentId,
studentName: `${log.student.studentFirstName} ${log.student.studentLastName}`,
className: log.student.studentClassName,
time: log.attendanceScannedAt,
action: log.attendanceEventType,
wasManual: log.wasManualEntry,
reason: log.manualEntryReason
}));
}
// Admin: Manual attendance entry
async addManualEntry(studentId: number, scannedAt: Date, eventType: string, reason: string) {
const student = await this.prisma.student.findUnique({
where: { studentId }
});
if (!student) {
throw new Error('Student not found');
}
const log = await this.prisma.attendanceLog.create({
data: {
studentId,
attendanceScannedAt: scannedAt,
attendanceEventType: eventType,
wasManualEntry: true,
manualEntryReason: reason
}
});
console.log(`[Manual Entry] ${student.studentFirstName} ${student.studentLastName} | ${eventType} | ${reason}`);
return log;
}
// Admin: Delete attendance log
async deleteAttendanceLog(logId: number) {
const log = await this.prisma.attendanceLog.delete({
where: { attendanceLogId: logId }
});
console.log(`[Delete Log] ID ${logId} deleted`);
return log;
}
// Cron job: Auto sign-out at midnight
@Cron('0 0 * * *') // Runs at 00:00 every day
async autoSignOutAtMidnight() {
console.log('[Cron] Running auto sign-out at midnight...');
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const yesterdayEnd = new Date(yesterday);
yesterdayEnd.setHours(23, 59, 59, 999);
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
// Find all students who signed in yesterday but never signed out
const students = await this.prisma.student.findMany({
include: {
attendanceLogs: {
where: {
attendanceScannedAt: { gte: yesterday, lt: midnight }
},
orderBy: { attendanceScannedAt: 'desc' }
}
}
});
let autoSignedOut = 0;
for (const student of students) {
if (student.attendanceLogs.length > 0) {
const lastLog = student.attendanceLogs[0];
// If last log was IN (not signed out)
if (lastLog.attendanceEventType === 'IN') {
// Create auto sign-out at 23:59:59
await this.prisma.attendanceLog.create({
data: {
studentId: student.studentId,
attendanceScannedAt: yesterdayEnd,
attendanceEventType: 'OUT',
wasManualEntry: true,
manualEntryReason: 'Auto-closed at midnight'
}
});
autoSignedOut++;
console.log(`[Auto Sign-Out] ${student.studentFirstName} ${student.studentLastName} at 23:59:59`);
}
}
}
console.log(`[Cron] Auto signed out ${autoSignedOut} students`);
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'path';
import { MailService } from './mail.service';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
MailerModule.forRootAsync({
useFactory: async (config: ConfigService) => ({
transport: {
host: config.get('SMTP_HOST'),
port: config.get('SMTP_PORT'),
secure: false, // true for 465, false for other ports
auth: {
user: config.get('SMTP_USER'),
pass: config.get('SMTP_PASS'),
},
},
defaults: {
from: `"Smartes Klassenzimmer" <${config.get('SMTP_FROM')}>`,
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
}),
inject: [ConfigService],
}),
],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,38 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
@Injectable()
export class MailService {
constructor(private mailerService: MailerService) {}
async sendUserConfirmation(user: User) {
// For now, we don't have a confirmation token flow, just a welcome email
// If we wanted confirmation, we'd generate a token here or pass it in.
await this.mailerService.sendMail({
to: user.email,
// from: '"Support Team" <support@example.com>', // override default from
subject: 'Willkommen im Smarten Klassenzimmer!',
template: './confirmation', // `.hbs` extension is appended automatically
context: {
name: user.username,
// url,
},
});
}
async sendPasswordReset(user: User, token: string) {
const url = `http://localhost:5173/reset-password?token=${token}`; // Frontend URL
await this.mailerService.sendMail({
to: user.email,
subject: 'Passwort zurücksetzen',
template: './reset-password',
context: {
name: user.username,
url,
},
});
}
}

View File

@@ -0,0 +1,5 @@
<p>Hey {{ name }},</p>
<p>Willkommen im Smarten Klassenzimmer!</p>
<p>Deine Registrierung war erfolgreich.</p>
<p>Viel Spaß beim Lernen!</p>

View File

@@ -0,0 +1,8 @@
<p>Hey {{ name }},</p>
<p>Du hast angefordert, dein Passwort zurückzusetzen.</p>
<p>Klicke auf den folgenden Link, um ein neues Passwort zu setzen:</p>
<p>
<a href="{{ url }}">Passwort zurücksetzen</a>
</p>
<p>Wenn du das nicht angefordert hast, ignoriere diese Email einfach.</p>

View File

@@ -0,0 +1,41 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// CORS Konfiguration
app.enableCors({
origin: [
'http://localhost:5173', // Vite Dev Server
'http://127.0.0.1:5173', // Vite Dev Server (Alternative)
'http://localhost:5500',
'http://127.0.0.1:5500',
'http://localhost:3000'
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Globales Präfix für alle Routes
app.setGlobalPrefix('api');
// Cookie Parser für JWT-Cookies
app.use(cookieParser());
// Validation Pipe für DTOs
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,15 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: any) {
process.on('beforeExit', async () => {
await app.close();
});
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
export class CreateRoomDto {
@IsString()
number: string; // z.B. "A101", "B203"
@IsOptional()
@IsString()
building?: string; // z.B. "Hauptgebäude", "Neubau"
@IsOptional()
@IsInt()
@Min(1, { message: 'capacity muss mindestens 1 sein' })
capacity?: number; // Anzahl Sitzplätze
@IsOptional()
@IsString()
equipment?: string; // z.B. "Beamer, Whiteboard"
}

View File

@@ -0,0 +1,2 @@
export * from './create-room.dto';
export * from './update-room.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateRoomDto } from './create-room.dto';
export class UpdateRoomDto extends PartialType(CreateRoomDto) {}

View File

@@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { RoomsService } from './rooms.service';
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
import { Roles } from '../auth/guards/roles.guard';
import { CreateRoomDto, UpdateRoomDto } from './dto';
@Controller('rooms')
@UseGuards(JwtAuthGuard)
export class RoomsController {
constructor(private readonly roomsService: RoomsService) {}
// GET /rooms - Alle Räume abrufen
@Get()
async findAll() {
return this.roomsService.findAll();
}
// GET /rooms/:id - Einen Raum nach ID abrufen
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.roomsService.findOne(id);
}
// POST /rooms - Neuen Raum erstellen (nur Lehrer)
@Post()
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async create(@Body() createDto: CreateRoomDto) {
return this.roomsService.create(createDto);
}
// PUT /rooms/:id - Raum aktualisieren (nur Lehrer)
@Put(':id')
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateRoomDto,
) {
return this.roomsService.update(id, updateDto);
}
// DELETE /rooms/:id - Raum löschen (nur Lehrer)
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async delete(@Param('id', ParseIntPipe) id: number) {
return this.roomsService.delete(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { RoomsController } from './rooms.controller';
import { RoomsService } from './rooms.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],
})
export class RoomsModule {}

View File

@@ -0,0 +1,86 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateRoomDto, UpdateRoomDto } from './dto';
@Injectable()
export class RoomsService {
constructor(private prisma: PrismaService) {}
// Alle Räume abrufen
async findAll() {
return this.prisma.room.findMany({
orderBy: { number: 'asc' },
include: {
_count: {
select: { timetableEntries: true },
},
},
});
}
// Einen Raum nach ID abrufen
async findOne(id: number) {
const room = await this.prisma.room.findUnique({
where: { id },
include: {
_count: {
select: { timetableEntries: true },
},
},
});
if (!room) {
throw new NotFoundException(`Raum mit ID ${id} nicht gefunden`);
}
return room;
}
// Neuen Raum erstellen (nur Lehrer)
async create(dto: CreateRoomDto) {
try {
return await this.prisma.room.create({
data: dto,
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException('Ein Raum mit dieser Nummer existiert bereits');
}
throw error;
}
}
// Raum aktualisieren (nur Lehrer)
async update(id: number, dto: UpdateRoomDto) {
await this.findOne(id); // Prüfen, ob Raum existiert
try {
return await this.prisma.room.update({
where: { id },
data: dto,
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException('Ein Raum mit dieser Nummer existiert bereits');
}
throw error;
}
}
// Raum löschen (nur Lehrer)
async delete(id: number) {
await this.findOne(id); // Prüfen, ob Raum existiert
try {
await this.prisma.room.delete({
where: { id },
});
return { message: 'Raum erfolgreich gelöscht' };
} catch (error) {
if (error.code === 'P2003') {
throw new ConflictException('Raum kann nicht gelöscht werden, da er noch in Stundenplan-Einträgen verwendet wird');
}
throw error;
}
}
}

View File

@@ -0,0 +1,17 @@
import { IsString, IsOptional, Matches } from 'class-validator';
export class CreateSubjectDto {
@IsString()
name: string; // z.B. "Mathematik", "Deutsch", "Englisch"
@IsOptional()
@IsString()
abbreviation?: string; // z.B. "Mathe", "DE", "EN"
@IsOptional()
@IsString()
@Matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: 'color muss ein gültiger Hex-Code sein (z.B. #FF5733)',
})
color?: string; // z.B. "#FF5733"
}

View File

@@ -0,0 +1,2 @@
export * from './create-subject.dto';
export * from './update-subject.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSubjectDto } from './create-subject.dto';
export class UpdateSubjectDto extends PartialType(CreateSubjectDto) {}

View File

@@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { SubjectsService } from './subjects.service';
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
import { Roles } from '../auth/guards/roles.guard';
import { CreateSubjectDto, UpdateSubjectDto } from './dto';
@Controller('subjects')
@UseGuards(JwtAuthGuard)
export class SubjectsController {
constructor(private readonly subjectsService: SubjectsService) {}
// GET /subjects - Alle Fächer abrufen
@Get()
async findAll() {
return this.subjectsService.findAll();
}
// GET /subjects/:id - Ein Fach nach ID abrufen
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.subjectsService.findOne(id);
}
// POST /subjects - Neues Fach erstellen (nur Lehrer)
@Post()
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async create(@Body() createDto: CreateSubjectDto) {
return this.subjectsService.create(createDto);
}
// PUT /subjects/:id - Fach aktualisieren (nur Lehrer)
@Put(':id')
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateSubjectDto,
) {
return this.subjectsService.update(id, updateDto);
}
// DELETE /subjects/:id - Fach löschen (nur Lehrer)
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async delete(@Param('id', ParseIntPipe) id: number) {
return this.subjectsService.delete(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SubjectsController } from './subjects.controller';
import { SubjectsService } from './subjects.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [SubjectsController],
providers: [SubjectsService],
exports: [SubjectsService],
})
export class SubjectsModule {}

View File

@@ -0,0 +1,86 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSubjectDto, UpdateSubjectDto } from './dto';
@Injectable()
export class SubjectsService {
constructor(private prisma: PrismaService) {}
// Alle Fächer abrufen
async findAll() {
return this.prisma.subject.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { timetableEntries: true },
},
},
});
}
// Ein Fach nach ID abrufen
async findOne(id: number) {
const subject = await this.prisma.subject.findUnique({
where: { id },
include: {
_count: {
select: { timetableEntries: true },
},
},
});
if (!subject) {
throw new NotFoundException(`Fach mit ID ${id} nicht gefunden`);
}
return subject;
}
// Neues Fach erstellen (nur Lehrer)
async create(dto: CreateSubjectDto) {
try {
return await this.prisma.subject.create({
data: dto,
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException('Ein Fach mit diesem Namen existiert bereits');
}
throw error;
}
}
// Fach aktualisieren (nur Lehrer)
async update(id: number, dto: UpdateSubjectDto) {
await this.findOne(id); // Prüfen, ob Fach existiert
try {
return await this.prisma.subject.update({
where: { id },
data: dto,
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException('Ein Fach mit diesem Namen existiert bereits');
}
throw error;
}
}
// Fach löschen (nur Lehrer)
async delete(id: number) {
await this.findOne(id); // Prüfen, ob Fach existiert
try {
await this.prisma.subject.delete({
where: { id },
});
return { message: 'Fach erfolgreich gelöscht' };
} catch (error) {
if (error.code === 'P2003') {
throw new ConflictException('Fach kann nicht gelöscht werden, da es noch in Stundenplan-Einträgen verwendet wird');
}
throw error;
}
}
}

View File

@@ -0,0 +1,68 @@
import {
IsInt,
IsString,
IsOptional,
IsBoolean,
Min,
Max,
Matches,
} from 'class-validator';
export class CreateTimetableEntryDto {
@IsInt()
@Min(1, { message: 'dayOfWeek muss zwischen 1 (Montag) und 5 (Freitag) sein' })
@Max(5, { message: 'dayOfWeek muss zwischen 1 (Montag) und 5 (Freitag) sein' })
dayOfWeek: number;
@IsString()
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'startTime muss im Format HH:MM sein (z.B. 08:00)',
})
startTime: string;
@IsString()
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'endTime muss im Format HH:MM sein (z.B. 09:30)',
})
endTime: string;
@IsOptional()
@IsInt()
subjectId?: number; // Foreign Key zu Subject
@IsOptional()
@IsString()
subjectName?: string; // Alternativ: Name des Fachs
@IsOptional()
@IsInt()
teacherId?: number; // Foreign Key zu User (Lehrer)
@IsOptional()
@IsInt()
roomId?: number; // Foreign Key zu Room
@IsOptional()
@IsString()
roomNumber?: string; // Alternativ: Raumnummer
@IsOptional()
@IsInt()
@Min(1, { message: 'weekNumber muss zwischen 1 und 53 sein' })
@Max(53, { message: 'weekNumber muss zwischen 1 und 53 sein' })
weekNumber?: number;
@IsOptional()
@IsInt()
@Min(2020, { message: 'year muss ein gültiges Jahr sein' })
@Max(2100, { message: 'year muss ein gültiges Jahr sein' })
year?: number;
@IsOptional()
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@IsBoolean()
allowStudentUploads?: boolean;
}

View File

@@ -0,0 +1,2 @@
export * from './create-timetable-entry.dto';
export * from './update-timetable-entry.dto';

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTimetableEntryDto } from './create-timetable-entry.dto';
export class UpdateTimetableEntryDto extends PartialType(
CreateTimetableEntryDto,
) {}

View File

@@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { TimetableService } from './timetable.service';
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
import { Roles } from '../auth/guards/roles.guard';
import { CreateTimetableEntryDto, UpdateTimetableEntryDto } from './dto';
@Controller('timetable')
@UseGuards(JwtAuthGuard)
export class TimetableController {
constructor(private readonly timetableService: TimetableService) {}
// GET /timetable?weekNumber=1&year=2025 - Stundenplan für eine Woche abrufen
@Get()
async getTimetable(
@Query('weekNumber') weekNumber?: string,
@Query('year') year?: string,
) {
const week = weekNumber ? parseInt(weekNumber, 10) : undefined;
const yr = year ? parseInt(year, 10) : undefined;
return this.timetableService.getTimetable(week, yr);
}
// POST /timetable - Neuen Stundenplan-Eintrag erstellen (nur Lehrer)
@Post()
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async createEntry(@Body() createDto: CreateTimetableEntryDto) {
return this.timetableService.createEntry(createDto);
}
// PUT /timetable/:id - Stundenplan-Eintrag aktualisieren (nur Lehrer)
@Put(':id')
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async updateEntry(
@Param('id', ParseIntPipe) entryId: number,
@Body() updateDto: UpdateTimetableEntryDto,
) {
return this.timetableService.updateEntry(entryId, updateDto);
}
// DELETE /timetable/:id - Stundenplan-Eintrag löschen (nur Lehrer)
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
async deleteEntry(@Param('id', ParseIntPipe) entryId: number) {
return this.timetableService.deleteEntry(entryId);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TimetableController } from './timetable.controller';
import { TimetableService } from './timetable.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TimetableController],
providers: [TimetableService],
})
export class TimetableModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTimetableEntryDto, UpdateTimetableEntryDto } from './dto';
@Injectable()
export class TimetableService {
constructor(private prisma: PrismaService) {}
// Alle Stundenplan-Einträge für eine bestimmte Woche abrufen
async getTimetable(weekNumber?: number, year?: number) {
const currentDate = new Date();
const targetWeek = weekNumber || this.getWeekNumber(currentDate);
const targetYear = year || currentDate.getFullYear();
return this.prisma.timetableEntry.findMany({
where: {
OR: [
// Wiederkehrende Einträge (für alle Wochen)
{ isRecurring: true },
// Spezifische Einträge für diese Woche
{
AND: [
{ weekNumber: targetWeek },
{ year: targetYear },
{ isRecurring: false },
],
},
],
},
include: {
subject: true, // Subject-Details laden
teacher: { // Lehrer-Details laden
select: {
id: true,
username: true,
email: true,
},
},
room: true, // Raum-Details laden
},
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
});
}
// Kalenderwoche berechnen (ISO 8601)
private getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
// Einen neuen Stundenplan-Eintrag erstellen (nur für Lehrer)
async createEntry(dto: CreateTimetableEntryDto) {
let subjectId = dto.subjectId;
if (!subjectId && dto.subjectName) {
// Find or create subject
const subject = await this.prisma.subject.upsert({
where: { name: dto.subjectName },
update: {},
create: { name: dto.subjectName },
});
subjectId = subject.id;
}
if (!subjectId) {
throw new Error('Subject ID or Name must be provided');
}
let roomId = dto.roomId;
if (!roomId && dto.roomNumber) {
// Find or create room
const room = await this.prisma.room.upsert({
where: { number: dto.roomNumber },
update: {},
create: { number: dto.roomNumber },
});
roomId = room.id;
}
return this.prisma.timetableEntry.create({
data: {
dayOfWeek: dto.dayOfWeek,
startTime: dto.startTime,
endTime: dto.endTime,
subjectId: subjectId,
teacherId: dto.teacherId,
roomId: roomId,
weekNumber: dto.weekNumber,
year: dto.year,
isRecurring: dto.isRecurring ?? true, // Standard: wiederkehrend
allowStudentUploads: dto.allowStudentUploads ?? false,
},
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
});
}
// Einen Stundenplan-Eintrag aktualisieren (nur für Lehrer)
async updateEntry(entryId: number, dto: UpdateTimetableEntryDto) {
// Prüfen, ob der Eintrag existiert
const entry = await this.prisma.timetableEntry.findUnique({
where: { id: entryId },
});
if (!entry) {
throw new NotFoundException('Stundenplan-Eintrag nicht gefunden');
}
let subjectId = dto.subjectId;
if (!subjectId && dto.subjectName) {
const subject = await this.prisma.subject.upsert({
where: { name: dto.subjectName },
update: {},
create: { name: dto.subjectName },
});
subjectId = subject.id;
}
let roomId = dto.roomId;
if (!roomId && dto.roomNumber) {
const room = await this.prisma.room.upsert({
where: { number: dto.roomNumber },
update: {},
create: { number: dto.roomNumber },
});
roomId = room.id;
}
const data: any = { ...dto };
if (subjectId) data.subjectId = subjectId;
if (roomId) data.roomId = roomId;
delete data.subjectName;
delete data.roomNumber;
return this.prisma.timetableEntry.update({
where: { id: entryId },
data: data,
include: {
subject: true,
teacher: {
select: {
id: true,
username: true,
email: true,
},
},
room: true,
},
});
}
// Einen Stundenplan-Eintrag löschen (nur für Lehrer)
async deleteEntry(entryId: number) {
// Prüfen, ob der Eintrag existiert
const entry = await this.prisma.timetableEntry.findUnique({
where: { id: entryId },
});
if (!entry) {
throw new NotFoundException('Stundenplan-Eintrag nicht gefunden');
}
await this.prisma.timetableEntry.delete({
where: { id: entryId },
});
return { message: 'Stundenplan-Eintrag erfolgreich gelöscht' };
}
}

View File

@@ -0,0 +1,52 @@
import {
Controller,
Get,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('teachers')
async findTeachers() {
const users = await this.usersService.findAll();
return users.filter(user => user.role === 'Lehrer');
}
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
@Get()
findAll() {
return this.usersService.findAll();
}
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
@Patch(':id')
update(@Param('id') id: string, @Body() updateData: any) {
return this.usersService.update(+id, updateData);
}
@UseGuards(RolesGuard)
@Roles(['Lehrer'])
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findAll() {
return await this.prisma.user.findMany({
select: {
id: true,
email: true,
username: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
}
async findOne(id: number) {
return await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
username: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
}
async update(id: number, updateData: any) {
const data: any = {
email: updateData.email,
username: updateData.username,
role: updateData.role,
};
if (updateData.password) {
data.password = await bcrypt.hash(updateData.password, 10);
}
return await this.prisma.user.update({
where: { id },
data,
select: {
id: true,
email: true,
username: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
}
async remove(id: number) {
return await this.prisma.user.delete({
where: { id },
});
}
}

View File

@@ -0,0 +1,77 @@
import { IsString, IsNumber, IsArray, IsOptional, ValidateNested, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
export enum DrawingTool {
PEN = 'pen',
ERASER = 'eraser',
LINE = 'line',
RECTANGLE = 'rectangle',
CIRCLE = 'circle',
TEXT = 'text',
}
export class Point {
@IsNumber()
x: number;
@IsNumber()
y: number;
}
export class WhiteboardDrawDto {
@IsString()
whiteboardId: string;
@IsEnum(DrawingTool)
tool: DrawingTool;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Point)
points: Point[];
@IsString()
@IsOptional()
color?: string;
@IsNumber()
@IsOptional()
lineWidth?: number;
@IsString()
@IsOptional()
text?: string;
@IsString()
userId: string;
}
export class WhiteboardClearDto {
@IsString()
whiteboardId: string;
@IsString()
userId: string;
}
export class WhiteboardJoinDto {
@IsString()
whiteboardId: string;
@IsString()
userId: string;
}
export class WhiteboardCursorDto {
@IsString()
whiteboardId: string;
@IsString()
userId: string;
@IsNumber()
x: number;
@IsNumber()
y: number;
}

View File

@@ -0,0 +1,234 @@
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
WebSocketServer,
ConnectedSocket,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { WsJwtGuard } from '../auth/guards';
import { WhiteboardService } from './whiteboard.service';
import {
WhiteboardDrawDto,
WhiteboardClearDto,
WhiteboardJoinDto,
WhiteboardCursorDto,
} from './dto/whiteboard-draw.dto';
import { Logger } from '@nestjs/common';
@WebSocketGateway({
cors: {
origin: [
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://localhost:5500',
'http://127.0.0.1:5500',
'http://localhost:3000',
],
credentials: true,
},
namespace: '/whiteboard',
})
export class WhiteboardGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(WhiteboardGateway.name);
private userSockets: Map<string, { whiteboardId: string; userId: string }> = new Map();
constructor(
private readonly jwtService: JwtService,
private readonly whiteboardService: WhiteboardService,
) {}
handleConnection(client: Socket) {
try {
const token = this.extractTokenFromHandshake(client);
if (!token) {
this.logger.log(`Client ${client.id} rejected: No token provided`);
client.emit('error', { message: 'Unauthorized: No token provided' });
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production',
});
// User-Daten zum Socket hinzufügen
client.data.user = payload;
this.logger.log(`Client connected: ${client.id}, User: ${payload.username}`);
} catch (error) {
this.logger.log(`Client ${client.id} rejected: Invalid token`);
client.emit('error', { message: 'Unauthorized: Invalid token' });
client.disconnect();
}
}
private extractTokenFromHandshake(client: Socket): string | null {
// Token aus Cookie extrahieren
const cookies = client.handshake.headers.cookie;
if (cookies) {
const cookieArray = cookies.split(';');
for (const cookie of cookieArray) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
return value;
}
}
}
// Fallback: Token aus auth object
const token = client.handshake.auth?.token;
if (token) {
return token;
}
// Fallback: Token aus query parameter
const queryToken = client.handshake.query?.token as string;
if (queryToken) {
return queryToken;
}
return null;
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
const userData = this.userSockets.get(client.id);
if (userData) {
this.whiteboardService.leaveWhiteboard(userData.whiteboardId, userData.userId);
// Benachrichtige andere Benutzer
this.server.to(userData.whiteboardId).emit('user-left', {
userId: userData.userId,
activeUsers: this.whiteboardService.getActiveUsers(userData.whiteboardId),
});
this.userSockets.delete(client.id);
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('join-whiteboard')
handleJoinWhiteboard(
@MessageBody() data: WhiteboardJoinDto,
@ConnectedSocket() client: Socket,
) {
const { whiteboardId, userId } = data;
this.logger.log(`User ${userId} joining whiteboard ${whiteboardId}`);
// Verlasse altes Whiteboard falls vorhanden
const oldData = this.userSockets.get(client.id);
if (oldData) {
client.leave(oldData.whiteboardId);
this.whiteboardService.leaveWhiteboard(oldData.whiteboardId, oldData.userId);
}
// Trete neuem Whiteboard bei
client.join(whiteboardId);
this.whiteboardService.joinWhiteboard(whiteboardId, userId);
this.userSockets.set(client.id, { whiteboardId, userId });
// Sende aktuelle Whiteboard-Daten an den neuen Benutzer
const currentData = this.whiteboardService.getWhiteboardData(whiteboardId);
client.emit('whiteboard-state', currentData);
// Benachrichtige andere Benutzer über den neuen Teilnehmer
const activeUsers = this.whiteboardService.getActiveUsers(whiteboardId);
this.server.to(whiteboardId).emit('user-joined', {
userId,
activeUsers,
});
return {
success: true,
activeUsers,
};
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('draw')
handleDraw(
@MessageBody() data: WhiteboardDrawDto,
@ConnectedSocket() client: Socket,
) {
const { whiteboardId } = data;
// Speichere die Zeichnung
const drawingData = this.whiteboardService.addDrawing(whiteboardId, data);
// Sende die Zeichnung an alle anderen Benutzer im Whiteboard (außer dem Sender)
client.to(whiteboardId).emit('drawing', drawingData);
return { success: true };
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('clear-whiteboard')
handleClearWhiteboard(
@MessageBody() data: WhiteboardClearDto,
@ConnectedSocket() client: Socket,
) {
const { whiteboardId, userId } = data;
this.logger.log(`User ${userId} clearing whiteboard ${whiteboardId}`);
// Lösche das Whiteboard
this.whiteboardService.clearWhiteboard(whiteboardId);
// Benachrichtige alle Benutzer
this.server.to(whiteboardId).emit('whiteboard-cleared', { userId });
return { success: true };
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('cursor-move')
handleCursorMove(
@MessageBody() data: WhiteboardCursorDto,
@ConnectedSocket() client: Socket,
) {
const { whiteboardId, userId, x, y } = data;
// Sende Cursor-Position an alle anderen Benutzer
client.to(whiteboardId).emit('cursor-position', {
userId,
x,
y,
});
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('undo')
handleUndo(
@MessageBody() data: { whiteboardId: string; userId: string },
@ConnectedSocket() client: Socket,
) {
const { whiteboardId, userId } = data;
this.logger.log(`User ${userId} requesting undo on whiteboard ${whiteboardId}`);
// Benachrichtige alle Benutzer über Undo
this.server.to(whiteboardId).emit('undo-requested', { userId });
return { success: true };
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('get-active-users')
handleGetActiveUsers(
@MessageBody() data: { whiteboardId: string },
) {
const activeUsers = this.whiteboardService.getActiveUsers(data.whiteboardId);
return { activeUsers };
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { WhiteboardGateway } from './whiteboard.gateway';
import { WhiteboardService } from './whiteboard.service';
@Module({
imports: [JwtModule],
providers: [WhiteboardGateway, WhiteboardService],
exports: [WhiteboardService],
})
export class WhiteboardModule {}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { WhiteboardDrawDto } from './dto/whiteboard-draw.dto';
export interface DrawingData extends WhiteboardDrawDto {
timestamp: number;
id: string;
}
@Injectable()
export class WhiteboardService {
// In-Memory Storage für Whiteboard-Daten (später kann man das in eine DB auslagern)
private whiteboards: Map<string, DrawingData[]> = new Map();
private activeUsers: Map<string, Set<string>> = new Map();
addDrawing(whiteboardId: string, drawing: WhiteboardDrawDto): DrawingData {
const drawingData: DrawingData = {
...drawing,
timestamp: Date.now(),
id: `${Date.now()}-${Math.random()}`,
};
if (!this.whiteboards.has(whiteboardId)) {
this.whiteboards.set(whiteboardId, []);
}
this.whiteboards.get(whiteboardId)!.push(drawingData);
return drawingData;
}
clearWhiteboard(whiteboardId: string): void {
this.whiteboards.set(whiteboardId, []);
}
getWhiteboardData(whiteboardId: string): DrawingData[] {
return this.whiteboards.get(whiteboardId) || [];
}
joinWhiteboard(whiteboardId: string, userId: string): void {
if (!this.activeUsers.has(whiteboardId)) {
this.activeUsers.set(whiteboardId, new Set());
}
this.activeUsers.get(whiteboardId)!.add(userId);
}
leaveWhiteboard(whiteboardId: string, userId: string): void {
const users = this.activeUsers.get(whiteboardId);
if (users) {
users.delete(userId);
if (users.size === 0) {
this.activeUsers.delete(whiteboardId);
}
}
}
getActiveUsers(whiteboardId: string): string[] {
return Array.from(this.activeUsers.get(whiteboardId) || []);
}
getActiveUserCount(whiteboardId: string): number {
return this.activeUsers.get(whiteboardId)?.size || 0;
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,27 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vscode
!.vscode/extensions.json
!.vscode/mcp.json

View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -0,0 +1,14 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "build/index.js"]

Some files were not shown because too many files have changed in this diff Show More