first commit
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal 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
40
.gitignore
vendored
Normal 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/
|
||||||
16
Smartes-Klassenzimmer-Backend/.env.example
Normal file
16
Smartes-Klassenzimmer-Backend/.env.example
Normal 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"
|
||||||
75
Smartes-Klassenzimmer-Backend/.gitignore
vendored
Normal file
75
Smartes-Klassenzimmer-Backend/.gitignore
vendored
Normal 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*
|
||||||
4
Smartes-Klassenzimmer-Backend/.prettierrc
Normal file
4
Smartes-Klassenzimmer-Backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
20
Smartes-Klassenzimmer-Backend/Dockerfile
Normal file
20
Smartes-Klassenzimmer-Backend/Dockerfile
Normal 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"]
|
||||||
19
Smartes-Klassenzimmer-Backend/README.md
Normal file
19
Smartes-Klassenzimmer-Backend/README.md
Normal 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`.
|
||||||
35
Smartes-Klassenzimmer-Backend/eslint.config.mjs
Normal file
35
Smartes-Klassenzimmer-Backend/eslint.config.mjs
Normal 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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
9
Smartes-Klassenzimmer-Backend/nest-cli.json
Normal file
9
Smartes-Klassenzimmer-Backend/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": ["mail/templates/**/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
12303
Smartes-Klassenzimmer-Backend/package-lock.json
generated
Normal file
12303
Smartes-Klassenzimmer-Backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
Smartes-Klassenzimmer-Backend/package.json
Normal file
74
Smartes-Klassenzimmer-Backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Smartes-Klassenzimmer-Backend/prisma.config.ts
Normal file
13
Smartes-Klassenzimmer-Backend/prisma.config.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "passwordResetExpires" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "passwordResetToken" TEXT;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
279
Smartes-Klassenzimmer-Backend/prisma/schema.prisma
Normal file
279
Smartes-Klassenzimmer-Backend/prisma/schema.prisma
Normal 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")
|
||||||
|
}
|
||||||
236
Smartes-Klassenzimmer-Backend/prisma/seed.ts
Normal file
236
Smartes-Klassenzimmer-Backend/prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
12
Smartes-Klassenzimmer-Backend/src/app.controller.ts
Normal file
12
Smartes-Klassenzimmer-Backend/src/app.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Smartes-Klassenzimmer-Backend/src/app.module.ts
Normal file
44
Smartes-Klassenzimmer-Backend/src/app.module.ts
Normal 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 {}
|
||||||
12
Smartes-Klassenzimmer-Backend/src/app.service.ts
Normal file
12
Smartes-Klassenzimmer-Backend/src/app.service.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
88
Smartes-Klassenzimmer-Backend/src/auth/auth.controller.ts
Normal file
88
Smartes-Klassenzimmer-Backend/src/auth/auth.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Smartes-Klassenzimmer-Backend/src/auth/auth.module.ts
Normal file
26
Smartes-Klassenzimmer-Backend/src/auth/auth.module.ts
Normal 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 {}
|
||||||
193
Smartes-Klassenzimmer-Backend/src/auth/auth.service.ts
Normal file
193
Smartes-Klassenzimmer-Backend/src/auth/auth.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class ForgotPasswordDto {
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
4
Smartes-Klassenzimmer-Backend/src/auth/dto/index.ts
Normal file
4
Smartes-Klassenzimmer-Backend/src/auth/dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './login.dto';
|
||||||
|
export * from './register.dto';
|
||||||
|
export * from './forgot-password.dto';
|
||||||
|
export * from './reset-password.dto';
|
||||||
11
Smartes-Klassenzimmer-Backend/src/auth/dto/login.dto.ts
Normal file
11
Smartes-Klassenzimmer-Backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
22
Smartes-Klassenzimmer-Backend/src/auth/dto/register.dto.ts
Normal file
22
Smartes-Klassenzimmer-Backend/src/auth/dto/register.dto.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
3
Smartes-Klassenzimmer-Backend/src/auth/guards/index.ts
Normal file
3
Smartes-Klassenzimmer-Backend/src/auth/guards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './jwt-auth.guard';
|
||||||
|
export * from './ws-jwt.guard';
|
||||||
|
export * from './roles.guard';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
31
Smartes-Klassenzimmer-Backend/src/auth/guards/roles.guard.ts
Normal file
31
Smartes-Klassenzimmer-Backend/src/auth/guards/roles.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Smartes-Klassenzimmer-Backend/src/chat/chat.controller.ts
Normal file
96
Smartes-Klassenzimmer-Backend/src/chat/chat.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
Smartes-Klassenzimmer-Backend/src/chat/chat.gateway.ts
Normal file
233
Smartes-Klassenzimmer-Backend/src/chat/chat.gateway.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Smartes-Klassenzimmer-Backend/src/chat/chat.module.ts
Normal file
13
Smartes-Klassenzimmer-Backend/src/chat/chat.module.ts
Normal 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 {}
|
||||||
332
Smartes-Klassenzimmer-Backend/src/chat/chat.service.ts
Normal file
332
Smartes-Klassenzimmer-Backend/src/chat/chat.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsArray, IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class AddMembersDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsNumber({}, { each: true })
|
||||||
|
userIds: number[];
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateGroupDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
178
Smartes-Klassenzimmer-Backend/src/classroom/classroom.gateway.ts
Normal file
178
Smartes-Klassenzimmer-Backend/src/classroom/classroom.gateway.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateFeedbackDto } from './create-feedback.dto';
|
||||||
|
|
||||||
|
export class UpdateFeedbackDto extends PartialType(CreateFeedbackDto) {}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
623
Smartes-Klassenzimmer-Backend/src/feedback/feedback.service.ts
Normal file
623
Smartes-Klassenzimmer-Backend/src/feedback/feedback.service.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Smartes-Klassenzimmer-Backend/src/files/files.controller.ts
Normal file
87
Smartes-Klassenzimmer-Backend/src/files/files.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Smartes-Klassenzimmer-Backend/src/files/files.module.ts
Normal file
11
Smartes-Klassenzimmer-Backend/src/files/files.module.ts
Normal 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 {}
|
||||||
93
Smartes-Klassenzimmer-Backend/src/files/files.service.ts
Normal file
93
Smartes-Klassenzimmer-Backend/src/files/files.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
2
Smartes-Klassenzimmer-Backend/src/grades/dto/index.ts
Normal file
2
Smartes-Klassenzimmer-Backend/src/grades/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './create-grade.dto';
|
||||||
|
export * from './update-grade.dto';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateGradeDto } from './create-grade.dto';
|
||||||
|
|
||||||
|
export class UpdateGradeDto extends PartialType(CreateGradeDto) {}
|
||||||
147
Smartes-Klassenzimmer-Backend/src/grades/grades.controller.ts
Normal file
147
Smartes-Klassenzimmer-Backend/src/grades/grades.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Smartes-Klassenzimmer-Backend/src/grades/grades.module.ts
Normal file
12
Smartes-Klassenzimmer-Backend/src/grades/grades.module.ts
Normal 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 {}
|
||||||
617
Smartes-Klassenzimmer-Backend/src/grades/grades.service.ts
Normal file
617
Smartes-Klassenzimmer-Backend/src/grades/grades.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
93
Smartes-Klassenzimmer-Backend/src/iot/iot.controller.ts
Normal file
93
Smartes-Klassenzimmer-Backend/src/iot/iot.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Smartes-Klassenzimmer-Backend/src/iot/iot.module.ts
Normal file
12
Smartes-Klassenzimmer-Backend/src/iot/iot.module.ts
Normal 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 {}
|
||||||
461
Smartes-Klassenzimmer-Backend/src/iot/iot.service.ts
Normal file
461
Smartes-Klassenzimmer-Backend/src/iot/iot.service.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Smartes-Klassenzimmer-Backend/src/mail/mail.module.ts
Normal file
38
Smartes-Klassenzimmer-Backend/src/mail/mail.module.ts
Normal 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 {}
|
||||||
38
Smartes-Klassenzimmer-Backend/src/mail/mail.service.ts
Normal file
38
Smartes-Klassenzimmer-Backend/src/mail/mail.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
41
Smartes-Klassenzimmer-Backend/src/main.ts
Normal file
41
Smartes-Klassenzimmer-Backend/src/main.ts
Normal 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();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
15
Smartes-Klassenzimmer-Backend/src/prisma/prisma.service.ts
Normal file
15
Smartes-Klassenzimmer-Backend/src/prisma/prisma.service.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
2
Smartes-Klassenzimmer-Backend/src/rooms/dto/index.ts
Normal file
2
Smartes-Klassenzimmer-Backend/src/rooms/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './create-room.dto';
|
||||||
|
export * from './update-room.dto';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateRoomDto } from './create-room.dto';
|
||||||
|
|
||||||
|
export class UpdateRoomDto extends PartialType(CreateRoomDto) {}
|
||||||
60
Smartes-Klassenzimmer-Backend/src/rooms/rooms.controller.ts
Normal file
60
Smartes-Klassenzimmer-Backend/src/rooms/rooms.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Smartes-Klassenzimmer-Backend/src/rooms/rooms.module.ts
Normal file
12
Smartes-Klassenzimmer-Backend/src/rooms/rooms.module.ts
Normal 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 {}
|
||||||
86
Smartes-Klassenzimmer-Backend/src/rooms/rooms.service.ts
Normal file
86
Smartes-Klassenzimmer-Backend/src/rooms/rooms.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
2
Smartes-Klassenzimmer-Backend/src/subjects/dto/index.ts
Normal file
2
Smartes-Klassenzimmer-Backend/src/subjects/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './create-subject.dto';
|
||||||
|
export * from './update-subject.dto';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateSubjectDto } from './create-subject.dto';
|
||||||
|
|
||||||
|
export class UpdateSubjectDto extends PartialType(CreateSubjectDto) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
2
Smartes-Klassenzimmer-Backend/src/timetable/dto/index.ts
Normal file
2
Smartes-Klassenzimmer-Backend/src/timetable/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './create-timetable-entry.dto';
|
||||||
|
export * from './update-timetable-entry.dto';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateTimetableEntryDto } from './create-timetable-entry.dto';
|
||||||
|
|
||||||
|
export class UpdateTimetableEntryDto extends PartialType(
|
||||||
|
CreateTimetableEntryDto,
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
180
Smartes-Klassenzimmer-Backend/src/timetable/timetable.service.ts
Normal file
180
Smartes-Klassenzimmer-Backend/src/timetable/timetable.service.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Smartes-Klassenzimmer-Backend/src/users/users.controller.ts
Normal file
52
Smartes-Klassenzimmer-Backend/src/users/users.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Smartes-Klassenzimmer-Backend/src/users/users.module.ts
Normal file
11
Smartes-Klassenzimmer-Backend/src/users/users.module.ts
Normal 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 {}
|
||||||
66
Smartes-Klassenzimmer-Backend/src/users/users.service.ts
Normal file
66
Smartes-Klassenzimmer-Backend/src/users/users.service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
Smartes-Klassenzimmer-Backend/tsconfig.build.json
Normal file
4
Smartes-Klassenzimmer-Backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
Smartes-Klassenzimmer-Backend/tsconfig.json
Normal file
25
Smartes-Klassenzimmer-Backend/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Smartes-Klassenzimmer-Backend/uploads/1765295849966-65353750.png
Normal file
BIN
Smartes-Klassenzimmer-Backend/uploads/1765295849966-65353750.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
27
Smartes-Klassenzimmer-Frontend/.gitignore
vendored
Normal file
27
Smartes-Klassenzimmer-Frontend/.gitignore
vendored
Normal 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
|
||||||
1
Smartes-Klassenzimmer-Frontend/.npmrc
Normal file
1
Smartes-Klassenzimmer-Frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
14
Smartes-Klassenzimmer-Frontend/Dockerfile
Normal file
14
Smartes-Klassenzimmer-Frontend/Dockerfile
Normal 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
Reference in New Issue
Block a user