diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js new file mode 100644 index 0000000..914ceeb --- /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: ['**/*.(t|j)s'], + 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/schema.prisma b/apps/backend/prisma/schema.prisma index e00e147..338cba1 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(8.0) + maxHoursPerDay Float @default(4.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..5801cae 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: true }, + }); return res; } catch (error) { throw new NotFoundException('No band found'); diff --git a/apps/backend/src/comments/comments.service.ts b/apps/backend/src/comments/comments.service.ts index 8b2b156..f4421b9 100644 --- a/apps/backend/src/comments/comments.service.ts +++ b/apps/backend/src/comments/comments.service.ts @@ -11,12 +11,22 @@ import { Comment } from './entities/comment.entity'; export class CommentsService { constructor(private readonly prisma: PrismaService) {} - create(createCommentDto: CreateCommentDto) { - return this.prisma.comment.create({ + async create(createCommentDto: CreateCommentDto) { + const comment = await this.prisma.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) + ); + } + + return comment; } findAll(page?: number, pageSize?: number): Promise> { @@ -65,7 +75,7 @@ export class CommentsService { async update(id: number, updateCommentDto: UpdateCommentDto) { try { - return await this.prisma.comment.update({ + const updated = await this.prisma.comment.update({ where: { id, }, @@ -73,6 +83,13 @@ export class CommentsService { ...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)); + } + + return updated; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === 'P2025') { @@ -99,4 +116,24 @@ 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): Promise { + const { count } = await this.prisma.reservation.deleteMany({ + where: { + AND: [ + { startTime: { lt: endTime } }, // reservation starts before comment ends + { endTime: { gt: startTime } }, // reservation ends after comment starts + { status: { not: 'ADMINMADE' } }, // admin reservations are never auto-deleted + ], + }, + }); + return count; + } } 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..7840f50 --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.controller.ts @@ -0,0 +1,27 @@ +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() + 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..ad5d66b --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.service.ts @@ -0,0 +1,24 @@ +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) { + const monday = new Date(dto.monday); + 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..2a67bd9 --- /dev/null +++ b/apps/backend/src/periods/periods.service.ts @@ -0,0 +1,41 @@ +import { 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) { + 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/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..5fab70e 100644 --- a/apps/backend/src/reservations/reservations.controller.ts +++ b/apps/backend/src/reservations/reservations.controller.ts @@ -20,7 +20,12 @@ export class ReservationsController { @Get() @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) - async findAll(@Query('page', ParseIntPipe) page: number, @Query('page_size', ParseIntPipe) pageSize: number) { + async findAll( + @Query('page', ParseIntPipe) page: number, + @Query('page_size', ParseIntPipe) pageSize: number, + @Query('gateKeeperId') gateKeeperId?: string + ) { + const parsedGateKeeperId = gateKeeperId ? parseInt(gateKeeperId, 10) : undefined; return this.reservationsService.findAll(page, pageSize); } 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..c914cd6 --- /dev/null +++ b/apps/backend/src/reservations/reservations.service.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from 'nestjs-prisma'; + +import { ReservationsService } from './reservations.service'; + +describe('ReservationsService', () => { + let service: ReservationsService; + //let prisma: PrismaService; + + const mockPrismaService = { + reservation: { + create: 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(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReservationsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(ReservationsService); + //prisma = module.get(PrismaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/reservations/reservations.service.ts b/apps/backend/src/reservations/reservations.service.ts index 00f5f9b..40d795f 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 { GateKeeperPriority, Prisma, ReservationStatus } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { PaginationDto } from '../dto/pagination.dto'; @@ -11,12 +19,203 @@ import { Reservation } from './entities/reservation.entity'; export class ReservationsService { constructor(private readonly prisma: PrismaService) {} - create(createReservationDto: CreateReservationDto) { + async create(createReservationDto: CreateReservationDto) { + // Validate the reservation + await this.validateReservation(createReservationDto); + + // Determine status + const status = await this.determineReservationStatus(createReservationDto); + return this.prisma.reservation.create({ data: { ...createReservationDto, + status: createReservationDto.status || status, + }, + }); + } + + private async validateReservation(dto: CreateReservationDto | UpdateReservationDto) { + // 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 (30 min - 3 hours) + const durationMs = endTime.getTime() - startTime.getTime(); + const minDuration = 30 * 60 * 1000; + const maxDuration = 3 * 60 * 60 * 1000; + + if (durationMs < minDuration) { + throw new BadRequestException('A foglalás túl rövid. Kérlek, adj meg legalább 30 perces idősávot.'); + } + if (durationMs > maxDuration) { + throw new BadRequestException('A foglalás túl hosszú. A maximálisan foglalható időtartam 3 ó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ő'); + } + + // 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.'); + } + } + + private async determineReservationStatus(dto: CreateReservationDto, excludeId?: number): Promise { + // Admin-made reservations keep their ADMINMADE status + if (dto.status === ReservationStatus.ADMINMADE) { + return ReservationStatus.ADMINMADE; + } + + if (!dto.userId) { + return ReservationStatus.NORMAL; // Band reservations default to NORMAL + } + + // 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; } findAll(page?: number, pageSize?: number): Promise> { @@ -24,6 +223,11 @@ export class ReservationsService { const reservations = this.prisma.reservation.findMany({ skip: hasPagination ? (page - 1) * pageSize : undefined, take: hasPagination ? pageSize : undefined, + include: { + user: true, + band: true, + gateKeeper: { include: { user: true } }, + }, orderBy: { startTime: 'asc', }, @@ -52,6 +256,10 @@ export class ReservationsService { where: { id, }, + include: { + user: true, + band: true, + }, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { @@ -64,6 +272,56 @@ export class ReservationsService { } async update(id: number, updateReservationDto: UpdateReservationDto) { + const existing = await this.prisma.reservation.findUniqueOrThrow({ + where: { id }, + include: { gateKeeper: { include: { user: true } } }, + }); + + // 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 + // TODO: Send email to the overridden gatekeeper if possible + // In a real scenario, we'd inject an EmailService here + console.log(`Gatekeeper ${existing.gateKeeperId} was overridden by ${updateReservationDto.gateKeeperId}`); + } + } + } + + // 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); + + // 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: { 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..e2f5128 --- /dev/null +++ b/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts @@ -0,0 +1,27 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class CreateSanctionRecordDto { + @IsNumber() + @IsOptional() + userId?: number; + + @IsNumber() + @IsOptional() + bandId?: number; + + @IsNumber() + @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..4e84e8e --- /dev/null +++ b/apps/backend/src/sanction-records/dto/update-sanction-record.dto.ts @@ -0,0 +1,12 @@ +import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class UpdateSanctionRecordDto { + @IsNumber() + @IsOptional() + @IsPositive() + points?: number; + + @IsString() + @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..5db48cf --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.controller.ts @@ -0,0 +1,62 @@ +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') + async findByUserId(@Param('userId', ParseIntPipe) userId: number) { + return this.sanctionRecordsService.findByUserId(userId); + } + + @Get('band/:bandId') + 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..ca38600 --- /dev/null +++ b/apps/backend/src/settings/settings.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; + +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')) + 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..f213e18 --- /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: 8.0, + maxHoursPerDay: 4.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/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..9e255f3 --- /dev/null +++ b/apps/frontend/docs/PAGE_PROTECTION.md @@ -0,0 +1,178 @@ +# 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 + +``` +src/ +├── middleware.ts # Server-side route protection +├── utils/ +│ └── withAuth.tsx # Client-side HOC for role-based access +├── pages/ +│ ├── admin/ +│ │ └── index.tsx # Example admin page +│ └── login.tsx # Login page (public) +└── hooks/ + └── useUser.ts # 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..4bba82a 100644 --- a/apps/frontend/src/app/admin/page.tsx +++ b/apps/frontend/src/app/admin/page.tsx @@ -3,11 +3,14 @@ import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; -import { ReservationLimitsForm } from '@/components/admin/reservation-limits-form'; import { UserRoleTable } from '@/components/admin/user-role-table'; import { useProfile } from '@/hooks/useProfile'; import { Role } from '@/types/user'; +import OpenedWeeksPanel from '../../components/admin/OpenedWeeksPanel'; +import PeriodsPanel from '../../components/admin/PeriodsPanel'; +import SettingsPanel from '../../components/admin/SettingsPanel'; + export default function AdminPage() { const router = useRouter(); const { profile, loading } = useProfile(); @@ -37,12 +40,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/my-gatekeeps/page.tsx b/apps/frontend/src/app/my-gatekeeps/page.tsx new file mode 100644 index 0000000..4552e8d --- /dev/null +++ b/apps/frontend/src/app/my-gatekeeps/page.tsx @@ -0,0 +1,286 @@ +'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 res = await axiosApi.get('/reservations', { params: { page: -1, page_size: -1 } }); + const allReservations = res.data.data as Reservation[]; + + 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); + + // Filter for reservations where current user is the gatekeeper + const gateKeeperReservations = allReservations.filter((reservation) => { + return reservation.gateKeeperId === myGatekeeper?.id; + }); + + setReservations(gateKeeperReservations); + } 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 = parseInt(inputValue, 10); + const reason = sanctionReasons[reservation.id] || ''; + + if (isNaN(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/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..fc1df17 100644 --- a/apps/frontend/src/app/stats/page.tsx +++ b/apps/frontend/src/app/stats/page.tsx @@ -8,13 +8,14 @@ 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); @@ -153,3 +154,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..35d7da6 --- /dev/null +++ b/apps/frontend/src/components/admin/PeriodsPanel.tsx @@ -0,0 +1,118 @@ +/* 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; + 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..69e5046 --- /dev/null +++ b/apps/frontend/src/components/admin/SettingsPanel.tsx @@ -0,0 +1,113 @@ +'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, 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/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 */}
-