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