diff --git a/.eslintrc.js b/.eslintrc.js index d687ee1..a6b1fb9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,7 +5,7 @@ module.exports = { env: { node: true, }, - ignorePatterns: ['.eslintrc.js', '.prettierrc.js'], + ignorePatterns: ['.eslintrc.js', '.prettierrc.js', 'apps/frontend/next-env.d.ts'], rules: { 'prettier/prettier': 'error', 'simple-import-sort/imports': 'error', diff --git a/.prettierignore b/.prettierignore index f074915..1e52b62 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules .idea dist -.next \ No newline at end of file +.next +next-env.d.ts diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js new file mode 100644 index 0000000..702d281 --- /dev/null +++ b/apps/backend/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.{ts,js}'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/apps/backend/package.json b/apps/backend/package.json index 7a99bcc..63593a5 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,8 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main" + "start:prod": "node dist/main", + "test:backend": "jest" }, "dependencies": { "@kir-dev/passport-authsch": "^2.2.2", @@ -37,11 +38,15 @@ "devDependencies": { "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.1", + "@nestjs/testing": "^11.1.17", "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", + "jest": "^30.3.0", "source-map-support": "^0.5.21", + "ts-jest": "^29.4.6", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql b/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql new file mode 100644 index 0000000..4396b3d --- /dev/null +++ b/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql @@ -0,0 +1,20 @@ +-- AlterEnum +ALTER TYPE "ReservationStatus" ADD VALUE 'SANCTIONED'; + +-- AlterTable +ALTER TABLE "Period" ADD COLUMN "isOpen" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "sanctionPoints" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "Settings" ( + "id" SERIAL NOT NULL, + "maxHoursPerWeek" DOUBLE PRECISION NOT NULL DEFAULT 8.0, + "maxHoursPerDay" DOUBLE PRECISION NOT NULL DEFAULT 4.0, + "minReservationMinutes" INTEGER NOT NULL DEFAULT 30, + "maxReservationMinutes" INTEGER NOT NULL DEFAULT 180, + "sanctionHourPenaltyPerPoint" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + + CONSTRAINT "Settings_pkey" PRIMARY KEY ("id") +); diff --git a/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql b/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql new file mode 100644 index 0000000..07def91 --- /dev/null +++ b/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Reservation" ADD COLUMN "needToBeLetIn" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql b/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql new file mode 100644 index 0000000..5d3d485 --- /dev/null +++ b/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `sanctionPoints` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "sanctionPoints"; + +-- CreateTable +CREATE TABLE "SanctionRecord" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "bandId" INTEGER, + "points" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "awardedBy" INTEGER NOT NULL, + "awardedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SanctionRecord_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_bandId_fkey" FOREIGN KEY ("bandId") REFERENCES "Band"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_awardedBy_fkey" FOREIGN KEY ("awardedBy") REFERENCES "ClubMembership"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260320130812_add_gatekeeper_priority/migration.sql b/apps/backend/prisma/migrations/20260320130812_add_gatekeeper_priority/migration.sql new file mode 100644 index 0000000..dcc763b --- /dev/null +++ b/apps/backend/prisma/migrations/20260320130812_add_gatekeeper_priority/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "GateKeeperPriority" AS ENUM ('PRIMARY', 'SECONDARY'); + +-- AlterTable +ALTER TABLE "Reservation" ADD COLUMN "gateKeeperPriority" "GateKeeperPriority"; diff --git a/apps/backend/prisma/migrations/20260322135731_remove_sanction_type/migration.sql b/apps/backend/prisma/migrations/20260322135731_remove_sanction_type/migration.sql new file mode 100644 index 0000000..301f534 --- /dev/null +++ b/apps/backend/prisma/migrations/20260322135731_remove_sanction_type/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [SANCTIONED] on the enum `ReservationStatus` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "ReservationStatus_new" AS ENUM ('NORMAL', 'OVERTIME', 'ADMINMADE'); +ALTER TABLE "Reservation" ALTER COLUMN "status" TYPE "ReservationStatus_new" USING ("status"::text::"ReservationStatus_new"); +ALTER TYPE "ReservationStatus" RENAME TO "ReservationStatus_old"; +ALTER TYPE "ReservationStatus_new" RENAME TO "ReservationStatus"; +DROP TYPE "ReservationStatus_old"; +COMMIT; diff --git a/apps/backend/prisma/migrations/20260322155651_add_weeks_and_extra_settings_attributes/migration.sql b/apps/backend/prisma/migrations/20260322155651_add_weeks_and_extra_settings_attributes/migration.sql new file mode 100644 index 0000000..a471725 --- /dev/null +++ b/apps/backend/prisma/migrations/20260322155651_add_weeks_and_extra_settings_attributes/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "banSanctionPointThreshold" INTEGER NOT NULL DEFAULT 5, +ADD COLUMN "maxTotalHoursPerWeek" DOUBLE PRECISION NOT NULL DEFAULT 12.0, +ADD COLUMN "sanctionTotalHourPenaltyPerPoint" DOUBLE PRECISION NOT NULL DEFAULT 2.0; + +-- CreateTable +CREATE TABLE "OpenedWeek" ( + "id" SERIAL NOT NULL, + "monday" TIMESTAMP(3) NOT NULL, + "isOpen" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "OpenedWeek_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OpenedWeek_monday_key" ON "OpenedWeek"("monday"); diff --git a/apps/backend/prisma/migrations/20260323103330_removed_unnecessary_reservation_config_models/migration.sql b/apps/backend/prisma/migrations/20260323103330_removed_unnecessary_reservation_config_models/migration.sql new file mode 100644 index 0000000..3ec6414 --- /dev/null +++ b/apps/backend/prisma/migrations/20260323103330_removed_unnecessary_reservation_config_models/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the `ReservationConfig` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `SanctionTier` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SanctionTier" DROP CONSTRAINT "SanctionTier_configId_fkey"; + +-- DropTable +DROP TABLE "ReservationConfig"; + +-- DropTable +DROP TABLE "SanctionTier"; diff --git a/apps/backend/prisma/migrations/20260326114226_init/migration.sql b/apps/backend/prisma/migrations/20260326114226_init/migration.sql new file mode 100644 index 0000000..8f0745f --- /dev/null +++ b/apps/backend/prisma/migrations/20260326114226_init/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SanctionRecord" ADD COLUMN "reservationId" INTEGER; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_reservationId_fkey" FOREIGN KEY ("reservationId") REFERENCES "Reservation"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260618150000_align_reservation_quota_defaults/migration.sql b/apps/backend/prisma/migrations/20260618150000_align_reservation_quota_defaults/migration.sql new file mode 100644 index 0000000..8f2e577 --- /dev/null +++ b/apps/backend/prisma/migrations/20260618150000_align_reservation_quota_defaults/migration.sql @@ -0,0 +1,10 @@ +ALTER TABLE "Settings" ALTER COLUMN "maxHoursPerWeek" SET DEFAULT 6.0; +ALTER TABLE "Settings" ALTER COLUMN "maxHoursPerDay" SET DEFAULT 3.0; + +UPDATE "Settings" +SET "maxHoursPerWeek" = 6.0 +WHERE "maxHoursPerWeek" = 8.0; + +UPDATE "Settings" +SET "maxHoursPerDay" = 3.0 +WHERE "maxHoursPerDay" = 4.0; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e00e147..8b565c5 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -29,12 +29,17 @@ enum BandMembershipStatus { ACCEPTED } +enum GateKeeperPriority { + PRIMARY + SECONDARY +} + model User { - id Int @id @default(autoincrement()) - email String @unique - phone String? @unique - role Role @default(USER) - authSchId String? @unique + id Int @id @default(autoincrement()) + email String @unique + phone String? @unique + role Role @default(USER) + authSchId String? @unique fullName String clubMembershipUpdatedAt DateTime? bandMemberships BandMembership[] @@ -42,7 +47,8 @@ model User { DormResidency DormResidency? Post Post[] profilePicture ProfilePicture? - reservations Reservation[] @relation("Reservations") + reservations Reservation[] @relation("Reservations") + sanctionRecords SanctionRecord[] @relation("UserSanctions") } model ProfilePicture { @@ -70,17 +76,19 @@ model ClubMembership { isGateKeeper Boolean user User @relation(fields: [userId], references: [id]) gateKeepingRecords Reservation[] @relation("GateKeeping") + sanctionsAwarded SanctionRecord[] @relation("GateKeeperSanctions") } model Band { - id Int @id @default(autoincrement()) - name String - email String? - webPage String? - description String? - genres String[] - members BandMembership[] - reservations Reservation[] + id Int @id @default(autoincrement()) + name String + email String? + webPage String? + description String? + genres String[] + members BandMembership[] + reservations Reservation[] + sanctionRecords SanctionRecord[] @relation("BandSanctions") } model BandMembership { @@ -95,16 +103,19 @@ model BandMembership { } model Reservation { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) userId Int? bandId Int? startTime DateTime endTime DateTime - gateKeeperId Int? - status ReservationStatus - band Band? @relation(fields: [bandId], references: [id]) - gateKeeper ClubMembership? @relation("GateKeeping", fields: [gateKeeperId], references: [id]) - user User? @relation("Reservations", fields: [userId], references: [id]) + gateKeeperId Int? + gateKeeperPriority GateKeeperPriority? + status ReservationStatus + needToBeLetIn Boolean @default(false) + band Band? @relation(fields: [bandId], references: [id]) + gateKeeper ClubMembership? @relation("GateKeeping", fields: [gateKeeperId], references: [id]) + user User? @relation("Reservations", fields: [userId], references: [id]) + sanctionRecords SanctionRecord[] } model Comment { @@ -119,6 +130,7 @@ model Period { id Int @id @default(autoincrement()) startDate DateTime endDate DateTime + isOpen Boolean @default(false) } model Post { @@ -131,22 +143,36 @@ model Post { User User @relation(fields: [authorId], references: [id]) } -model ReservationConfig { - id Int @id @default(autoincrement()) - userDailyHours Float @default(4) - userWeeklyHours Float @default(8) - bandDailyHours Float @default(6) - bandWeeklyHours Float @default(12) - sanctionTiers SanctionTier[] -} - -model SanctionTier { - id Int @id @default(autoincrement()) - configId Int - config ReservationConfig @relation(fields: [configId], references: [id]) - minPoints Int - userDailyHours Float - userWeeklyHours Float - bandDailyHours Float - bandWeeklyHours Float +model Settings { + id Int @id @default(autoincrement()) + maxHoursPerWeek Float @default(6.0) + maxHoursPerDay Float @default(3.0) + minReservationMinutes Int @default(30) + maxReservationMinutes Int @default(180) + sanctionHourPenaltyPerPoint Float @default(1.0) + maxTotalHoursPerWeek Float @default(12.0) + sanctionTotalHourPenaltyPerPoint Float @default(2.0) + banSanctionPointThreshold Int @default(5) +} + +model OpenedWeek { + id Int @id @default(autoincrement()) + monday DateTime @unique + isOpen Boolean @default(false) +} + +model SanctionRecord { + id Int @id @default(autoincrement()) + userId Int? + bandId Int? + reservationId Int? + points Int + reason String + awardedBy Int + awardedAt DateTime @default(now()) + + user User? @relation("UserSanctions", fields: [userId], references: [id]) + band Band? @relation("BandSanctions", fields: [bandId], references: [id]) + reservation Reservation? @relation(fields: [reservationId], references: [id]) + gateKeeper ClubMembership @relation("GateKeeperSanctions", fields: [awardedBy], references: [id]) } diff --git a/apps/backend/src/admin/admin.controller.ts b/apps/backend/src/admin/admin.controller.ts deleted file mode 100644 index 0bdc134..0000000 --- a/apps/backend/src/admin/admin.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CurrentUser } from '@kir-dev/passport-authsch'; -import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { ApiBearerAuth } from '@nestjs/swagger'; -import { Role, User } from '@prisma/client'; - -import { Roles } from '../auth/decorators/Roles.decorator'; -import { RolesGuard } from '../auth/roles.guard'; -import { AdminService } from './admin.service'; -import { SetRoleDto } from './dto/set-role.dto'; -import { UpdateConfigDto } from './dto/update-config.dto'; - -@Controller('admin') -@ApiBearerAuth() -@UseGuards(AuthGuard('jwt'), RolesGuard) -@Roles(Role.ADMIN) -export class AdminController { - constructor(private readonly adminService: AdminService) {} - - @Get('config') - getConfig() { - return this.adminService.getConfig(); - } - - @Patch('config') - updateConfig(@Body() updateConfigDto: UpdateConfigDto) { - return this.adminService.updateConfig(updateConfigDto); - } - - @Patch('users/:id/role') - setUserRole(@Param('id', ParseIntPipe) targetId: number, @Body() setRoleDto: SetRoleDto, @CurrentUser() user: User) { - return this.adminService.setUserRole(user.id, targetId, setRoleDto.role); - } -} diff --git a/apps/backend/src/admin/admin.module.ts b/apps/backend/src/admin/admin.module.ts deleted file mode 100644 index a8ef137..0000000 --- a/apps/backend/src/admin/admin.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AdminController } from './admin.controller'; -import { AdminService } from './admin.service'; - -@Module({ - controllers: [AdminController], - providers: [AdminService], -}) -export class AdminModule {} diff --git a/apps/backend/src/admin/admin.service.ts b/apps/backend/src/admin/admin.service.ts deleted file mode 100644 index 01f4abf..0000000 --- a/apps/backend/src/admin/admin.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { PrismaService } from 'nestjs-prisma'; - -import { UpdateConfigDto } from './dto/update-config.dto'; - -const CONFIG_ID = 1; - -@Injectable() -export class AdminService { - constructor(private readonly prisma: PrismaService) {} - - async getConfig() { - return this.prisma.reservationConfig.upsert({ - where: { id: CONFIG_ID }, - update: {}, - create: {}, - include: { sanctionTiers: { orderBy: { minPoints: 'asc' } } }, - }); - } - - async updateConfig(dto: UpdateConfigDto) { - try { - return await this.prisma.$transaction(async (tx) => { - const { sanctionTiers, ...configFields } = dto; - - const config = await tx.reservationConfig.upsert({ - where: { id: CONFIG_ID }, - update: configFields, - create: { id: CONFIG_ID, ...configFields }, - }); - - if (sanctionTiers !== undefined) { - await tx.sanctionTier.deleteMany({ where: { configId: config.id } }); - - if (sanctionTiers.length > 0) { - await tx.sanctionTier.createMany({ - data: sanctionTiers.map((tier) => ({ ...tier, configId: config.id })), - }); - } - } - - return tx.reservationConfig.findUnique({ - where: { id: config.id }, - include: { sanctionTiers: { orderBy: { minPoints: 'asc' } } }, - }); - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`Config not found.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - throw e; - } - } - - async setUserRole(requesterId: number, targetId: number, role: string) { - if (requesterId === targetId) { - throw new ForbiddenException('You cannot change your own role.'); - } - try { - return await this.prisma.user.update({ - where: { id: targetId }, - data: { role: role as 'USER' | 'ADMIN' }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`User with id ${targetId} not found.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - throw e; - } - } -} diff --git a/apps/backend/src/admin/dto/set-role.dto.ts b/apps/backend/src/admin/dto/set-role.dto.ts deleted file mode 100644 index 023c36d..0000000 --- a/apps/backend/src/admin/dto/set-role.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Role } from '@prisma/client'; -import { IsEnum } from 'class-validator'; - -export class SetRoleDto { - @IsEnum(Role) - role: Role; -} diff --git a/apps/backend/src/admin/dto/update-config.dto.ts b/apps/backend/src/admin/dto/update-config.dto.ts deleted file mode 100644 index 8e5ba14..0000000 --- a/apps/backend/src/admin/dto/update-config.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNumber, IsOptional, IsPositive, Min, ValidateNested } from 'class-validator'; - -export class SanctionTierDto { - @IsInt() - @Min(0) - minPoints: number; - - @IsNumber() - @IsPositive() - userDailyHours: number; - - @IsNumber() - @IsPositive() - userWeeklyHours: number; - - @IsNumber() - @IsPositive() - bandDailyHours: number; - - @IsNumber() - @IsPositive() - bandWeeklyHours: number; -} - -export class UpdateConfigDto { - @IsNumber() - @IsPositive() - @IsOptional() - userDailyHours?: number; - - @IsNumber() - @IsPositive() - @IsOptional() - userWeeklyHours?: number; - - @IsNumber() - @IsPositive() - @IsOptional() - bandDailyHours?: number; - - @IsNumber() - @IsPositive() - @IsOptional() - bandWeeklyHours?: number; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => SanctionTierDto) - @IsOptional() - sanctionTiers?: SanctionTierDto[]; -} diff --git a/apps/backend/src/admin/entities/reservation-config.entity.ts b/apps/backend/src/admin/entities/reservation-config.entity.ts deleted file mode 100644 index 0709c9f..0000000 --- a/apps/backend/src/admin/entities/reservation-config.entity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNumber, IsOptional, IsPositive, Min, ValidateNested } from 'class-validator'; - -export class SanctionTier { - @IsInt() - @Min(0) - minPoints: number; - - @IsNumber() - @IsPositive() - userDailyHours: number; - - @IsNumber() - @IsPositive() - userWeeklyHours: number; - - @IsNumber() - @IsPositive() - bandDailyHours: number; - - @IsNumber() - @IsPositive() - bandWeeklyHours: number; -} - -export class ReservationConfig { - @IsNumber() - @IsPositive() - id: number; - - @IsNumber() - @IsPositive() - userDailyHours: number; - - @IsNumber() - @IsPositive() - userWeeklyHours: number; - - @IsNumber() - @IsPositive() - bandDailyHours: number; - - @IsNumber() - @IsPositive() - bandWeeklyHours: number; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => SanctionTier) - @IsOptional() - sanctionTiers: SanctionTier[]; -} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 3e73b82..3398738 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -2,15 +2,18 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from 'nestjs-prisma'; -import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { BandsModule } from './bands/bandsModule'; import { CommentsModule } from './comments/comments.module'; import { MembershipsModule } from './memberships/memberships.module'; +import { OpenedWeeksModule } from './opened-weeks/opened-weeks.module'; +import { PeriodsModule } from './periods/periods.module'; import { PostsModule } from './posts/posts.module'; import { ReservationsModule } from './reservations/reservations.module'; +import { SanctionRecordsModule } from './sanction-records/sanction-records.module'; +import { SettingsModule } from './settings/settings.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -23,8 +26,11 @@ import { UsersModule } from './users/users.module'; AuthModule, MembershipsModule, PostsModule, + SettingsModule, + PeriodsModule, + SanctionRecordsModule, ConfigModule.forRoot(), - AdminModule, + OpenedWeeksModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/bands/bands.service.ts b/apps/backend/src/bands/bands.service.ts index 4d1e44b..239cdee 100644 --- a/apps/backend/src/bands/bands.service.ts +++ b/apps/backend/src/bands/bands.service.ts @@ -24,7 +24,10 @@ export class BandsService { async findOne(id: number): Promise { try { - const res = await this.prisma.band.findUniqueOrThrow({ where: { id } }); + const res = await this.prisma.band.findUniqueOrThrow({ + where: { id }, + include: { members: { include: { user: { select: { fullName: true } } } } }, + }); return res; } catch (error) { throw new NotFoundException('No band found'); diff --git a/apps/backend/src/bands/bandsController.ts b/apps/backend/src/bands/bandsController.ts index 82736cd..54ecef8 100644 --- a/apps/backend/src/bands/bandsController.ts +++ b/apps/backend/src/bands/bandsController.ts @@ -21,11 +21,15 @@ export class BandsController { } @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) findAll() { return this.bandsService.findAll(); } @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) findOne(@Param('id', ParseIntPipe) id: number) { return this.bandsService.findOne(id); } @@ -47,6 +51,8 @@ export class BandsController { } @Get(':id/members') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) findMembers(@Param('id', ParseIntPipe) id: number) { return this.bandsService.findMembers(id); } diff --git a/apps/backend/src/comments/comments.controller.ts b/apps/backend/src/comments/comments.controller.ts index 7ebce3b..a2e677c 100644 --- a/apps/backend/src/comments/comments.controller.ts +++ b/apps/backend/src/comments/comments.controller.ts @@ -1,6 +1,9 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; import { CommentsService } from './comments.service'; import { CreateCommentDto } from './dto/create-comment.dto'; @@ -12,31 +15,38 @@ export class CommentsController { @Post() @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) create(@Body() createCommentDto: CreateCommentDto) { return this.commentsService.create(createCommentDto); } @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) findAll(@Query('page', ParseIntPipe) page: number, @Query('page_size', ParseIntPipe) pageSize: number) { return this.commentsService.findAll(page, pageSize); } @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) findOne(@Param('id', ParseIntPipe) id: number) { return this.commentsService.findOne(id); } @Patch(':id') @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) update(@Param('id', ParseIntPipe) id: number, @Body() updateCommentDto: UpdateCommentDto) { return this.commentsService.update(id, updateCommentDto); } @Delete(':id') @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) remove(@Param('id', ParseIntPipe) id: number) { return this.commentsService.remove(id); } diff --git a/apps/backend/src/comments/comments.service.ts b/apps/backend/src/comments/comments.service.ts index 8b2b156..00bfb7c 100644 --- a/apps/backend/src/comments/comments.service.ts +++ b/apps/backend/src/comments/comments.service.ts @@ -1,5 +1,5 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, ReservationStatus } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { PaginationDto } from '../dto/pagination.dto'; @@ -11,11 +11,26 @@ import { Comment } from './entities/comment.entity'; export class CommentsService { constructor(private readonly prisma: PrismaService) {} - create(createCommentDto: CreateCommentDto) { - return this.prisma.comment.create({ - data: { - ...createCommentDto, - }, + async create(createCommentDto: CreateCommentDto) { + // Create the comment and purge overlapping reservations atomically, so a failure + // in the deletion step doesn't leave a committed comment behind. + return this.prisma.$transaction(async (tx) => { + const comment = await tx.comment.create({ + data: { + ...createCommentDto, + }, + }); + + // If the new comment blocks reservations, delete any that overlap its window + if (!createCommentDto.isReservable) { + await this.deleteOverlappingReservations( + new Date(createCommentDto.startTime), + new Date(createCommentDto.endTime), + tx + ); + } + + return comment; }); } @@ -29,7 +44,7 @@ export class CommentsService { }, }); - const count = this.prisma.reservation.count(); + const count = this.prisma.comment.count(); return Promise.all([comments, count]) .then(([comments, count]) => { @@ -65,13 +80,23 @@ export class CommentsService { async update(id: number, updateCommentDto: UpdateCommentDto) { try { - return await this.prisma.comment.update({ - where: { - id, - }, - data: { - ...updateCommentDto, - }, + // Update the comment and purge overlapping reservations atomically. + return await this.prisma.$transaction(async (tx) => { + const updated = await tx.comment.update({ + where: { + id, + }, + data: { + ...updateCommentDto, + }, + }); + + // If the updated comment now blocks reservations, purge any overlapping ones + if (updated.isReservable === false) { + await this.deleteOverlappingReservations(new Date(updated.startTime), new Date(updated.endTime), tx); + } + + return updated; }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { @@ -99,4 +124,28 @@ export class CommentsService { } } } + + /** + * Deletes all reservations whose time window overlaps with [startTime, endTime]. + * Called automatically when a non-reservable comment is created or updated. + * A reservation overlaps if it starts before the window ends AND ends after the window starts. + * + * @returns the number of reservations deleted + */ + async deleteOverlappingReservations( + startTime: Date, + endTime: Date, + client: Prisma.TransactionClient = this.prisma + ): Promise { + const { count } = await client.reservation.deleteMany({ + where: { + AND: [ + { startTime: { lt: endTime } }, // reservation starts before comment ends + { endTime: { gt: startTime } }, // reservation ends after comment starts + { status: { not: ReservationStatus.ADMINMADE } }, // admin reservations are never auto-deleted + ], + }, + }); + return count; + } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index f8f107f..2d3488a 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -4,6 +4,12 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; +// Pin the process timezone so all server-side week/day calculations (reservation quotas, opened +// weeks, period boundaries) use the club's local time regardless of where the server is hosted. +// Without this, a UTC-hosted server computes week boundaries differently from the (local-time) +// frontend, which breaks the exact-match lookup of opened weeks. Overridable via the TZ env var. +process.env.TZ = process.env.TZ || 'Europe/Budapest'; + async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors({ diff --git a/apps/backend/src/memberships/memberships.controller.ts b/apps/backend/src/memberships/memberships.controller.ts index 046e1be..5b99fdd 100644 --- a/apps/backend/src/memberships/memberships.controller.ts +++ b/apps/backend/src/memberships/memberships.controller.ts @@ -13,11 +13,15 @@ export class MembershipsController { constructor(private readonly membershipsService: MembershipsService) {} @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) async findAll() { return this.membershipsService.findAll(); } @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) async findOne(@Param('id', ParseIntPipe) id: number) { return this.membershipsService.findOne(id); } diff --git a/apps/backend/src/opened-weeks/dto/update-opened-week.dto.ts b/apps/backend/src/opened-weeks/dto/update-opened-week.dto.ts new file mode 100644 index 0000000..d0ec60a --- /dev/null +++ b/apps/backend/src/opened-weeks/dto/update-opened-week.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsDateString, IsNotEmpty } from 'class-validator'; + +export class UpdateOpenedWeekDto { + @IsNotEmpty() + @IsDateString() + monday: string; + + @IsNotEmpty() + @IsBoolean() + isOpen: boolean; +} diff --git a/apps/backend/src/opened-weeks/opened-weeks.controller.ts b/apps/backend/src/opened-weeks/opened-weeks.controller.ts new file mode 100644 index 0000000..9ae1f88 --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; + +import { UpdateOpenedWeekDto } from './dto/update-opened-week.dto'; +import { OpenedWeeksService } from './opened-weeks.service'; + +@Controller('opened-weeks') +export class OpenedWeeksController { + constructor(private readonly openedWeeksService: OpenedWeeksService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findAll() { + return this.openedWeeksService.findAll(); + } + + @Put() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + upsert(@Body() dto: UpdateOpenedWeekDto) { + return this.openedWeeksService.upsert(dto); + } +} diff --git a/apps/backend/src/opened-weeks/opened-weeks.module.ts b/apps/backend/src/opened-weeks/opened-weeks.module.ts new file mode 100644 index 0000000..a21a341 --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { OpenedWeeksController } from './opened-weeks.controller'; +import { OpenedWeeksService } from './opened-weeks.service'; + +@Module({ + providers: [OpenedWeeksService], + controllers: [OpenedWeeksController], +}) +export class OpenedWeeksModule {} diff --git a/apps/backend/src/opened-weeks/opened-weeks.service.ts b/apps/backend/src/opened-weeks/opened-weeks.service.ts new file mode 100644 index 0000000..f7e0f68 --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { UpdateOpenedWeekDto } from './dto/update-opened-week.dto'; + +@Injectable() +export class OpenedWeeksService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.openedWeek.findMany({ + orderBy: { monday: 'asc' }, + }); + } + + async upsert(dto: UpdateOpenedWeekDto) { + // Normalize to the canonical Monday-midnight of the week (server-local, i.e. club time), so the + // stored key always matches what the reservation validation computes when it looks the week up. + const monday = new Date(dto.monday); + const dayOfWeek = (monday.getDay() + 6) % 7; // Monday = 0 + monday.setDate(monday.getDate() - dayOfWeek); + monday.setHours(0, 0, 0, 0); + + return this.prisma.openedWeek.upsert({ + where: { monday }, + update: { isOpen: dto.isOpen }, + create: { monday, isOpen: dto.isOpen }, + }); + } +} diff --git a/apps/backend/src/periods/dto/create-period.dto.ts b/apps/backend/src/periods/dto/create-period.dto.ts new file mode 100644 index 0000000..7bb41b7 --- /dev/null +++ b/apps/backend/src/periods/dto/create-period.dto.ts @@ -0,0 +1,13 @@ +import { IsBoolean, IsDateString, IsOptional } from 'class-validator'; + +export class CreatePeriodDto { + @IsDateString() + startDate: string; + + @IsDateString() + endDate: string; + + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} diff --git a/apps/backend/src/periods/dto/update-period.dto.ts b/apps/backend/src/periods/dto/update-period.dto.ts new file mode 100644 index 0000000..a0eb726 --- /dev/null +++ b/apps/backend/src/periods/dto/update-period.dto.ts @@ -0,0 +1,15 @@ +import { IsBoolean, IsDateString, IsOptional } from 'class-validator'; + +export class UpdatePeriodDto { + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} diff --git a/apps/backend/src/periods/periods.controller.ts b/apps/backend/src/periods/periods.controller.ts new file mode 100644 index 0000000..66062de --- /dev/null +++ b/apps/backend/src/periods/periods.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; + +import { CreatePeriodDto } from './dto/create-period.dto'; +import { UpdatePeriodDto } from './dto/update-period.dto'; +import { PeriodsService } from './periods.service'; + +@Controller('periods') +export class PeriodsController { + constructor(private readonly periodsService: PeriodsService) {} + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + create(@Body() createPeriodDto: CreatePeriodDto) { + return this.periodsService.create(createPeriodDto); + } + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findAll() { + return this.periodsService.findAll(); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.periodsService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + update(@Param('id', ParseIntPipe) id: number, @Body() updatePeriodDto: UpdatePeriodDto) { + return this.periodsService.update(id, updatePeriodDto); + } + + @Delete(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + remove(@Param('id', ParseIntPipe) id: number) { + return this.periodsService.remove(id); + } +} diff --git a/apps/backend/src/periods/periods.module.ts b/apps/backend/src/periods/periods.module.ts new file mode 100644 index 0000000..3b4c3d4 --- /dev/null +++ b/apps/backend/src/periods/periods.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { PeriodsController } from './periods.controller'; +import { PeriodsService } from './periods.service'; + +@Module({ + controllers: [PeriodsController], + providers: [PeriodsService], + exports: [PeriodsService], +}) +export class PeriodsModule {} diff --git a/apps/backend/src/periods/periods.service.ts b/apps/backend/src/periods/periods.service.ts new file mode 100644 index 0000000..877fe55 --- /dev/null +++ b/apps/backend/src/periods/periods.service.ts @@ -0,0 +1,44 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { CreatePeriodDto } from './dto/create-period.dto'; +import { UpdatePeriodDto } from './dto/update-period.dto'; + +@Injectable() +export class PeriodsService { + constructor(private prisma: PrismaService) {} + + async create(createPeriodDto: CreatePeriodDto) { + if (new Date(createPeriodDto.startDate) >= new Date(createPeriodDto.endDate)) { + throw new BadRequestException('A kezdő dátumnak meg kell előznie a befejező dátumot.'); + } + return this.prisma.period.create({ + data: createPeriodDto, + }); + } + + async findAll() { + return this.prisma.period.findMany({ + orderBy: { startDate: 'asc' }, + }); + } + + async findOne(id: number) { + return this.prisma.period.findUnique({ + where: { id }, + }); + } + + async update(id: number, updatePeriodDto: UpdatePeriodDto) { + return this.prisma.period.update({ + where: { id }, + data: updatePeriodDto, + }); + } + + async remove(id: number) { + return this.prisma.period.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/posts/posts.controller.ts b/apps/backend/src/posts/posts.controller.ts index 84eeef1..ce8b4e3 100644 --- a/apps/backend/src/posts/posts.controller.ts +++ b/apps/backend/src/posts/posts.controller.ts @@ -1,4 +1,9 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; import { CreatePostDto } from './dto/create-post.dto'; import { UpdatePostDto } from './dto/update-post.dto'; @@ -9,6 +14,9 @@ export class PostsController { constructor(private readonly postsService: PostsService) {} @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) create(@Body() createPostDto: CreatePostDto) { return this.postsService.create(createPostDto); } @@ -24,16 +32,25 @@ export class PostsController { } @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) update(@Param('id', ParseIntPipe) id: number, @Body() updatePostDto: UpdatePostDto) { return this.postsService.update(id, updatePostDto); } @Patch(':id/pin') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) togglePin(@Param('id', ParseIntPipe) id: number) { return this.postsService.togglePin(id); } @Delete(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) remove(@Param('id', ParseIntPipe) id: number) { return this.postsService.remove(id); } diff --git a/apps/backend/src/reservations/dto/simple-reservation.dto.ts b/apps/backend/src/reservations/dto/simple-reservation.dto.ts index 7ddcf96..ffd79c7 100644 --- a/apps/backend/src/reservations/dto/simple-reservation.dto.ts +++ b/apps/backend/src/reservations/dto/simple-reservation.dto.ts @@ -2,4 +2,4 @@ import { OmitType } from '@nestjs/swagger'; import { Reservation } from '../entities/reservation.entity'; -export class SimpleReservationDto extends OmitType(Reservation, ['id', 'userId']) {} +export class SimpleReservationDto extends OmitType(Reservation, ['id']) {} diff --git a/apps/backend/src/reservations/dto/update-reservation.dto.ts b/apps/backend/src/reservations/dto/update-reservation.dto.ts index 9f3246b..646069c 100644 --- a/apps/backend/src/reservations/dto/update-reservation.dto.ts +++ b/apps/backend/src/reservations/dto/update-reservation.dto.ts @@ -1,10 +1,5 @@ import { PartialType } from '@nestjs/mapped-types'; -import { IsNumber, IsOptional } from 'class-validator'; import { SimpleReservationDto } from './simple-reservation.dto'; -export class UpdateReservationDto extends PartialType(SimpleReservationDto) { - @IsNumber() - @IsOptional() - gateKeeperId?: number; -} +export class UpdateReservationDto extends PartialType(SimpleReservationDto) {} diff --git a/apps/backend/src/reservations/entities/reservation.entity.ts b/apps/backend/src/reservations/entities/reservation.entity.ts index 6c29282..443c7ab 100644 --- a/apps/backend/src/reservations/entities/reservation.entity.ts +++ b/apps/backend/src/reservations/entities/reservation.entity.ts @@ -1,5 +1,5 @@ -import { ReservationStatus } from '@prisma/client'; -import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; +import { GateKeeperPriority, ReservationStatus } from '@prisma/client'; +import { IsBoolean, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; export class Reservation { @IsNotEmpty() @@ -7,8 +7,10 @@ export class Reservation { id: number; @IsNumber() + @IsOptional() userId: number; + @IsOptional() @IsNumber() bandId: number; @@ -24,7 +26,14 @@ export class Reservation { @IsOptional() gateKeeperId: number; - @IsNotEmpty() + @IsOptional() + @IsEnum(GateKeeperPriority) + gateKeeperPriority: GateKeeperPriority; + + @IsOptional() @IsEnum(ReservationStatus) status: ReservationStatus; + + @IsBoolean() + needToBeLetIn: boolean; } diff --git a/apps/backend/src/reservations/reservations.controller.ts b/apps/backend/src/reservations/reservations.controller.ts index 8eecf23..0f0aa14 100644 --- a/apps/backend/src/reservations/reservations.controller.ts +++ b/apps/backend/src/reservations/reservations.controller.ts @@ -1,6 +1,8 @@ +import { CurrentUser } from '@kir-dev/passport-authsch'; import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; +import { User } from '@prisma/client'; import { CreateReservationDto } from './dto/create-reservation.dto'; import { UpdateReservationDto } from './dto/update-reservation.dto'; @@ -13,15 +15,21 @@ export class ReservationsController { @Post() @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) - create(@Body() createReservationDto: CreateReservationDto) { - return this.reservationsService.create(createReservationDto); + create(@Body() createReservationDto: CreateReservationDto, @CurrentUser() user: User) { + return this.reservationsService.create(createReservationDto, user); } @Get() @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) - async findAll(@Query('page', ParseIntPipe) page: number, @Query('page_size', ParseIntPipe) pageSize: number) { - return this.reservationsService.findAll(page, pageSize); + async findAll( + @Query('page', ParseIntPipe) page: number, + @Query('page_size', ParseIntPipe) pageSize: number, + @Query('gateKeeperId') gateKeeperId?: string + ) { + const parsedGateKeeperId = + gateKeeperId && !Number.isNaN(Number(gateKeeperId)) ? parseInt(gateKeeperId, 10) : undefined; + return this.reservationsService.findAll(page, pageSize, parsedGateKeeperId); } @Get(':id') @@ -34,14 +42,18 @@ export class ReservationsController { @Patch(':id') @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) - update(@Param('id', ParseIntPipe) id: number, @Body() updateReservationDto: UpdateReservationDto) { - return this.reservationsService.update(id, updateReservationDto); + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateReservationDto: UpdateReservationDto, + @CurrentUser() user: User + ) { + return this.reservationsService.update(id, updateReservationDto, user); } @Delete(':id') @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) - remove(@Param('id', ParseIntPipe) id: number) { - return this.reservationsService.remove(id); + remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { + return this.reservationsService.remove(id, user); } } diff --git a/apps/backend/src/reservations/reservations.service.spec.ts b/apps/backend/src/reservations/reservations.service.spec.ts new file mode 100644 index 0000000..f015109 --- /dev/null +++ b/apps/backend/src/reservations/reservations.service.spec.ts @@ -0,0 +1,156 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GateKeeperPriority, ReservationStatus, Role } from '@prisma/client'; +import { PrismaService } from 'nestjs-prisma'; + +import { ReservationsService } from './reservations.service'; + +describe('ReservationsService', () => { + let service: ReservationsService; + + const mockPrismaService = { + reservation: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + findUniqueOrThrow: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + period: { + findFirst: jest.fn(), + }, + openedWeek: { + findUnique: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + settings: { + findFirst: jest.fn(), + }, + bandMembership: { + findFirst: jest.fn(), + }, + clubMembership: { + findUnique: jest.fn(), + }, + }; + + const settings = { + minReservationMinutes: 30, + maxReservationMinutes: 180, + maxHoursPerDay: 3, + maxHoursPerWeek: 6, + maxTotalHoursPerWeek: 12, + sanctionHourPenaltyPerPoint: 1, + sanctionTotalHourPenaltyPerPoint: 2, + banSanctionPointThreshold: 5, + }; + + const baseReservationDto = { + userId: 1, + startTime: new Date('2026-06-22T10:00:00.000Z'), + endTime: new Date('2026-06-22T11:00:00.000Z'), + status: ReservationStatus.NORMAL, + needToBeLetIn: false, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + mockPrismaService.settings.findFirst.mockResolvedValue(settings); + mockPrismaService.period.findFirst.mockResolvedValue({ id: 1 }); + mockPrismaService.openedWeek.findUnique.mockResolvedValue({ id: 1, isOpen: true }); + mockPrismaService.reservation.findFirst.mockResolvedValue(null); + mockPrismaService.reservation.findMany.mockResolvedValue([]); + mockPrismaService.reservation.create.mockImplementation(({ data }) => Promise.resolve(data)); + mockPrismaService.user.findUnique.mockResolvedValue({ id: 1, sanctionRecords: [] }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReservationsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(ReservationsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('rejects non-admin reservation creation for another user', async () => { + await expect( + service.create({ ...baseReservationDto, userId: 2 } as any, { id: 1, role: Role.USER } as any) + ).rejects.toThrow('Más felhasználó nevében csak admin foglalhat.'); + + expect(mockPrismaService.reservation.create).not.toHaveBeenCalled(); + }); + + it('rejects non-admin admin-made reservation creation', async () => { + await expect( + service.create( + { ...baseReservationDto, status: ReservationStatus.ADMINMADE } as any, + { id: 1, role: Role.USER } as any + ) + ).rejects.toThrow('Admin foglalást csak admin hozhat létre.'); + + expect(mockPrismaService.reservation.create).not.toHaveBeenCalled(); + }); + + it('recomputes non-admin reservation status instead of trusting the request body', async () => { + const created = await service.create( + { ...baseReservationDto, status: ReservationStatus.OVERTIME } as any, + { id: 1, role: Role.USER } as any + ); + + expect(created.status).toBe(ReservationStatus.NORMAL); + }); + + it('rejects non-admin band reservation creation without accepted membership', async () => { + mockPrismaService.bandMembership.findFirst.mockResolvedValue(null); + + await expect( + service.create({ ...baseReservationDto, userId: undefined, bandId: 1 } as any, { id: 1, role: Role.USER } as any) + ).rejects.toThrow('Csak olyan zenekar nevében foglalhatsz, amelynek elfogadott tagja vagy.'); + + expect(mockPrismaService.reservation.create).not.toHaveBeenCalled(); + }); + + it('rejects non-admin manual status changes', async () => { + mockPrismaService.reservation.findUniqueOrThrow.mockResolvedValue({ + id: 1, + userId: 1, + bandId: null, + gateKeeperId: null, + status: ReservationStatus.OVERTIME, + }); + + await expect( + service.update(1, { status: ReservationStatus.NORMAL } as any, { id: 2, role: Role.USER } as any) + ).rejects.toThrow('A foglalás státuszát csak admin vagy a szerver kvótaellenőrzése módosíthatja.'); + }); + + it('rejects gatekeeper assignment to an owned reservation', async () => { + mockPrismaService.reservation.findUniqueOrThrow.mockResolvedValue({ + id: 1, + userId: 1, + bandId: null, + gateKeeperId: null, + status: ReservationStatus.NORMAL, + }); + mockPrismaService.clubMembership.findUnique.mockResolvedValue({ id: 10, isGateKeeper: true }); + + await expect( + service.update( + 1, + { gateKeeperId: 10, gateKeeperPriority: GateKeeperPriority.PRIMARY } as any, + { id: 1, role: Role.USER } as any + ) + ).rejects.toThrow('Saját vagy saját zenekari foglaláshoz nem jelentkezhetsz beengedőnek.'); + }); +}); diff --git a/apps/backend/src/reservations/reservations.service.ts b/apps/backend/src/reservations/reservations.service.ts index 00f5f9b..810b01f 100644 --- a/apps/backend/src/reservations/reservations.service.ts +++ b/apps/backend/src/reservations/reservations.service.ts @@ -1,5 +1,13 @@ -import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +/* eslint-disable max-lines */ +/* eslint-disable no-console */ +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { BandMembershipStatus, GateKeeperPriority, Prisma, ReservationStatus, Role, User } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { PaginationDto } from '../dto/pagination.dto'; @@ -11,25 +19,403 @@ import { Reservation } from './entities/reservation.entity'; export class ReservationsService { constructor(private readonly prisma: PrismaService) {} - create(createReservationDto: CreateReservationDto) { + async create(createReservationDto: CreateReservationDto, currentUser: User) { + const dto = await this.authorizeCreate(createReservationDto, currentUser); + + // Validate the reservation + await this.validateReservation(dto); + + // Determine status + const status = + currentUser.role === Role.ADMIN && dto.status === ReservationStatus.ADMINMADE + ? ReservationStatus.ADMINMADE + : await this.determineReservationStatus(dto); + return this.prisma.reservation.create({ data: { - ...createReservationDto, + ...dto, + status, }, }); } - findAll(page?: number, pageSize?: number): Promise> { + private async authorizeCreate(dto: CreateReservationDto, currentUser: User): Promise { + if (currentUser.role === Role.ADMIN) { + return dto; + } + + const sanitized = { ...dto }; + if (sanitized.status === ReservationStatus.ADMINMADE) { + throw new ForbiddenException('Admin foglalást csak admin hozhat létre.'); + } + if (sanitized.gateKeeperId !== undefined && sanitized.gateKeeperId !== null) { + throw new ForbiddenException('Beengedőt csak meglévő foglaláshoz lehet hozzárendelni.'); + } + if (sanitized.gateKeeperPriority !== undefined && sanitized.gateKeeperPriority !== null) { + throw new ForbiddenException('Beengedői prioritást csak meglévő foglaláshoz lehet hozzárendelni.'); + } + + (sanitized as Partial).status = undefined; + + if (sanitized.userId !== undefined && sanitized.userId !== null && sanitized.userId !== currentUser.id) { + throw new ForbiddenException('Más felhasználó nevében csak admin foglalhat.'); + } + + if (sanitized.bandId !== undefined && sanitized.bandId !== null) { + const membership = await this.prisma.bandMembership.findFirst({ + where: { bandId: sanitized.bandId, userId: currentUser.id, status: BandMembershipStatus.ACCEPTED }, + }); + if (!membership) { + throw new ForbiddenException('Csak olyan zenekar nevében foglalhatsz, amelynek elfogadott tagja vagy.'); + } + } + + return sanitized; + } + + private async validateReservation(dto: CreateReservationDto | UpdateReservationDto, excludeId?: number) { + // Skip validation if times aren't provided (for partial updates) + if (!dto.startTime || !dto.endTime) { + return; + } + + const startTime = new Date(dto.startTime); + const endTime = new Date(dto.endTime); + + // 1. Validate 15-minute intervals + if (startTime.getMinutes() % 15 !== 0 || endTime.getMinutes() % 15 !== 0) { + throw new BadRequestException( + 'Kezdő és befejező időpont 15 perces intervallumokra kerekítve kell legyen (:00, :15, :30, :45)' + ); + } + + // 2. Validate duration against the configured min/max reservation length (falls back to + // 30 min / 3 hours if settings haven't been initialized yet). + const durationSettings = await this.prisma.settings.findFirst(); + const minMinutes = durationSettings?.minReservationMinutes ?? 30; + const maxMinutes = durationSettings?.maxReservationMinutes ?? 180; + const durationMs = endTime.getTime() - startTime.getTime(); + const minDuration = minMinutes * 60 * 1000; + const maxDuration = maxMinutes * 60 * 1000; + + if (durationMs < minDuration) { + throw new BadRequestException(`A foglalás túl rövid. Kérlek, adj meg legalább ${minMinutes} perces idősávot.`); + } + if (durationMs > maxDuration) { + throw new BadRequestException( + `A foglalás túl hosszú. A maximálisan foglalható időtartam ${maxMinutes / 60} óra.` + ); + } + + // 3. Validate exclusive user OR band (only check for CreateReservationDto) + const hasUserId = 'userId' in dto && dto.userId !== undefined && dto.userId !== null; + const hasBandId = 'bandId' in dto && dto.bandId !== undefined && dto.bandId !== null; + + if (!hasUserId && !hasBandId) { + throw new BadRequestException('Felhasználó vagy banda megadása kötelező'); + } + if (hasUserId && hasBandId) { + throw new BadRequestException('Csak felhasználó VAGY banda adható meg, nem mindkettő'); + } + + // 3b. Band eligibility: single-member bands and duplicate-member bands cannot book. + // Admins booking on someone's behalf (ADMINMADE) bypass these checks. + if (hasBandId && dto.bandId && dto.status !== ReservationStatus.ADMINMADE) { + await this.validateBandEligibility(dto.bandId); + } + + // 4. Check if Period is open + const openPeriod = await this.prisma.period.findFirst({ + where: { + isOpen: true, + startDate: { lte: startTime }, + endDate: { gte: endTime }, + }, + }); + + if (!openPeriod && dto.status !== ReservationStatus.ADMINMADE) { + throw new BadRequestException('A kiválasztott időpont nem esik egyetlen nyitott félévbe/időszakba sem.'); + } + + // 5. Check if Week is open + // Find the Monday of the week for startTime + const dayOfWeek = (startTime.getDay() + 6) % 7; // Monday = 0 + const weekStart = new Date(startTime); + weekStart.setDate(startTime.getDate() - dayOfWeek); + weekStart.setHours(0, 0, 0, 0); + + const openedWeek = await this.prisma.openedWeek.findUnique({ + where: { monday: weekStart }, + }); + + if ((!openedWeek || !openedWeek.isOpen) && dto.status !== ReservationStatus.ADMINMADE) { + throw new BadRequestException('Sajnáljuk, de erre a hétre még nem nyitottuk meg a foglalási lehetőséget.'); + } + + // 6. Prevent overlapping a protected reservation. NORMAL and ADMINMADE reservations may not + // be booked over; OVERTIME ones are freely overwritable, so overlaps with them are allowed. + // Admin bookings (ADMINMADE) bypass this — they may override anyone. + if (dto.status !== ReservationStatus.ADMINMADE) { + const conflict = await this.prisma.reservation.findFirst({ + where: { + id: excludeId ? { not: excludeId } : undefined, + status: { in: [ReservationStatus.NORMAL, ReservationStatus.ADMINMADE] }, + startTime: { lt: endTime }, + endTime: { gt: startTime }, + }, + }); + if (conflict) { + throw new BadRequestException('A terem már foglalt a kiválasztott idősávban.'); + } + } + } + + /** + * Enforces the spec's band booking rules: + * - a band with fewer than 2 accepted members cannot book, + * - if another band has the exact same set of accepted members, neither may book. + */ + private async validateBandEligibility(bandId: number) { + const acceptedMemberIds = async (id: number): Promise => { + const memberships = await this.prisma.bandMembership.findMany({ + where: { bandId: id, status: BandMembershipStatus.ACCEPTED }, + select: { userId: true }, + }); + return memberships + .map((m) => m.userId) + .filter((uid): uid is number => uid !== null) + .sort((a, b) => a - b); + }; + + const memberIds = await acceptedMemberIds(bandId); + + if (memberIds.length < 2) { + throw new BadRequestException('Egytagú zenekarok nem foglalhatnak időpontot.'); + } + + const fingerprint = memberIds.join(','); + const otherBands = await this.prisma.band.findMany({ + where: { id: { not: bandId } }, + select: { id: true }, + }); + + for (const other of otherBands) { + const otherIds = await acceptedMemberIds(other.id); + if (otherIds.length === memberIds.length && otherIds.join(',') === fingerprint) { + throw new BadRequestException( + 'Egy másik, pontosan ugyanezekből a tagokból álló zenekar is létezik, ezért ez a zenekar nem foglalhat.' + ); + } + } + } + + private async determineReservationStatus(dto: CreateReservationDto, excludeId?: number): Promise { + // Admin-made reservations keep their ADMINMADE status + if (dto.status === ReservationStatus.ADMINMADE) { + return ReservationStatus.ADMINMADE; + } + + // Band reservations have their own daily/weekly budget, separate from any member's + // personal budget. Over-budget band reservations become OVERTIME (freely overwritable). + if (!dto.userId) { + if (!dto.bandId) { + return ReservationStatus.NORMAL; + } + const settings = await this.prisma.settings.findFirst(); + if (!settings) { + return ReservationStatus.NORMAL; + } + return this.statusFromBlockLimits( + { bandId: dto.bandId }, + dto, + excludeId, + settings.maxHoursPerDay, + settings.maxHoursPerWeek + ); + } + + // Check user sanctions - now calculated from SanctionRecord table + const user = await this.prisma.user.findUnique({ + where: { id: dto.userId }, + include: { + sanctionRecords: true, + }, + }); + + if (!user) { + return ReservationStatus.NORMAL; + } + + // Calculate total sanction points from records + const sanctionPoints = user.sanctionRecords.reduce((sum, record) => sum + record.points, 0); + + // Check quota for overtime + const settings = await this.prisma.settings.findFirst(); + if (!settings) { + return ReservationStatus.NORMAL; + } + + if (sanctionPoints >= settings.banSanctionPointThreshold) { + throw new ForbiddenException( + `A foglalás megtagadva: Elérted a szankciós küszöböt. Jelenleg ${sanctionPoints} pontod van, a megengedett maximum ${settings.banSanctionPointThreshold}.` + ); + } + + // Get user's reservations this week + const now = new Date(dto.startTime); + const dayOfWeek = (now.getDay() + 6) % 7; // Monday = 0 + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - dayOfWeek); + weekStart.setHours(0, 0, 0, 0); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + + // Get day boundaries + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + + // Calculate total weekly hours (both NORMAL and OVERTIME) + const allWeeklyReservations = await this.prisma.reservation.findMany({ + where: { + id: excludeId ? { not: excludeId } : undefined, + userId: dto.userId, + startTime: { gte: weekStart, lt: weekEnd }, + }, + }); + + const totalWeeklyHours = allWeeklyReservations.reduce((total, r) => { + const duration = new Date(r.endTime).getTime() - new Date(r.startTime).getTime(); + return total + duration / (1000 * 60 * 60); + }, 0); + + const newDuration = (new Date(dto.endTime).getTime() - new Date(dto.startTime).getTime()) / (1000 * 60 * 60); + const adjustedTotalWeeklyLimit = Math.max( + 0, + settings.maxTotalHoursPerWeek - sanctionPoints * settings.sanctionTotalHourPenaltyPerPoint + ); + + if (totalWeeklyHours + newDuration > adjustedTotalWeeklyLimit) { + throw new ForbiddenException( + `A foglalás nem hozható létre: Túllépted a heti időkeretet. (Elérhető: ${adjustedTotalWeeklyLimit} óra, Szankciós pontjaid: ${sanctionPoints})` + ); + } + + const weeklyReservations = allWeeklyReservations.filter((r) => r.status !== ReservationStatus.OVERTIME); + + const dailyReservations = await this.prisma.reservation.findMany({ + where: { + id: excludeId ? { not: excludeId } : undefined, + userId: dto.userId, + startTime: { gte: dayStart, lt: dayEnd }, + status: { not: ReservationStatus.OVERTIME }, + }, + }); + + const weeklyHours = weeklyReservations.reduce((total, r) => { + const duration = new Date(r.endTime).getTime() - new Date(r.startTime).getTime(); + return total + duration / (1000 * 60 * 60); + }, 0); + + const dailyHours = dailyReservations.reduce((total, r) => { + const duration = new Date(r.endTime).getTime() - new Date(r.startTime).getTime(); + return total + duration / (1000 * 60 * 60); + }, 0); + + const adjustedWeeklyLimit = Math.max( + 0, + settings.maxHoursPerWeek - sanctionPoints * settings.sanctionHourPenaltyPerPoint + ); + const adjustedDailyLimit = Math.max( + 0, + settings.maxHoursPerDay - (sanctionPoints * settings.sanctionHourPenaltyPerPoint) / 2 + ); + + // Check both weekly AND daily limits for OVERTIME + if (weeklyHours + newDuration > adjustedWeeklyLimit || dailyHours + newDuration > adjustedDailyLimit) { + return ReservationStatus.OVERTIME; + } + + return ReservationStatus.NORMAL; + } + + /** + * Determines NORMAL vs OVERTIME for a reservation owner (a user or a band) based purely on + * NORMAL-block daily/weekly hour limits. Existing OVERTIME reservations don't count toward + * the limits, since they're already freely overwritable. + */ + private async statusFromBlockLimits( + ownerWhere: Prisma.ReservationWhereInput, + dto: CreateReservationDto, + excludeId: number | undefined, + dailyLimit: number, + weeklyLimit: number + ): Promise { + const start = new Date(dto.startTime); + + const dayOfWeek = (start.getDay() + 6) % 7; // Monday = 0 + const weekStart = new Date(start); + weekStart.setDate(start.getDate() - dayOfWeek); + weekStart.setHours(0, 0, 0, 0); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + + const dayStart = new Date(start); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + + const sumHours = (rs: { startTime: Date; endTime: Date }[]) => + rs.reduce((total, r) => total + (new Date(r.endTime).getTime() - new Date(r.startTime).getTime()) / 3_600_000, 0); + + const weeklyReservations = await this.prisma.reservation.findMany({ + where: { + ...ownerWhere, + id: excludeId ? { not: excludeId } : undefined, + status: { not: ReservationStatus.OVERTIME }, + startTime: { gte: weekStart, lt: weekEnd }, + }, + }); + + const dailyReservations = await this.prisma.reservation.findMany({ + where: { + ...ownerWhere, + id: excludeId ? { not: excludeId } : undefined, + status: { not: ReservationStatus.OVERTIME }, + startTime: { gte: dayStart, lt: dayEnd }, + }, + }); + + const newDuration = (new Date(dto.endTime).getTime() - start.getTime()) / 3_600_000; + + if ( + sumHours(weeklyReservations) + newDuration > weeklyLimit || + sumHours(dailyReservations) + newDuration > dailyLimit + ) { + return ReservationStatus.OVERTIME; + } + return ReservationStatus.NORMAL; + } + + findAll(page?: number, pageSize?: number, gateKeeperId?: number): Promise> { const hasPagination = page !== -1 && pageSize !== -1; + const where = gateKeeperId ? { gateKeeperId } : undefined; const reservations = this.prisma.reservation.findMany({ + where, skip: hasPagination ? (page - 1) * pageSize : undefined, take: hasPagination ? pageSize : undefined, + include: { + user: true, + band: true, + gateKeeper: { include: { user: true } }, + }, orderBy: { startTime: 'asc', }, }); - const count = this.prisma.reservation.count(); + const count = this.prisma.reservation.count({ where }); return Promise.all([reservations, count]) .then(([reservations, count]) => { @@ -52,6 +438,10 @@ export class ReservationsService { where: { id, }, + include: { + user: true, + band: true, + }, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { @@ -63,7 +453,66 @@ export class ReservationsService { } } - async update(id: number, updateReservationDto: UpdateReservationDto) { + async update(id: number, updateReservationDto: UpdateReservationDto, currentUser: User) { + const existing = await this.prisma.reservation.findUniqueOrThrow({ + where: { id }, + include: { gateKeeper: { include: { user: true } } }, + }); + + await this.assertCanUpdate(existing, updateReservationDto, currentUser); + + if ( + currentUser.role !== Role.ADMIN && + updateReservationDto.status !== undefined && + updateReservationDto.status !== existing.status + ) { + throw new ForbiddenException('A foglalás státuszát csak admin vagy a szerver kvótaellenőrzése módosíthatja.'); + } + + // Handle gatekeeper priority and override logic + if (updateReservationDto.gateKeeperId !== undefined) { + // If clearing gatekeeper, also clear priority + if (updateReservationDto.gateKeeperId === null) { + updateReservationDto.gateKeeperPriority = null; + } else { + // If assigning a new gatekeeper + if (!updateReservationDto.gateKeeperPriority) { + throw new BadRequestException('Beengedő mellé prioritás megadása kötelező'); + } + + if (existing.gateKeeperId && existing.gateKeeperId !== updateReservationDto.gateKeeperId) { + if (existing.gateKeeperPriority === GateKeeperPriority.PRIMARY) { + throw new ForbiddenException('Ezt a foglalást már egy elsődleges beengedő elvállalta'); + } + + if (updateReservationDto.gateKeeperPriority !== GateKeeperPriority.PRIMARY) { + throw new BadRequestException('Csak elsődleges prioritással lehet felülbírálni egy meglévő beengedőt'); + } + + // Override logic: PRIMARY overrides SECONDARY. + // The overridden gatekeeper is notified by email from the client (see onSetGK in + // useReservationDetails), which owns the mail integration (/api/kir-mail). + } + } + } + + // Validate if time fields are being updated + if (updateReservationDto.startTime || updateReservationDto.endTime) { + const dto = { + userId: existing.userId, + bandId: existing.bandId, + startTime: existing.startTime, + endTime: existing.endTime, + status: existing.status, + ...updateReservationDto, + }; + await this.validateReservation(dto as any, id); + + // Recalculate status if time or user/band changes + const newStatus = await this.determineReservationStatus(dto as any, id); + updateReservationDto.status = newStatus; + } + try { return await this.prisma.reservation.update({ where: { @@ -83,9 +532,25 @@ export class ReservationsService { } } - remove(id: number) { + async remove(id: number, currentUser: User) { + const existing = await this.prisma.reservation.findUniqueOrThrow({ where: { id } }); + + // Admins may delete anything. Otherwise: ADMINMADE is admin-only, OVERTIME is freely + // overwritable by anyone, and NORMAL reservations may only be deleted by their owner. + if (currentUser.role !== Role.ADMIN) { + if (existing.status === ReservationStatus.ADMINMADE) { + throw new ForbiddenException('Admin által létrehozott foglalást csak admin törölhet.'); + } + if ( + existing.status !== ReservationStatus.OVERTIME && + !(await this.userOwnsReservation(existing, currentUser.id)) + ) { + throw new ForbiddenException('Ezt a foglalást csak a létrehozója vagy admin törölheti.'); + } + } + try { - return this.prisma.reservation.delete({ + return await this.prisma.reservation.delete({ where: { id, }, @@ -99,4 +564,88 @@ export class ReservationsService { } } } + + /** Whether the user is the reservation's owner (personal booking) or an accepted band member. */ + private async userOwnsReservation( + reservation: { userId: number | null; bandId: number | null }, + userId: number + ): Promise { + if (reservation.userId && reservation.userId === userId) { + return true; + } + if (reservation.bandId) { + const membership = await this.prisma.bandMembership.findFirst({ + where: { bandId: reservation.bandId, userId, status: BandMembershipStatus.ACCEPTED }, + }); + return membership !== null; + } + return false; + } + + /** Whether the user is an active gatekeeper (has a club membership flagged as gatekeeper). */ + private async getGateKeeperMembership(userId: number): Promise<{ id: number; isGateKeeper: boolean } | null> { + return this.prisma.clubMembership.findUnique({ + where: { userId }, + select: { id: true, isGateKeeper: true }, + }); + } + + /** + * Authorizes a reservation update. Gatekeeper-field changes (admission) require gatekeeper + * rights; content changes require ownership/admin, with OVERTIME freely overwritable and + * ADMINMADE admin-only. + */ + private async assertCanUpdate( + existing: { userId: number | null; bandId: number | null; gateKeeperId?: number | null; status: ReservationStatus }, + dto: UpdateReservationDto, + currentUser: User + ): Promise { + if (currentUser.role === Role.ADMIN) { + return; + } + + const touchesGateKeeper = 'gateKeeperId' in dto || 'gateKeeperPriority' in dto; + const touchesContent = + dto.startTime !== undefined || + dto.endTime !== undefined || + dto.userId !== undefined || + dto.bandId !== undefined || + dto.status !== undefined || + dto.needToBeLetIn !== undefined; + + if (touchesGateKeeper) { + const membership = await this.getGateKeeperMembership(currentUser.id); + if (!membership?.isGateKeeper) { + throw new ForbiddenException('Beengedőt csak beengedő jogosultsággal rendelkező felhasználó állíthat be.'); + } + if (await this.userOwnsReservation(existing, currentUser.id)) { + throw new ForbiddenException('Saját vagy saját zenekari foglaláshoz nem jelentkezhetsz beengedőnek.'); + } + if (dto.gateKeeperId !== undefined && dto.gateKeeperId !== null && dto.gateKeeperId !== membership.id) { + throw new ForbiddenException('Beengedőként csak saját magadat rendelheted hozzá a foglaláshoz.'); + } + if (dto.gateKeeperId === null && existing.gateKeeperId && existing.gateKeeperId !== membership.id) { + throw new ForbiddenException('Más beengedési szándékát csak admin törölheti.'); + } + if ( + dto.gateKeeperPriority !== undefined && + dto.gateKeeperId === undefined && + existing.gateKeeperId !== membership.id + ) { + throw new ForbiddenException('Más beengedési prioritását csak admin módosíthatja.'); + } + } + + if (touchesContent) { + if (existing.status === ReservationStatus.ADMINMADE) { + throw new ForbiddenException('Admin által létrehozott foglalást csak admin módosíthat.'); + } + if ( + existing.status !== ReservationStatus.OVERTIME && + !(await this.userOwnsReservation(existing, currentUser.id)) + ) { + throw new ForbiddenException('Ezt a foglalást csak a létrehozója vagy admin módosíthatja.'); + } + } + } } diff --git a/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts b/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts new file mode 100644 index 0000000..7a6c506 --- /dev/null +++ b/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts @@ -0,0 +1,30 @@ +import { IsInt, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class CreateSanctionRecordDto { + @IsInt() + @IsPositive() + @IsOptional() + userId?: number; + + @IsInt() + @IsPositive() + @IsOptional() + bandId?: number; + + @IsInt() + @IsPositive() + @IsOptional() + reservationId?: number; + + @IsNumber() + @IsPositive() + points: number; + + @IsString() + @IsNotEmpty() + reason: string; + + @IsNumber() + @IsPositive() + awardedBy: number; +} diff --git a/apps/backend/src/sanction-records/dto/update-sanction-record.dto.ts b/apps/backend/src/sanction-records/dto/update-sanction-record.dto.ts new file mode 100644 index 0000000..70b0e87 --- /dev/null +++ b/apps/backend/src/sanction-records/dto/update-sanction-record.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class UpdateSanctionRecordDto { + @IsNumber() + @IsOptional() + @IsPositive() + points?: number; + + @IsString() + @IsNotEmpty() + @IsOptional() + reason?: string; +} diff --git a/apps/backend/src/sanction-records/entities/sanction-record.entity.ts b/apps/backend/src/sanction-records/entities/sanction-record.entity.ts new file mode 100644 index 0000000..a609a0d --- /dev/null +++ b/apps/backend/src/sanction-records/entities/sanction-record.entity.ts @@ -0,0 +1,33 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class SanctionRecord { + @IsNumber() + @IsPositive() + id: number; + + @IsNumber() + @IsOptional() + userId?: number; + + @IsNumber() + @IsOptional() + bandId?: number; + + @IsNumber() + @IsOptional() + reservationId?: number; + + @IsNumber() + @IsPositive() + points: number; + + @IsString() + @IsNotEmpty() + reason: string; + + @IsNumber() + @IsPositive() + awardedBy: number; + + awardedAt: Date; +} diff --git a/apps/backend/src/sanction-records/sanction-records.controller.ts b/apps/backend/src/sanction-records/sanction-records.controller.ts new file mode 100644 index 0000000..f3d7ae8 --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.controller.ts @@ -0,0 +1,68 @@ +import { CurrentUser } from '@kir-dev/passport-authsch'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role, User } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; + +import { CreateSanctionRecordDto } from './dto/create-sanction-record.dto'; +import { UpdateSanctionRecordDto } from './dto/update-sanction-record.dto'; +import { SanctionRecordsService } from './sanction-records.service'; + +@Controller('sanction-records') +export class SanctionRecordsController { + constructor(private readonly sanctionRecordsService: SanctionRecordsService) {} + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async create(@Body() dto: CreateSanctionRecordDto, @CurrentUser() user: User) { + return this.sanctionRecordsService.create(dto, user); + } + + @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSanctionRecordDto, @CurrentUser() user: User) { + return this.sanctionRecordsService.update(id, dto, user.id); + } + + @Get('me') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async findForCurrentUser(@CurrentUser() user: User) { + return this.sanctionRecordsService.findForCurrentUser(user.authSchId); + } + + @Get('user/:userId') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + async findByUserId(@Param('userId', ParseIntPipe) userId: number) { + return this.sanctionRecordsService.findByUserId(userId); + } + + @Get('band/:bandId') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + async findByBandId(@Param('bandId', ParseIntPipe) bandId: number) { + return this.sanctionRecordsService.findByBandId(bandId); + } + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async findAll() { + return this.sanctionRecordsService.findAll(); + } + + @Delete(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + async delete(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { + return this.sanctionRecordsService.delete(id, user); + } +} diff --git a/apps/backend/src/sanction-records/sanction-records.module.ts b/apps/backend/src/sanction-records/sanction-records.module.ts new file mode 100644 index 0000000..59c8fd2 --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { SanctionRecordsController } from './sanction-records.controller'; +import { SanctionRecordsService } from './sanction-records.service'; + +@Module({ + controllers: [SanctionRecordsController], + providers: [SanctionRecordsService], + exports: [SanctionRecordsService], +}) +export class SanctionRecordsModule {} diff --git a/apps/backend/src/sanction-records/sanction-records.service.ts b/apps/backend/src/sanction-records/sanction-records.service.ts new file mode 100644 index 0000000..28b41f6 --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.service.ts @@ -0,0 +1,204 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Role, User } from '@prisma/client'; +import { PrismaService } from 'nestjs-prisma'; + +import { CreateSanctionRecordDto } from './dto/create-sanction-record.dto'; +import { UpdateSanctionRecordDto } from './dto/update-sanction-record.dto'; + +@Injectable() +export class SanctionRecordsService { + constructor(private readonly prisma: PrismaService) {} + + async create(dto: CreateSanctionRecordDto, user: User) { + // Validate that either userId or bandId is provided, but not both + if (!dto.userId && !dto.bandId) { + throw new BadRequestException('Either userId or bandId must be provided'); + } + if (dto.userId && dto.bandId) { + throw new BadRequestException('Cannot sanction both user and band at the same time'); + } + + // Verify the gatekeeper exists and is owned by the user or user is ADMIN + const gateKeeper = await this.prisma.clubMembership.findUnique({ + where: { id: dto.awardedBy }, + }); + if (!gateKeeper) { + throw new NotFoundException('GateKeeper not found'); + } + + if (gateKeeper.userId !== user.id && user.role !== Role.ADMIN) { + throw new ForbiddenException('Only the gatekeeper who created this record can create it'); + } + + // Create the sanction record + return this.prisma.sanctionRecord.create({ + data: { + userId: dto.userId, + bandId: dto.bandId, + reservationId: dto.reservationId, + points: dto.points, + reason: dto.reason, + awardedBy: dto.awardedBy, + }, + include: { + user: true, + band: true, + gateKeeper: { + include: { + user: true, + }, + }, + }, + }); + } + + async findByUserId(userId: number) { + return this.prisma.sanctionRecord.findMany({ + where: { userId }, + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }); + } + + async findByBandId(bandId: number) { + return this.prisma.sanctionRecord.findMany({ + where: { bandId }, + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }); + } + + async findAll() { + return this.prisma.sanctionRecord.findMany({ + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + }); + } + + async findForCurrentUser(authSchId: string) { + const user = await this.prisma.user.findUnique({ + where: { authSchId }, + include: { + sanctionRecords: { + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }, + bandMemberships: { + where: { status: 'ACCEPTED' }, + include: { + band: { + include: { + sanctionRecords: { + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Combine user sanctions with band sanctions + const userSanctions = user.sanctionRecords.map((s) => ({ + ...s, + type: 'user' as const, + })); + + const bandSanctions = user.bandMemberships.flatMap( + (bm) => + bm.band?.sanctionRecords.map((s) => ({ + ...s, + type: 'band' as const, + bandName: bm.band?.name, + })) || [] + ); + + // Combine and sort by date + const allSanctions = [...userSanctions, ...bandSanctions].sort( + (a, b) => new Date(b.awardedAt).getTime() - new Date(a.awardedAt).getTime() + ); + + return { + userSanctions, + bandSanctions, + allSanctions, + totalUserPoints: userSanctions.reduce((sum, s) => sum + s.points, 0), + totalBandPoints: bandSanctions.reduce((sum, s) => sum + s.points, 0), + }; + } + + async update(id: number, dto: UpdateSanctionRecordDto, userId: number) { + const record = await this.prisma.sanctionRecord.findUnique({ + where: { id }, + include: { gateKeeper: true }, + }); + if (!record) { + throw new NotFoundException(`Sanction record with id ${id} not found`); + } + + if (record.gateKeeper.userId !== userId) { + throw new ForbiddenException('Only the gatekeeper who created this record can update it'); + } + + return this.prisma.sanctionRecord.update({ + where: { id }, + data: { + points: dto.points, + reason: dto.reason, + }, + }); + } + + async delete(id: number, user: User) { + const record = await this.prisma.sanctionRecord.findUnique({ + where: { id }, + include: { gateKeeper: true }, + }); + + if (!record) { + throw new NotFoundException(`Sanction record with id ${id} not found`); + } + + if (record.gateKeeper.userId !== user.id && user.role !== Role.ADMIN) { + throw new ForbiddenException('Only the gatekeeper who created this record or an ADMIN can delete it'); + } + + return this.prisma.sanctionRecord.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/settings/dto/update-settings.dto.ts b/apps/backend/src/settings/dto/update-settings.dto.ts new file mode 100644 index 0000000..e1c41a2 --- /dev/null +++ b/apps/backend/src/settings/dto/update-settings.dto.ts @@ -0,0 +1,43 @@ +import { IsNumber, IsOptional, Min } from 'class-validator'; + +export class UpdateSettingsDto { + @IsOptional() + @IsNumber() + @Min(0) + maxHoursPerWeek?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxHoursPerDay?: number; + + @IsOptional() + @IsNumber() + @Min(1) + minReservationMinutes?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxReservationMinutes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + sanctionHourPenaltyPerPoint?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxTotalHoursPerWeek?: number; + + @IsOptional() + @IsNumber() + @Min(0) + sanctionTotalHourPenaltyPerPoint?: number; + + @IsOptional() + @IsNumber() + @Min(0) + banSanctionPointThreshold?: number; +} diff --git a/apps/backend/src/settings/settings.controller.ts b/apps/backend/src/settings/settings.controller.ts new file mode 100644 index 0000000..b7fdad2 --- /dev/null +++ b/apps/backend/src/settings/settings.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; + +import { UpdateSettingsDto } from './dto/update-settings.dto'; +import { SettingsService } from './settings.service'; + +@Controller('settings') +export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findFirst() { + return this.settingsService.findFirst(); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.settingsService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + update(@Param('id', ParseIntPipe) id: number, @Body() updateSettingsDto: UpdateSettingsDto) { + return this.settingsService.update(id, updateSettingsDto); + } +} diff --git a/apps/backend/src/settings/settings.module.ts b/apps/backend/src/settings/settings.module.ts new file mode 100644 index 0000000..cb39426 --- /dev/null +++ b/apps/backend/src/settings/settings.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { SettingsController } from './settings.controller'; +import { SettingsService } from './settings.service'; + +@Module({ + controllers: [SettingsController], + providers: [SettingsService], + exports: [SettingsService], +}) +export class SettingsModule {} diff --git a/apps/backend/src/settings/settings.service.ts b/apps/backend/src/settings/settings.service.ts new file mode 100644 index 0000000..d3fe8ff --- /dev/null +++ b/apps/backend/src/settings/settings.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { UpdateSettingsDto } from './dto/update-settings.dto'; + +@Injectable() +export class SettingsService { + constructor(private prisma: PrismaService) {} + + async findOne(id: number) { + return this.prisma.settings.findUnique({ + where: { id }, + }); + } + + async findFirst() { + // Enforce singleton: Get the first (and should be only) settings record + let settings = await this.prisma.settings.findFirst(); + + // If no settings exist, create default ones (singleton initialization) + if (!settings) { + settings = await this.prisma.settings.create({ + data: { + maxHoursPerWeek: 6.0, + maxHoursPerDay: 3.0, + minReservationMinutes: 30, + maxReservationMinutes: 180, + sanctionHourPenaltyPerPoint: 1.0, + maxTotalHoursPerWeek: 12.0, + sanctionTotalHourPenaltyPerPoint: 2.0, + banSanctionPointThreshold: 5, + }, + }); + } + + return settings; + } + + async update(id: number, updateSettingsDto: UpdateSettingsDto) { + // Only allow updating the first record to maintain singleton + const existing = await this.findFirst(); + return this.prisma.settings.update({ + where: { id: existing.id }, // Always update the singleton record + data: updateSettingsDto, + }); + } +} diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index cfcce46..59f9614 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -1,8 +1,8 @@ import { CurrentUser } from '@kir-dev/passport-authsch'; -import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; -import { User } from '@prisma/client'; +import { Role, User } from '@prisma/client'; import { UpdateUserDto } from './dto/update-user.dto'; import { UsersService } from './users.service'; @@ -12,8 +12,10 @@ export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() - async findAll() { - return this.usersService.findAll(); + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async findAll(@CurrentUser() user: User) { + return this.usersService.findAll(user); } @Get('me') @@ -24,12 +26,20 @@ export class UsersController { } @Get(':id') - async findOne(@Param('id', ParseIntPipe) id: number) { - return this.usersService.findOne(id); + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { + return this.usersService.findOne(id, user); } @Patch(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto) { + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto, @CurrentUser() user: User) { + // Users may only edit their own profile; admins may edit anyone. + if (user.role !== Role.ADMIN && user.id !== id) { + throw new ForbiddenException('Csak a saját profilodat módosíthatod.'); + } return this.usersService.update(id, updateUserDto); } } diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 03789fc..5320ed2 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,42 +1,48 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma, User } from '@prisma/client'; +import { DormResidency, Prisma, Role, User } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { UpdateUserDto } from './dto/update-user.dto'; +type UserWithDorm = User & { DormResidency: DormResidency | null }; + @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} - async findAll(): Promise<{ users: User[] }> { - const users = await this.prisma.user.findMany(); - return { users }; + async findAll(currentUser?: User): Promise<{ users: User[] }> { + const privileged = await this.isPrivileged(currentUser); + const users = await this.prisma.user.findMany({ include: { DormResidency: true } }); + return { users: users.map((u) => this.serialize(u, privileged)) }; } - async findOne(id: number): Promise { + async findOne(id: number, currentUser?: User): Promise { const user = await this.prisma.user.findUnique({ - where: { - id, - }, + where: { id }, + include: { DormResidency: true }, }); if (!user) { throw new NotFoundException(`User with id ${id} not found`); } - return user; + // The user always sees their own dorm/room data; otherwise only gatekeepers/admins do. + const privileged = currentUser?.id === id || (await this.isPrivileged(currentUser)); + return this.serialize(user, privileged); } async findMe(id: string): Promise { - const user = this.prisma.user.findUnique({ + const user = await this.prisma.user.findUnique({ where: { authSchId: id }, + include: { DormResidency: true, clubMembership: true }, }); if (user === null) { throw new NotFoundException(`User with id ${id} not found`); } - return user; + // Users always see their own dorm/room data. + return this.serialize(user, true); } async update(id: number, updateUserDto: UpdateUserDto) { @@ -56,4 +62,32 @@ export class UsersService { throw e; } } + + /** Whether the requester may see privileged fields (dorm status + room number): admins and gatekeepers. */ + private async isPrivileged(currentUser?: User): Promise { + if (!currentUser) { + return false; + } + if (currentUser.role === Role.ADMIN) { + return true; + } + const membership = await this.prisma.clubMembership.findUnique({ where: { userId: currentUser.id } }); + return membership?.isGateKeeper === true; + } + + /** + * Strips the DormResidency relation from the response. Dorm status (söci) and room number are + * only exposed to privileged viewers (gatekeepers/admins) or the user themselves, per the spec. + */ + private serialize(user: UserWithDorm, includeDormInfo: boolean): User { + const { DormResidency, ...rest } = user; + if (!includeDormInfo) { + return rest; + } + return { + ...rest, + isDormResident: DormResidency !== null, + roomNumber: DormResidency?.roomNumber, + } as User & { isDormResident: boolean; roomNumber?: number }; + } } diff --git a/apps/frontend/declarations.d.ts b/apps/frontend/declarations.d.ts new file mode 100644 index 0000000..f787c0f --- /dev/null +++ b/apps/frontend/declarations.d.ts @@ -0,0 +1,3 @@ +// NOTE: The declaration below was injected by `"framer"` +// see https://www.framer.com/docs/guides/handshake for more information. +declare module 'https://framer.com/m/*'; diff --git a/apps/frontend/docs/PAGE_PROTECTION.md b/apps/frontend/docs/PAGE_PROTECTION.md new file mode 100644 index 0000000..1588db2 --- /dev/null +++ b/apps/frontend/docs/PAGE_PROTECTION.md @@ -0,0 +1,179 @@ +# Page Protection System + +Comprehensive role-based authentication and authorization system for protecting pages and routes. + +## Features + +- ✅ Server-side route protection via Next.js Middleware +- ✅ Client-side role-based access control via HOC +- ✅ Loading states during authentication check +- ✅ Automatic redirects for unauthorized access +- ✅ Type-safe role definitions + +## Components + +### 1. Middleware (`src/middleware.ts`) + +Server-side protection that runs before pages load. Checks for JWT token presence and redirects to login if missing. + +**Protected by default:** + +- `/admin/*` - Admin pages +- `/profile` - User profile +- `/settings` - User settings + +**To add more protected routes:** + +```typescript +const protectedPaths = [ + '/admin', + '/profile', + '/settings', + '/dashboard', // Add your route here +]; +``` + +### 2. `withAuth` HOC (`src/utils/withAuth.tsx`) + +Client-side HOC for fine-grained role-based access control. + +## Usage Examples + +### Admin-Only Page + +```tsx +import { withAdminAuth } from '@/utils/withAuth'; + +function AdminPage() { + return
Admin Content
; +} + +export default withAdminAuth(AdminPage); +``` + +### Any Authenticated User + +```tsx +import { withUserAuth } from '@/utils/withAuth'; + +function ProfilePage() { + return
User Profile
; +} + +export default withUserAuth(ProfilePage); +``` + +### Custom Role Configuration + +```tsx +import { withAuth } from '@/utils/withAuth'; + +function SpecialPage() { + return
Special Content
; +} + +export default withAuth(SpecialPage, { + allowedRoles: ['ADMIN'], // Only admins + redirectTo: '/login', // Where to send if not logged in + redirectUnauthorizedTo: '/403', // Where to send if logged in but wrong role +}); +``` + +### Multiple Roles + +```tsx +// If you add more roles in the future +export default withAuth(Page, { + allowedRoles: ['ADMIN', 'MODERATOR'], +}); +``` + +## How It Works + +### 1. Middleware (Server-Side) + +- Runs on every request before page loads +- Checks for JWT token in cookies +- Redirects to `/login` if token is missing +- Preserves intended destination in URL params + +### 2. HOC (Client-Side) + +- Runs after page loads +- Fetches current user via `useUser` hook +- Checks user role against allowed roles +- Shows loading spinner while checking +- Redirects if unauthorized +- Renders page if authorized + +## Security Notes + +1. **Double Protection:** Middleware + HOC provides both server and client-side security +2. **Backend Validation:** Always validate permissions on backend API endpoints too +3. **Token Expiry:** JWT expiration is handled by the backend +4. **Redirect Loop Prevention:** Login page is excluded from middleware matcher + +## Customization + +### Change Loading UI + +Edit the loading component in `withAuth.tsx`: + +```tsx +if (loading) { + return ; +} +``` + +### Add More Roles + +1. Update the `UserRole` type in `withAuth.tsx`: + +```typescript +type UserRole = 'ADMIN' | 'USER' | 'MODERATOR' | 'GUEST'; +``` + +2. Use in your pages: + +```tsx +export default withAuth(Page, { allowedRoles: ['MODERATOR'] }); +``` + +## File Structure + +```text +src/ +├── middleware.ts # Server-side route protection +├── utils/ +│ └── withAuth.tsx # Client-side HOC for role-based access +├── app/ +│ ├── admin/ +│ │ └── page.tsx # Example admin page +│ └── login/ +│ └── route.ts # Login route handler (public) +└── hooks/ + └── useUser.tsx # Hook for current user data +``` + +## Testing + +1. **As Guest:** Try accessing `/admin` → Should redirect to `/login` +2. **As USER:** Log in, try `/admin` → Should redirect to `/` (unauthorized) +3. **As ADMIN:** Log in, access `/admin` → Should show admin content + +## Troubleshooting + +**Redirect loop to login:** + +- Check that `/login` is excluded in middleware matcher +- Verify `useUser` hook returns `loading: false` eventually + +**Page flashes before redirect:** + +- Normal behavior - middleware prevents server-side access, HOC prevents client-side +- Consider adding SSR if you need server-side role checks + +**Role check not working:** + +- Verify user role is correctly set in JWT payload +- Check that role matches exactly (`'ADMIN'` not `'admin'`) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4c23af5..a7c5dbc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -38,7 +38,7 @@ "react-dom": "^18.2.0", "react-icons": "^5.5.0", "shadcn": "^2.1.7", - "sonner": "^1.7.4", + "sonner": "^2.0.7", "switch": "^0.0.0", "swr": "^2.3.4", "tailwind-merge": "^2.5.5", diff --git a/apps/frontend/src/app/admin/page.tsx b/apps/frontend/src/app/admin/page.tsx index db487c4..edfabac 100644 --- a/apps/frontend/src/app/admin/page.tsx +++ b/apps/frontend/src/app/admin/page.tsx @@ -3,7 +3,9 @@ import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; -import { ReservationLimitsForm } from '@/components/admin/reservation-limits-form'; +import OpenedWeeksPanel from '@/components/admin/OpenedWeeksPanel'; +import PeriodsPanel from '@/components/admin/PeriodsPanel'; +import SettingsPanel from '@/components/admin/SettingsPanel'; import { UserRoleTable } from '@/components/admin/user-role-table'; import { useProfile } from '@/hooks/useProfile'; import { Role } from '@/types/user'; @@ -37,12 +39,21 @@ export default function AdminPage() {
-

Foglalási korlátok

-

- Felhasználók és zenekarok maximálisan foglalható óráinak beállítása, valamint szankciópont-alapú - korlátozások. -

- +

Megnyitott hetek

+

+ +

+ +
+

Félévek

+

+ +

+ +
+

Beállítások

+

+

diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 579827f..10e7c34 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -5,24 +5,24 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 20 14.3% 4.1%; + --foreground: 0 0% 3.9%; --card: 0 0% 100%; - --card-foreground: 20 14.3% 4.1%; + --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; - --popover-foreground: 20 14.3% 4.1%; - --primary: 24.6 95% 53.1%; - --primary-foreground: 60 9.1% 97.8%; - --secondary: 60 4.8% 95.9%; - --secondary-foreground: 24 9.8% 10%; - --muted: 60 4.8% 95.9%; - --muted-foreground: 25 5.3% 44.7%; - --accent: 60 4.8% 95.9%; - --accent-foreground: 24 9.8% 10%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 60 9.1% 97.8%; - --border: 20 5.9% 90%; - --input: 20 5.9% 90%; - --ring: 24.6 95% 53.1%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; --radius: 0.75rem; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; @@ -32,25 +32,25 @@ } .dark { - --background: 20 14.3% 4.1%; - --foreground: 60 9.1% 97.8%; - --card: 20 14.3% 4.1%; - --card-foreground: 60 9.1% 97.8%; - --popover: 20 14.3% 4.1%; - --popover-foreground: 60 9.1% 97.8%; - --primary: 20.5 90.2% 48.2%; - --primary-foreground: 60 9.1% 97.8%; - --secondary: 12 6.5% 15.1%; - --secondary-foreground: 60 9.1% 97.8%; - --muted: 12 6.5% 15.1%; - --muted-foreground: 24 5.4% 63.9%; - --accent: 12 6.5% 15.1%; - --accent-foreground: 60 9.1% 97.8%; - --destructive: 0 72.2% 50.6%; - --destructive-foreground: 60 9.1% 97.8%; - --border: 12 6.5% 15.1%; - --input: 12 6.5% 15.1%; - --ring: 20.5 90.2% 48.2%; + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; @@ -110,35 +110,16 @@ overflow-x: hidden; -webkit-overflow-scrolling: touch; scroll-behavior: auto; - overscroll-behavior: contain; - will-change: scroll-position; - transform: translateZ(0); - backface-visibility: hidden; - isolation: isolate; - contain: layout style paint; scroll-padding-top: 0; scroll-padding-bottom: 0; } - /* Ensure smooth scrolling within the container */ - .main-content-scroll * { - transform: translateZ(0); - } - /* Prevent scroll momentum issues */ .main-content-scroll { -webkit-overflow-scrolling: auto; scroll-behavior: auto; } - /* Force hardware acceleration for smooth scrolling */ - .main-content-scroll { - -webkit-transform: translate3d(0, 0, 0); - -moz-transform: translate3d(0, 0, 0); - -ms-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - } - /* Sticky header within scroll container */ .main-content-scroll .sticky { position: sticky; @@ -148,10 +129,4 @@ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } - - /* Ensure sticky elements don't interfere with scroll */ - .main-content-scroll .sticky { - transform: translateZ(0); - will-change: transform; - } } diff --git a/apps/frontend/src/app/login/route.ts b/apps/frontend/src/app/login/route.ts new file mode 100644 index 0000000..10ee4ba --- /dev/null +++ b/apps/frontend/src/app/login/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export function GET() { + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3030'; + + return NextResponse.redirect(new URL('/auth/login', apiUrl)); +} diff --git a/apps/frontend/src/app/my-gatekeeps/page.tsx b/apps/frontend/src/app/my-gatekeeps/page.tsx new file mode 100644 index 0000000..bc4c44a --- /dev/null +++ b/apps/frontend/src/app/my-gatekeeps/page.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useUser } from '@/hooks/useUser'; +import axiosApi from '@/lib/apiSetup'; +import { ClubMembership } from '@/types/member'; +import { Reservation } from '@/types/reservation'; +import { SanctionRecord } from '@/types/sanction-record'; +import { withGatekeeperAuth } from '@/utils/withAuth'; + +function MyGatekeepsPage() { + const { user, loading: userLoading } = useUser(); + const [reservations, setReservations] = useState([]); + const [sanctions, setSanctions] = useState([]); // Added + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sanctionInputs, setSanctionInputs] = useState<{ [key: number]: string }>({}); + const [sanctionReasons, setSanctionReasons] = useState<{ [key: number]: string }>({}); + const [updatingReservation, setUpdatingReservation] = useState(null); + const [editingSanctionId, setEditingSanctionId] = useState(null); // Added + const [myGatekeeperId, setMyGatekeeperId] = useState(null); + + const fetchReservations = async () => { + if (userLoading) return; + + try { + setLoading(true); + setError(null); + + const sanctionsRes = await axiosApi.get('/sanction-records'); + setSanctions(sanctionsRes.data as SanctionRecord[]); + + const gateKeeperQuery = await axiosApi.get('/memberships'); + // Memberships endpoint returns a plain array directly + const memberships = gateKeeperQuery.data || []; + const myGatekeeper = memberships.find((membership: ClubMembership) => membership.userId === user?.id); + setMyGatekeeperId(myGatekeeper?.id || null); + + // Only the current user's gatekept reservations, filtered server-side. + if (!myGatekeeper?.id) { + setReservations([]); + return; + } + + const res = await axiosApi.get('/reservations', { + params: { page: -1, page_size: -1, gateKeeperId: myGatekeeper.id }, + }); + setReservations(res.data.data as Reservation[]); + } catch (err) { + setError('Nem sikerült betölteni a beengedéseket'); + console.error('Error fetching reservations:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!userLoading) { + fetchReservations(); + } + }, [userLoading]); + + const handleSanctionInputChange = (reservationId: number, value: string) => { + setSanctionInputs((prev) => ({ ...prev, [reservationId]: value })); + }; + + const handleReasonChange = (reservationId: number, value: string) => { + setSanctionReasons((prev) => ({ ...prev, [reservationId]: value })); + }; + + const handleSaveSanctionPoints = async (reservation: Reservation, sanctionId?: number) => { + const inputValue = sanctionInputs[reservation.id] || ''; + const pointsToAdd = Number(inputValue); + const reason = sanctionReasons[reservation.id] || ''; + + if (!Number.isInteger(pointsToAdd) || pointsToAdd <= 0) { + toast.error('Kérlek adj meg egy pozitív egész számot!'); + return; + } + + if (!reason.trim()) { + toast.error('Kérlek add meg az indoklást!'); + return; + } + + if (!myGatekeeperId && !sanctionId) { + toast.error('Nem található a beengedő azonosító'); + return; + } + + // Determine if this is a user or band sanction + const isUserReservation = Boolean(reservation.user?.id); + const isBandReservation = Boolean(reservation.band?.id); + + try { + setUpdatingReservation(reservation.id); + + if (sanctionId) { + // Update existing + await axiosApi.patch(`/sanction-records/${sanctionId}`, { + points: pointsToAdd, + reason: reason, + }); + toast.success(`Sikeresen frissítetted a szankciós pontokat!`); + setEditingSanctionId(null); + } else { + // Create new + await axiosApi.post('/sanction-records', { + userId: isUserReservation ? reservation.user?.id : undefined, + bandId: isBandReservation ? reservation.band?.id : undefined, + reservationId: reservation.id, + points: pointsToAdd, + reason: reason, + awardedBy: myGatekeeperId, + }); + toast.success(`Sikeresen hozzáadtad a szankciós pontokat!`); + } + + // Clear inputs and refresh reservations + setSanctionInputs((prev) => ({ ...prev, [reservation.id]: '' })); + setSanctionReasons((prev) => ({ ...prev, [reservation.id]: '' })); + await fetchReservations(); + } catch (err) { + console.error('Error saving sanction points:', err); + toast.error('Hiba történt a szankciós pontok mentése során'); + } finally { + setUpdatingReservation(null); + } + }; + + const formatDateTime = (date: Date | string) => { + return new Date(date).toLocaleString('hu-HU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const getStatusLabel = (status: string) => { + const labels: { [key: string]: string } = { + NORMAL: 'Normál', + OVERTIME: 'Túlóra', + ADMINMADE: 'Admin által létrehozva', + }; + return labels[status] || status; + }; + + if (loading) { + return ( +
+

Beengedéseim

+

Betöltés...

+
+ ); + } + + if (error) { + return ( +
+

Beengedéseim

+

{error}

+
+ ); + } + + return ( +
+
+

Beengedéseim

+
+ +
+ {reservations.length === 0 ? ( +

Nem található beengedésed

+ ) : ( +
+ {reservations.map((reservation) => { + const reserverName = reservation.user?.fullName || reservation.band?.name || 'Ismeretlen'; + + return ( + + + {reserverName} + + + {getStatusLabel(reservation.status)} + + + + + +
+

Kezdés:

+

{formatDateTime(reservation.startTime)}

+
+
+

Befejezés:

+

{formatDateTime(reservation.endTime)}

+
+
+ + + {(() => { + const sanction = sanctions.find((s) => s.reservationId === reservation.id); + const isEditing = editingSanctionId === sanction?.id; + + if (sanction && !isEditing) { + return ( +
+

+ {sanction.points} pont - {sanction.reason} +

+ {sanction.awardedBy === myGatekeeperId && ( + + )} +
+ ); + } + + return ( +
+ handleSanctionInputChange(reservation.id, e.target.value)} + className='w-32 px-3 py-2 text-sm border border-primary rounded-md focus:outline-none focus:ring-2 focus:ring-primary dark:bg-zinc-800' + disabled={updatingReservation === reservation.id} + /> + handleReasonChange(reservation.id, e.target.value)} + className='flex-1 px-3 py-2 text-sm border border-primary rounded-md focus:outline-none focus:ring-2 focus:ring-primary dark:bg-zinc-800' + disabled={updatingReservation === reservation.id} + /> + + {isEditing && ( + + )} +
+ ); + })()} +
+
+ ); + })} +
+ )} +
+
+ ); +} + +export default withGatekeeperAuth(MyGatekeepsPage); diff --git a/apps/frontend/src/app/profile/page.tsx b/apps/frontend/src/app/profile/page.tsx new file mode 100644 index 0000000..7034b35 --- /dev/null +++ b/apps/frontend/src/app/profile/page.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useUser } from '@/hooks/useUser'; +import axiosApi from '@/lib/apiSetup'; +import { withAuth } from '@/utils/withAuth'; + +type SanctionRecordWithDetails = { + id: number; + userId?: number; + bandId?: number; + points: number; + reason: string; + awardedBy: number; + awardedAt: string; + type: 'user' | 'band'; + bandName?: string; + gateKeeper?: { + user?: { + fullName: string; + }; + }; +}; + +type SanctionData = { + userSanctions: SanctionRecordWithDetails[]; + bandSanctions: SanctionRecordWithDetails[]; + allSanctions: SanctionRecordWithDetails[]; + totalUserPoints: number; + totalBandPoints: number; +}; + +function ProfilePage() { + const { user } = useUser(); + const [sanctionData, setSanctionData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSanctions = async () => { + try { + setLoading(true); + const res = await axiosApi.get('/sanction-records/me'); + setSanctionData(res.data); + } catch (err) { + console.error('Error fetching sanctions:', err); + setError('Nem sikerült betölteni a szankciós előzményeket'); + } finally { + setLoading(false); + } + }; + + fetchSanctions(); + }, []); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString('hu-HU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (loading) { + return ( +
+

Profilom

+

Betöltés...

+
+ ); + } + + if (error) { + return ( +
+

Profilom

+

{error}

+
+ ); + } + + const totalPoints = (sanctionData?.totalUserPoints || 0) + (sanctionData?.totalBandPoints || 0); + + return ( +
+
+

Profilom

+
+ +
+ {/* User Info Card */} + + + {user?.fullName} + {user?.email} + + +
+
+

{sanctionData?.totalUserPoints || 0}

+

Saját szankciós pont

+
+
+

{sanctionData?.totalBandPoints || 0}

+

Banda szankciós pont

+
+
+

{totalPoints}

+

Összes pont

+
+
+
+
+ + {/* Sanction History */} + + + Szankciós előzmények + A kapott szankciós pontok és indoklásaik + + + {sanctionData?.allSanctions.length === 0 ? ( +

Nincs szankciós előzmény

+ ) : ( + + + + Dátum + Típus + Pont + Indoklás + Beengedő + + + + {sanctionData?.allSanctions.map((sanction) => ( + + {formatDate(sanction.awardedAt)} + + + {sanction.type === 'user' ? 'Saját' : sanction.bandName || 'Banda'} + + + {sanction.points} + {sanction.reason} + {sanction.gateKeeper?.user?.fullName || 'Ismeretlen'} + + ))} + +
+ )} +
+
+
+
+ ); +} + +export default withAuth(ProfilePage); diff --git a/apps/frontend/src/app/register/route.ts b/apps/frontend/src/app/register/route.ts new file mode 100644 index 0000000..2e13832 --- /dev/null +++ b/apps/frontend/src/app/register/route.ts @@ -0,0 +1 @@ +export { GET } from '../login/route'; diff --git a/apps/frontend/src/app/reservation/page.tsx b/apps/frontend/src/app/reservation/page.tsx index d7910cf..4c284a6 100644 --- a/apps/frontend/src/app/reservation/page.tsx +++ b/apps/frontend/src/app/reservation/page.tsx @@ -1,9 +1,23 @@ +'use client'; + import Calendar from '@components/calendar/calendar'; +import { useRouter } from 'next/navigation'; + +import { Button } from '@/components/ui/button'; export default function ReservationPage() { + const router = useRouter(); + return ( -
-

Foglalások

+
+
+
+

Foglalások

+ +
+
); diff --git a/apps/frontend/src/app/rules/page.tsx b/apps/frontend/src/app/rules/page.tsx index 26d39bd..303ce2a 100644 --- a/apps/frontend/src/app/rules/page.tsx +++ b/apps/frontend/src/app/rules/page.tsx @@ -18,7 +18,7 @@ export default function Rules() {

Ha olyan problémád van, melyet a lent leírtak nem fednek, úgy a{' '} - + főispánt / teremispánt {' '} kell keresni. @@ -32,11 +32,11 @@ export default function Rules() {

  1. - + Regisztrálj {' '} vagy{' '} - + jelentkezz be.
  2. @@ -44,7 +44,7 @@ export default function Rules() { Iratkozz fel a{' '} @@ -69,7 +69,7 @@ export default function Rules() {

    A fentiekkel kapcsolatban bármilyen probléma esetén írjatok a levlistára ( - + probaterem@sch.bme.hu ). @@ -104,7 +104,7 @@ export default function Rules() {

  3. Időpontot a weblapon tudsz foglalni. Ezt minél hamarabb elintézed, annál esélyesebb, hogy lesz beengedőd. Amennyiben nincs, írj a listánkra! ( - + probaterem@sch.bme.hu ) @@ -114,7 +114,7 @@ export default function Rules() { az már feltételes foglalás lesz, ami azt jelenti, hogy bárki ráfoglalhat erre az időpontra (akár Te is másokéra). Figyeljetek rá, hogy az egyéni tagok foglalásai nem ekvivalensek a bandák foglalásaival (lásd:{' '} - + Szankciók ). diff --git a/apps/frontend/src/app/stats/page.tsx b/apps/frontend/src/app/stats/page.tsx index 7680111..7f75511 100644 --- a/apps/frontend/src/app/stats/page.tsx +++ b/apps/frontend/src/app/stats/page.tsx @@ -8,13 +8,9 @@ import axiosApi from '@/lib/apiSetup'; import { ClubMembership } from '@/types/member'; import { Reservation } from '@/types/reservation'; import { User } from '@/types/user'; +import { withGatekeeperAuth } from '@/utils/withAuth'; -function getCurrentPeriodStart() { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), 1); -} - -export default function Stats() { +function Stats() { const { user: me } = useUser(); const [loading, setLoading] = useState(true); @@ -71,8 +67,6 @@ export default function Stats() { }; }, []); - const periodStart = useMemo(() => getCurrentPeriodStart(), []); - const isAuthorized = useMemo(() => { if (!me) return false; if ((me as any)?.clubMembership) return true; @@ -80,20 +74,17 @@ export default function Stats() { }, [me, memberships]); const gatekeepingCounts = useMemo(() => { - const membershipToUser = new Map(); - users.forEach((u) => { - const mid = (u as any)?.clubMembership?.id as number | undefined; - if (mid) membershipToUser.set(mid, u); - }); - const eligibleUsers = users.filter((u) => (u as any)?.clubMembership?.isGateKeeper); + // One admission == one hour-long block, so a 3-hour reservation counts as 3 admissions. + // Counts are cumulative (all-time), per the spec's "eddigi beengedés". const counts = new Map(); reservations.forEach((r) => { - const start = new Date((r as any).startTime); const mid = (r as any).gateKeeperId as number | undefined; if (!mid) return; - if (start >= periodStart) counts.set(mid, (counts.get(mid) || 0) + 1); + const durationMs = new Date((r as any).endTime).getTime() - new Date((r as any).startTime).getTime(); + const blocks = Math.round(durationMs / (1000 * 60 * 60)); + counts.set(mid, (counts.get(mid) || 0) + blocks); }); return eligibleUsers.map((u) => { @@ -101,7 +92,7 @@ export default function Stats() { const count = mid ? counts.get(mid) || 0 : 0; return { id: u.id, fullName: u.fullName, count }; }); - }, [users, reservations, periodStart]); + }, [users, reservations]); if (loading) { return ( @@ -153,3 +144,5 @@ export default function Stats() {
); } + +export default withGatekeeperAuth(Stats); diff --git a/apps/frontend/src/components/admin/OpenedWeeksPanel.tsx b/apps/frontend/src/components/admin/OpenedWeeksPanel.tsx new file mode 100644 index 0000000..7e517da --- /dev/null +++ b/apps/frontend/src/components/admin/OpenedWeeksPanel.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { getFirstDayOfWeek } from '@/components/calendar/isReservationOvertime'; +import { Button } from '@/components/ui/button'; +import axiosApi from '@/lib/apiSetup'; +import { OpenedWeek } from '@/types/openedWeek'; + +export default function OpenedWeeksPanel() { + const [weeks, setWeeks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchWeeks(); + }, []); + + const fetchWeeks = async () => { + try { + const res = await axiosApi.get('/opened-weeks'); + setWeeks(res.data); + } catch (error) { + console.error('Failed to fetch opened weeks:', error); + } finally { + setLoading(false); + } + }; + + const handleToggleWeek = async (monday: Date, currentStatus: boolean) => { + try { + await axiosApi.put('/opened-weeks', { + monday: monday.toISOString(), + isOpen: !currentStatus, + }); + fetchWeeks(); + } catch (error) { + console.error('Failed to toggle week:', error); + } + }; + + // Generate upcoming weeks to display (e.g. 10 weeks ahead) + const renderUpcomingWeeks = () => { + const list = []; + const today = new Date(); + const currentMonday = getFirstDayOfWeek(today); + + for (let i = -1; i < 10; i++) { + const weekDate = new Date(currentMonday); + weekDate.setDate(currentMonday.getDate() + i * 7); + + const existingWeek = weeks.find((w) => new Date(w.monday).getTime() === weekDate.getTime()); + const isOpen = existingWeek ? existingWeek.isOpen : false; + + const weekEnd = new Date(weekDate); + weekEnd.setDate(weekDate.getDate() + 6); + + list.push( +
+
+ + {weekDate.toLocaleDateString('hu-HU')} - {weekEnd.toLocaleDateString('hu-HU')} + + + {isOpen ? 'Nyitva' : 'Zárva'} + +
+ +
+ ); + } + + return list; + }; + + if (loading) return
Betöltés...
; + + return ( +
+

Hetek Megnyitása

+

Itt nyithatod meg a jövőbeli heteket, hogy lehessen rájuk foglalni.

+ +
{renderUpcomingWeeks()}
+
+ ); +} diff --git a/apps/frontend/src/components/admin/PeriodsPanel.tsx b/apps/frontend/src/components/admin/PeriodsPanel.tsx new file mode 100644 index 0000000..2aeec97 --- /dev/null +++ b/apps/frontend/src/components/admin/PeriodsPanel.tsx @@ -0,0 +1,122 @@ +/* eslint-disable no-alert */ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import axiosApi from '@/lib/apiSetup'; +import { Period } from '@/types/period'; + +export default function PeriodsPanel() { + const [periods, setPeriods] = useState([]); + const [loading, setLoading] = useState(true); + const [newStart, setNewStart] = useState(''); + const [newEnd, setNewEnd] = useState(''); + + useEffect(() => { + fetchPeriods(); + }, []); + + const fetchPeriods = async () => { + try { + const res = await axiosApi.get('/periods'); + setPeriods(res.data); + } catch (error) { + console.error('Failed to fetch periods:', error); + } finally { + setLoading(false); + } + }; + + const handleToggleOpen = async (period: Period) => { + try { + await axiosApi.patch(`/periods/${period.id}`, { isOpen: !period.isOpen }); + fetchPeriods(); + } catch (error) { + console.error('Failed to update period:', error); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm('Biztosan törlöd ezt az időszakot?')) return; + try { + await axiosApi.delete(`/periods/${id}`); + fetchPeriods(); + } catch (error) { + console.error('Failed to delete period:', error); + } + }; + + const handleCreate = async () => { + if (!newStart || !newEnd) return; + if (new Date(newStart) > new Date(newEnd)) { + alert('A kezdő dátum nem lehet később, mint a befejező dátum.'); + return; + } + try { + await axiosApi.post('/periods', { + startDate: new Date(newStart).toISOString(), + endDate: new Date(newEnd).toISOString(), + isOpen: false, + }); + setNewStart(''); + setNewEnd(''); + fetchPeriods(); + } catch (error) { + console.error('Failed to create period:', error); + } + }; + + const formatDate = (dateString: Date | string) => { + return new Date(dateString).toLocaleDateString('hu-HU'); + }; + + if (loading) return
Betöltés...
; + + return ( +
+

Időszakok (Félévek) Kezelése

+ +
+

Új időszak hozzáadása

+
+
+ + setNewStart(e.target.value)} /> +
+
+ + setNewEnd(e.target.value)} /> +
+ +
+
+ +
+ {periods.map((period) => ( +
+
+ + {formatDate(period.startDate)} - {formatDate(period.endDate)} + + + {period.isOpen ? 'Nyitva' : 'Zárva'} + +
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/apps/frontend/src/components/admin/SettingsPanel.tsx b/apps/frontend/src/components/admin/SettingsPanel.tsx new file mode 100644 index 0000000..6af4a5d --- /dev/null +++ b/apps/frontend/src/components/admin/SettingsPanel.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import axiosApi from '@/lib/apiSetup'; +import { Settings } from '@/types/settings'; + +export default function SettingsPanel() { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + try { + const res = await axiosApi.get('/settings'); + setSettings(res.data); + } catch (error) { + console.error('Failed to fetch settings:', error); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!settings) return; + try { + await axiosApi.patch(`/settings/${settings.id}`, { + maxHoursPerWeek: Number(settings.maxHoursPerWeek), + maxHoursPerDay: Number(settings.maxHoursPerDay), + minReservationMinutes: Number(settings.minReservationMinutes), + maxReservationMinutes: Number(settings.maxReservationMinutes), + sanctionHourPenaltyPerPoint: Number(settings.sanctionHourPenaltyPerPoint), + maxTotalHoursPerWeek: Number(settings.maxTotalHoursPerWeek), + sanctionTotalHourPenaltyPerPoint: Number(settings.sanctionTotalHourPenaltyPerPoint), + banSanctionPointThreshold: Number(settings.banSanctionPointThreshold), + }); + toast.success('Beállítások sikeresen elmentve!'); + } catch (error) { + console.error('Failed to save settings:', error); + toast.error('Hiba történt a mentés során.'); + } + }; + + if (loading) return
Betöltés...
; + if (!settings) return
Nem sikerült betölteni a beállításokat.
; + + return ( +
+

Általános Beállítások

+
+
+ + setSettings({ ...settings, maxHoursPerWeek: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, maxHoursPerDay: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, minReservationMinutes: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, maxReservationMinutes: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, sanctionHourPenaltyPerPoint: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, maxTotalHoursPerWeek: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, sanctionTotalHourPenaltyPerPoint: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, banSanctionPointThreshold: Number(e.target.value) })} + /> +
+
+ +
+ ); +} diff --git a/apps/frontend/src/components/admin/reservation-limits-form.tsx b/apps/frontend/src/components/admin/reservation-limits-form.tsx deleted file mode 100644 index 07a4fc7..0000000 --- a/apps/frontend/src/components/admin/reservation-limits-form.tsx +++ /dev/null @@ -1,196 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useAdminConfig } from '@/hooks/useAdminConfig'; -import { SanctionTierInput } from '@/types/admin'; - -export function ReservationLimitsForm() { - const { config, loading, update } = useAdminConfig(); - const [saving, setSaving] = useState(false); - - const [userDailyHours, setUserDailyHours] = useState(''); - const [userWeeklyHours, setUserWeeklyHours] = useState(''); - const [bandDailyHours, setBandDailyHours] = useState(''); - const [bandWeeklyHours, setBandWeeklyHours] = useState(''); - const [tiers, setTiers] = useState([]); - - useEffect(() => { - if (config) { - setUserDailyHours(String(config.userDailyHours)); - setUserWeeklyHours(String(config.userWeeklyHours)); - setBandDailyHours(String(config.bandDailyHours)); - setBandWeeklyHours(String(config.bandWeeklyHours)); - setTiers( - config.sanctionTiers.map(({ minPoints, userDailyHours, userWeeklyHours, bandDailyHours, bandWeeklyHours }) => ({ - minPoints, - userDailyHours, - userWeeklyHours, - bandDailyHours, - bandWeeklyHours, - })) - ); - } - }, [config]); - - const addTier = () => { - setTiers((prev) => [ - ...prev, - { minPoints: 0, userDailyHours: 2, userWeeklyHours: 4, bandDailyHours: 3, bandWeeklyHours: 6 }, - ]); - }; - - const removeTier = (index: number) => { - setTiers((prev) => prev.filter((_, i) => i !== index)); - }; - - const updateTierField = (index: number, field: keyof SanctionTierInput, value: string) => { - const parsed = parseFloat(value); - if (isNaN(parsed)) return; - setTiers((prev) => prev.map((tier, i) => (i === index ? { ...tier, [field]: parsed } : tier))); - }; - - const handleSave = async () => { - setSaving(true); - await update({ - userDailyHours: parseFloat(userDailyHours), - userWeeklyHours: parseFloat(userWeeklyHours), - bandDailyHours: parseFloat(bandDailyHours), - bandWeeklyHours: parseFloat(bandWeeklyHours), - sanctionTiers: tiers, - }); - setSaving(false); - }; - - if (loading) { - return

Konfiguráció betöltése...

; - } - - return ( -
-
-
- - setUserDailyHours(e.target.value)} - /> -
-
- - setUserWeeklyHours(e.target.value)} - /> -
-
- - setBandDailyHours(e.target.value)} - /> -
-
- - setBandWeeklyHours(e.target.value)} - /> -
-
- -
-
-

Szankciópont-küszöbök

- -
- - {tiers.length === 0 && ( -

Nincsenek szankciópont-küszöbök beállítva.

- )} - - {tiers.map((tier, index) => ( -
-
- Küszöb #{index + 1} - -
-
-
- - updateTierField(index, 'minPoints', e.target.value)} - /> -
-
- - updateTierField(index, 'userDailyHours', e.target.value)} - /> -
-
- - updateTierField(index, 'userWeeklyHours', e.target.value)} - /> -
-
- - updateTierField(index, 'bandDailyHours', e.target.value)} - /> -
-
- - updateTierField(index, 'bandWeeklyHours', e.target.value)} - /> -
-
-
- ))} -
- - -
- ); -} diff --git a/apps/frontend/src/components/band/band-form-dialog.tsx b/apps/frontend/src/components/band/band-form-dialog.tsx index cdeb4a8..da2752c 100644 --- a/apps/frontend/src/components/band/band-form-dialog.tsx +++ b/apps/frontend/src/components/band/band-form-dialog.tsx @@ -109,7 +109,7 @@ export default function BandFormDialog({ mode, band, open, onOpenChange, onSucce } } catch (e: any) { if (e?.response?.status === 401) { - window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/login`; + window.location.href = '/login'; return; } showErrorToast(e); diff --git a/apps/frontend/src/components/calendar/Line.tsx b/apps/frontend/src/components/calendar/Line.tsx index 42f2ba5..bf08b77 100644 --- a/apps/frontend/src/components/calendar/Line.tsx +++ b/apps/frontend/src/components/calendar/Line.tsx @@ -2,7 +2,7 @@ export default function Line() { const offset = (new Date().getHours() + new Date().getMinutes() / 60) * 40 * 2; return (
void; onAddEvent: () => void; } export default function AddComment(props: AddCommentProps) { + // Initialize with rounded times (15-minute intervals) + const getRoundedDate = () => { + const date = new Date(); + const minutes = Math.floor(date.getMinutes() / 15) * 15; + date.setMinutes(minutes); + date.setSeconds(0); + date.setMilliseconds(0); + return date; + }; + const [comment, setComment] = useState(''); - const [startTime, setStartTime] = useState(new Date()); - const [endTime, setEndTime] = useState(new Date()); + const [startTime, setStartTime] = useState(getRoundedDate()); + const [endTime, setEndTime] = useState(getRoundedDate()); const [isReservable, setIsReservable] = useState(false); const { user } = useUser(); // Get current user @@ -56,42 +68,22 @@ export default function AddComment(props: AddCommentProps) { id='comment' type='text' placeholder='Írja be a kommentet...' - className='bg-white hover:bg-slate-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-black dark:text-zinc-200 border border-zinc-600 w-full px-3 py-2 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent' + className='bg-white hover:bg-slate-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-black dark:text-zinc-200 border border-zinc-600 w-full px-3 py-2 rounded-md focus:ring-2 focus:ring-ring focus:border-transparent' onChange={(e) => setComment(e.target.value)} value={comment} />
-
-
- - setStartTime(new Date(e.target.value))} - /> -
+
+ -
- - setEndTime(new Date(e.target.value))} - /> -
+
setIsReservable(e.target.checked)} checked={isReservable} @@ -102,7 +94,7 @@ export default function AddComment(props: AddCommentProps) {

- {selected === 'reservation' ? 'Új foglalás létrehozása' : 'Új komment hozzáadása'} + {selected === 'reservation' ? 'Új foglalás létrehozása' : 'Új felhívás hozzáadása'}

@@ -63,7 +63,7 @@ export function AddPanel(props: AddEventProps) { onClick={() => setSelected('reservation')} className={`flex-1 py-2 border-2 ${ selected === 'reservation' - ? 'bg-orange-500 hover:bg-orange-600 text-zinc-900' + ? 'bg-primary hover:bg-primary/90 text-primary-foreground' : 'bg-white hover:bg-slate-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-black dark:text-zinc-200' }`} > @@ -73,11 +73,11 @@ export function AddPanel(props: AddEventProps) { onClick={() => setSelected('comment')} className={`flex-1 py-2 border-2 ${ selected === 'comment' - ? 'bg-orange-500 hover:bg-orange-600 text-zinc-900' + ? 'bg-primary hover:bg-primary/90 text-primary-foreground' : 'bg-white hover:bg-slate-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-black dark:text-zinc-200' }`} > - Komment + Felhívás
@@ -95,7 +95,7 @@ export function AddPanel(props: AddEventProps) {
) : ( - )} diff --git a/apps/frontend/src/components/calendar/add-reservation.tsx b/apps/frontend/src/components/calendar/add-reservation.tsx index e001762..661cd72 100644 --- a/apps/frontend/src/components/calendar/add-reservation.tsx +++ b/apps/frontend/src/components/calendar/add-reservation.tsx @@ -1,13 +1,15 @@ -import { Input } from '@components/ui/input'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useUser } from '@/hooks/useUser'; +import { useWeekStatus } from '@/hooks/useWeekStatus'; import axiosApi from '@/lib/apiSetup'; import { submitReservation } from '@/lib/reservationSubmitter'; import { Band } from '@/types/band'; import { Reservation } from '@/types/reservation'; import { User } from '@/types/user'; +import { TimePicker } from './time-picker'; + interface AddPanelProps { onGetData: () => void; currentDate: Date; @@ -18,94 +20,72 @@ interface AddPanelProps { export default function AddReservation(props: AddPanelProps) { const { user: myUser, refetch: refetchUser } = useUser(); - const [bandName, setBandName] = useState(''); const [bands, setBands] = useState([]); - const [band, setBand] = useState(); - - const [userName, setUserName] = useState(''); const [users, setUsers] = useState([]); + + // Exclusive user OR band selection + const [selectedValue, setSelectedValue] = useState(''); const [user, setUser] = useState(); + const [band, setBand] = useState(); - const [startTime, setStartTime] = useState(new Date()); - const [endTime, setEndTime] = useState(new Date()); + // Initialize with rounded times (15-minute intervals) + const getRoundedDate = () => { + const date = new Date(); + const minutes = Math.floor(date.getMinutes() / 15) * 15; + date.setMinutes(minutes); + date.setSeconds(0); + date.setMilliseconds(0); + return date; + }; - const [userSuggestions, setUserSuggestions] = useState([]); - const [bandSuggestions, setBandSuggestions] = useState([]); - const [showUserSuggestions, setShowUserSuggestions] = useState(false); - const [showBandSuggestions, setShowBandSuggestions] = useState(false); - const suggestionRef = useRef(null); + const [startTime, setStartTime] = useState(getRoundedDate()); + const [endTime, setEndTime] = useState(getRoundedDate()); const [valid, setValid] = useState(true); const [errorMessage, setErrorMessage] = useState(''); + const [needToBeLetIn, setNeedToBeLetIn] = useState(false); const [adminOverride, setAdminOverride] = useState(false); + const [userBands, setUserBands] = useState([]); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (suggestionRef.current && !suggestionRef.current.contains(event.target as Node)) { - setShowUserSuggestions(false); - setShowBandSuggestions(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + // Check whether the week containing startTime is open for reservations (issue #58) + const weekStatus = useWeekStatus(startTime); + const weekIsLocked = !weekStatus.isOpen && myUser?.role !== 'ADMIN'; useEffect(() => { - axiosApi.get('/bands').then((res) => { - setBands(res.data); - }); - axiosApi.get('/users').then((res) => { - setUsers(res.data.users); - }); + axiosApi.get('/bands').then((res) => setBands(res.data)); + axiosApi.get('/users').then((res) => setUsers(res.data.users)); refetchUser(); - }, []); + }, [myUser]); - const handleBandNameChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setBandName(value); - if (value.length > 0) { - const filteredSuggestions = bands.filter((band) => band.name.toLowerCase().includes(value.toLowerCase())); - setBandSuggestions(filteredSuggestions); - setShowBandSuggestions(true); - } else { - setBandSuggestions([]); - setShowBandSuggestions(false); + // Filter bands to only show user's bands (based on membership) + useEffect(() => { + if (myUser && bands.length > 0) { + const myBandIds = + myUser.role === 'ADMIN' + ? bands // Admins see all bands + : bands.filter((band) => band.members?.some((member) => member.userId === myUser.id)); + setUserBands(myBandIds); } - }; + }, [myUser, bands]); - const handleUserNameChange = (e: React.ChangeEvent) => { - if (!Array.isArray(users)) { - console.error('HIBA: users nem egy tömb!', users); - return; - } + const handleSelectionChange = (e: React.ChangeEvent) => { const value = e.target.value; - setUserName(value); - if (value.length > 0) { - const filteredSuggestions = users.filter((user) => user.fullName.toLowerCase().includes(value.toLowerCase())); - setUserSuggestions(filteredSuggestions); - setShowUserSuggestions(true); - } else { - setUserSuggestions([]); - setShowUserSuggestions(false); + setSelectedValue(value); + + if (value.startsWith('user-')) { + const userId = parseInt(value.replace('user-', '')); + const selectedUser = users.find((u) => u.id === userId); + setUser(selectedUser); + setBand(undefined); + } else if (value.startsWith('band-')) { + const bandId = parseInt(value.replace('band-', '')); + const selectedBand = bands.find((b) => b.id === bandId); + setBand(selectedBand); + setUser(undefined); } }; - const handleUserSuggestionClick = (suggestion: User) => { - setUser(suggestion); - setUserName(suggestion.fullName); - setShowUserSuggestions(false); - }; - - const handleBandSuggestionClick = (suggestion: Band) => { - setBand(suggestion); - setBandName(suggestion.name); - setShowBandSuggestions(false); - }; - const handleSubmit = async () => { const { message } = await submitReservation({ user, @@ -118,136 +98,92 @@ export default function AddReservation(props: AddPanelProps) { props.onGetData(); setUser(undefined); setBand(undefined); - setBandName(''); - setUserName(''); - setStartTime(new Date()); - setEndTime(new Date()); + setSelectedValue(''); + setStartTime(getRoundedDate()); + setEndTime(getRoundedDate()); setAdminOverride(false); props.onAddEvent(); }, setValid, adminOverride, + needToBeLetIn, }); if (message) { setErrorMessage(message); } }; - function shiftStart(date: Date) { - date.setHours(date.getHours() + 1); - setStartTime(date); - } - - function shiftEnd(date: Date) { - date.setHours(date.getHours() + 1); - setEndTime(date); - } - - // For add-reservation.tsx - replace the return part with: return (
- {myUser?.role === 'ADMIN' ? ( - <> -
- -
- - {showUserSuggestions && userSuggestions.length > 0 && ( -
- {userSuggestions.map((suggestion) => ( - - ))} -
- )} -
-
- -
- - setAdminOverride(e.target.checked)} - className='h-4 w-4 accent-orange-500' - /> -
- - ) : null} - + {/* User OR Band Selection */}
-