diff --git a/.eslintrc.js b/.eslintrc.js index d687ee1..a6b1fb9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,7 +5,7 @@ module.exports = { env: { node: true, }, - ignorePatterns: ['.eslintrc.js', '.prettierrc.js'], + ignorePatterns: ['.eslintrc.js', '.prettierrc.js', 'apps/frontend/next-env.d.ts'], rules: { 'prettier/prettier': 'error', 'simple-import-sort/imports': 'error', diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index bad5164..5b6d140 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies run: yarn install @@ -39,7 +39,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies run: yarn install @@ -53,6 +53,18 @@ jobs: needs: - lint-check - format-check + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: mmmk + POSTGRES_PASSWORD: mmmk + POSTGRES_DB: mmmk + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql://mmmk:mmmk@localhost:5432/mmmk steps: - name: Checkout code uses: actions/checkout@v4 @@ -60,10 +72,23 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies run: yarn install - name: Build run: cd apps/backend && yarn prisma generate && yarn build + + - name: Type-check and build frontend + run: cd apps/frontend && npx tsc --noEmit && yarn build + env: + NEXT_PUBLIC_API_URL: http://localhost:3030 + + - name: Apply migrations to an empty database + run: cd apps/backend && yarn prisma migrate deploy + + - name: Test + run: cd apps/backend && yarn test:backend + env: + MMMK_RUN_DATABASE_TESTS: '1' diff --git a/.gitignore b/.gitignore index 5a61dee..a7c985e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ yarn-error.log* .DS_Store .env .amazonq + +qa-* +copilot.* + +# Logs +*.log diff --git a/.prettierignore b/.prettierignore index f074915..1e52b62 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules .idea dist -.next \ No newline at end of file +.next +next-env.d.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d56a93e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# AGENTS.md + +This repository contains a full-stack application using NestJS 11 + Prisma 6 (backend) and Next.js 16 / React 19 (frontend) in a Yarn monorepo (Node 22). Product rules, structure and conventions: `project_info/`. + +## Build/Lint/Test Commands + +### Development + +- **Backend:** `yarn start:backend` (runs `apps/backend` in dev mode) +- **Frontend:** `yarn start:frontend` (runs `apps/frontend` in dev mode) + +### Build + +- **Backend:** `yarn build:backend` +- **Frontend:** `yarn build:frontend` + +### Linting & Formatting + +- **Lint:** `yarn lint` +- **Lint Fix:** `yarn lint:fix` +- **Format:** `yarn format` +- **Format Check:** `yarn format:check` + +### Testing + +- **Backend:** `cd apps/backend && yarn test:backend` (Jest 30) +- **DB integration tests:** opt-in with `MMMK_RUN_DATABASE_TESTS=1` and `DATABASE_URL`; CI runs them on Postgres (`.github/workflows/analysis.yml`) +- **Frontend:** no tests; type-check with `cd apps/frontend && npx tsc --noEmit` + +--- + +## Code Style & Guidelines + +### TypeScript + +- Use TypeScript for all new code. +- Strict typing is encouraged. + +### Backend (NestJS) + +- Follow NestJS conventions: controllers, services, modules, DTOs, entities. +- Use dependency injection for services. +- Validate incoming data using `class-validator` and `class-transformer`. +- Structure: `apps/backend/src//`; shared helpers in `src/utils/`. +- Prisma errors are mapped globally (`main.ts`); don't wrap calls in try/catch just to map `P2025`. +- Env vars: `apps/backend/.env.example`. Schema changes need a migration. + +### Frontend (Next.js) + +- Follow Next.js App Router conventions. Route protection is `src/proxy.ts` (Next 16 replaced `middleware.ts`) plus the `withAuth` HOCs. +- Structure: `apps/frontend/src/app/` for routes, `apps/frontend/src/components/` for reusable UI. +- Styling: Tailwind CSS. +- Components: Use shadcn/ui components where applicable. + +### General + +- **Imports:** Use absolute imports if configured, otherwise be consistent with the project's relative import style. +- **Naming Conventions:** + - Use camelCase for functions, variables, and properties. + - Use PascalCase for classes, components, and interfaces/types. +- **Error Handling:** + - Use meaningful error messages; user-facing messages are Hungarian. + - Leverage NestJS Exception Filters in the backend. +- **Formatting:** Prettier is used. Ensure `yarn format:check` passes before committing. + +--- + +_This file serves as the primary guideline for autonomous agents working in this repository._ diff --git a/README.md b/README.md index b1416db..cc5844a 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,3 @@ yarn build:backend ``` ## Happy Coding! - - - diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..da69863 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,34 @@ +# PostgreSQL kapcsolat (Prisma) +DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/mmmk" + +# AuthSCH OAuth kliens +AUTHSCH_CLIENT_ID="your-authsch-client-id" +AUTHSCH_CLIENT_SECRET="your-authsch-client-secret" + +# JWT aláíró kulcs (hosszú, véletlen string) +JWT_SECRET="change-me" + +# Frontend origin (CORS) és a bejelentkezés utáni visszairányítás címe +FRONTEND_URL="http://localhost:3000" +FRONTEND_CALLBACK_URL="http://localhost:3000/callback" + +# A backend portja (alapértelmezés: 3030); a docker-compose a BACKEND_PORT-ot használja a port-mappinghez +PORT=3030 +BACKEND_PORT=3030 + +# PEK szinkron: a kör neve és a titulusok +PEK_GROUP_NAME="Muzsika mívelő mérnökök klubja" +PEK_MEMBER_TITLE="tag" +PEK_NEWBIE_TITLE="újonc" +PEK_GATEKEEPER_TITLE="beengedő" +# Opcionális: ez a titulus is beengedő jogot ad +PEK_ROOM_MANAGER_TITLE="" +# Opcionális: admin jogot adó titulusok vesszővel elválasztva (alapértelmezés: körvezető,teremmester,ac) +# PEK_LEADERSHIP_TITLES="körvezető,teremmester,ac" + +# Szerveroldali e-mail értesítések (kir-mail) +KIR_MAIL_URL="https://kir-mail.example.com" +KIR_MAIL_TOKEN="your-kir-mail-token" + +# Csak tesztekhez: 1 esetén lefutnak az adatbázisos integrációs tesztek +# MMMK_RUN_DATABASE_TESTS=1 diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js new file mode 100644 index 0000000..729de0c --- /dev/null +++ b/apps/backend/jest.config.js @@ -0,0 +1,14 @@ +// Same club timezone as main.ts, so day/week boundaries in tests match the running server. +process.env.TZ = 'Europe/Budapest'; + +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.{ts,js}'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/apps/backend/package.json b/apps/backend/package.json index 9c9219a..a1f59c1 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,22 +11,24 @@ "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", - "@nestjs/common": "^10.4.16", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.18", - "@nestjs/jwt": "^10.2.0", - "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.3.8", - "@nestjs/swagger": "^7.3.1", + "@kir-dev/passport-authsch": "^2.2.5", + "@nestjs/common": "^11.2.8", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.2.8", + "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.1", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.2.8", + "@nestjs/swagger": "^11.4.7", "@prisma/client": "6.3.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", - "nestjs-prisma": "^0.23.0", + "nestjs-prisma": "^0.27.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "prisma": "6.3.1", @@ -35,13 +37,17 @@ "rxjs": "^7.8.1" }, "devDependencies": { - "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.1", - "@types/express": "^4.17.21", + "@nestjs/cli": "^11.0.24", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.2.8", + "@types/express": "^5.0.6", + "@types/jest": "^30.0.0", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", + "jest": "^30.3.0", "source-map-support": "^0.5.21", + "ts-jest": "^29.4.6", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql b/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql new file mode 100644 index 0000000..4396b3d --- /dev/null +++ b/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql @@ -0,0 +1,20 @@ +-- AlterEnum +ALTER TYPE "ReservationStatus" ADD VALUE 'SANCTIONED'; + +-- AlterTable +ALTER TABLE "Period" ADD COLUMN "isOpen" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "sanctionPoints" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "Settings" ( + "id" SERIAL NOT NULL, + "maxHoursPerWeek" DOUBLE PRECISION NOT NULL DEFAULT 8.0, + "maxHoursPerDay" DOUBLE PRECISION NOT NULL DEFAULT 4.0, + "minReservationMinutes" INTEGER NOT NULL DEFAULT 30, + "maxReservationMinutes" INTEGER NOT NULL DEFAULT 180, + "sanctionHourPenaltyPerPoint" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + + CONSTRAINT "Settings_pkey" PRIMARY KEY ("id") +); diff --git a/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql b/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql new file mode 100644 index 0000000..07def91 --- /dev/null +++ b/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Reservation" ADD COLUMN "needToBeLetIn" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql b/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql new file mode 100644 index 0000000..5d3d485 --- /dev/null +++ b/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `sanctionPoints` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "sanctionPoints"; + +-- CreateTable +CREATE TABLE "SanctionRecord" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "bandId" INTEGER, + "points" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "awardedBy" INTEGER NOT NULL, + "awardedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SanctionRecord_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_bandId_fkey" FOREIGN KEY ("bandId") REFERENCES "Band"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_awardedBy_fkey" FOREIGN KEY ("awardedBy") REFERENCES "ClubMembership"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260320130812_add_gatekeeper_priority/migration.sql b/apps/backend/prisma/migrations/20260320130812_add_gatekeeper_priority/migration.sql new file mode 100644 index 0000000..dcc763b --- /dev/null +++ b/apps/backend/prisma/migrations/20260320130812_add_gatekeeper_priority/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "GateKeeperPriority" AS ENUM ('PRIMARY', 'SECONDARY'); + +-- AlterTable +ALTER TABLE "Reservation" ADD COLUMN "gateKeeperPriority" "GateKeeperPriority"; diff --git a/apps/backend/prisma/migrations/20260322135731_remove_sanction_type/migration.sql b/apps/backend/prisma/migrations/20260322135731_remove_sanction_type/migration.sql new file mode 100644 index 0000000..301f534 --- /dev/null +++ b/apps/backend/prisma/migrations/20260322135731_remove_sanction_type/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [SANCTIONED] on the enum `ReservationStatus` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "ReservationStatus_new" AS ENUM ('NORMAL', 'OVERTIME', 'ADMINMADE'); +ALTER TABLE "Reservation" ALTER COLUMN "status" TYPE "ReservationStatus_new" USING ("status"::text::"ReservationStatus_new"); +ALTER TYPE "ReservationStatus" RENAME TO "ReservationStatus_old"; +ALTER TYPE "ReservationStatus_new" RENAME TO "ReservationStatus"; +DROP TYPE "ReservationStatus_old"; +COMMIT; diff --git a/apps/backend/prisma/migrations/20260322155651_add_weeks_and_extra_settings_attributes/migration.sql b/apps/backend/prisma/migrations/20260322155651_add_weeks_and_extra_settings_attributes/migration.sql new file mode 100644 index 0000000..a471725 --- /dev/null +++ b/apps/backend/prisma/migrations/20260322155651_add_weeks_and_extra_settings_attributes/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "banSanctionPointThreshold" INTEGER NOT NULL DEFAULT 5, +ADD COLUMN "maxTotalHoursPerWeek" DOUBLE PRECISION NOT NULL DEFAULT 12.0, +ADD COLUMN "sanctionTotalHourPenaltyPerPoint" DOUBLE PRECISION NOT NULL DEFAULT 2.0; + +-- CreateTable +CREATE TABLE "OpenedWeek" ( + "id" SERIAL NOT NULL, + "monday" TIMESTAMP(3) NOT NULL, + "isOpen" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "OpenedWeek_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OpenedWeek_monday_key" ON "OpenedWeek"("monday"); diff --git a/apps/backend/prisma/migrations/20260323103330_removed_unnecessary_reservation_config_models/migration.sql b/apps/backend/prisma/migrations/20260323103330_removed_unnecessary_reservation_config_models/migration.sql new file mode 100644 index 0000000..3ec6414 --- /dev/null +++ b/apps/backend/prisma/migrations/20260323103330_removed_unnecessary_reservation_config_models/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the `ReservationConfig` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `SanctionTier` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SanctionTier" DROP CONSTRAINT "SanctionTier_configId_fkey"; + +-- DropTable +DROP TABLE "ReservationConfig"; + +-- DropTable +DROP TABLE "SanctionTier"; diff --git a/apps/backend/prisma/migrations/20260326114226_init/migration.sql b/apps/backend/prisma/migrations/20260326114226_init/migration.sql new file mode 100644 index 0000000..8f0745f --- /dev/null +++ b/apps/backend/prisma/migrations/20260326114226_init/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SanctionRecord" ADD COLUMN "reservationId" INTEGER; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_reservationId_fkey" FOREIGN KEY ("reservationId") REFERENCES "Reservation"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260618150000_align_reservation_quota_defaults/migration.sql b/apps/backend/prisma/migrations/20260618150000_align_reservation_quota_defaults/migration.sql new file mode 100644 index 0000000..8f2e577 --- /dev/null +++ b/apps/backend/prisma/migrations/20260618150000_align_reservation_quota_defaults/migration.sql @@ -0,0 +1,10 @@ +ALTER TABLE "Settings" ALTER COLUMN "maxHoursPerWeek" SET DEFAULT 6.0; +ALTER TABLE "Settings" ALTER COLUMN "maxHoursPerDay" SET DEFAULT 3.0; + +UPDATE "Settings" +SET "maxHoursPerWeek" = 6.0 +WHERE "maxHoursPerWeek" = 8.0; + +UPDATE "Settings" +SET "maxHoursPerDay" = 3.0 +WHERE "maxHoursPerDay" = 4.0; diff --git a/apps/backend/prisma/migrations/20261005140000_apply_conditional_sanction_level/migration.sql b/apps/backend/prisma/migrations/20261005140000_apply_conditional_sanction_level/migration.sql new file mode 100644 index 0000000..8b5106d --- /dev/null +++ b/apps/backend/prisma/migrations/20261005140000_apply_conditional_sanction_level/migration.sql @@ -0,0 +1,19 @@ +-- Apply the third sanction level to existing upcoming bookings as well as new ones. +-- Keep past admissions and protected admin bookings unchanged. +UPDATE "Reservation" AS reservation +SET "status" = 'OVERTIME' +WHERE reservation."status" = 'NORMAL' + AND reservation."startTime" >= CURRENT_TIMESTAMP + AND ( + (reservation."userId" IS NOT NULL AND ( + SELECT COALESCE(SUM(sanction."points"), 0) + FROM "SanctionRecord" AS sanction + WHERE sanction."userId" = reservation."userId" + ) >= 3) + OR + (reservation."bandId" IS NOT NULL AND ( + SELECT COALESCE(SUM(sanction."points"), 0) + FROM "SanctionRecord" AS sanction + WHERE sanction."bandId" = reservation."bandId" + ) >= 3) + ); diff --git a/apps/backend/prisma/migrations/20261013120000_band_reservations_without_user/migration.sql b/apps/backend/prisma/migrations/20261013120000_band_reservations_without_user/migration.sql new file mode 100644 index 0000000..05d8751 --- /dev/null +++ b/apps/backend/prisma/migrations/20261013120000_band_reservations_without_user/migration.sql @@ -0,0 +1,3 @@ +-- A foglalás vagy felhasználóé, vagy zenekaré. A korábbi felület zenekari foglalásnál a foglaló +-- felhasználót is elmentette, ami a szerkesztést elutasítja és a foglaló személyes keretébe is beszámít. +UPDATE "Reservation" SET "userId" = NULL WHERE "bandId" IS NOT NULL; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index a621df1..28bdec3 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -29,6 +29,11 @@ enum BandMembershipStatus { ACCEPTED } +enum GateKeeperPriority { + PRIMARY + SECONDARY +} + model User { id Int @id @default(autoincrement()) email String @unique @@ -43,6 +48,7 @@ model User { Post Post[] profilePicture ProfilePicture? reservations Reservation[] @relation("Reservations") + sanctionRecords SanctionRecord[] @relation("UserSanctions") } model ProfilePicture { @@ -70,18 +76,20 @@ 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[] - isApproved Boolean @default(false) - members BandMembership[] - reservations Reservation[] + id Int @id @default(autoincrement()) + name String + email String? + webPage String? + description String? + genres String[] + isApproved Boolean @default(false) + members BandMembership[] + reservations Reservation[] + sanctionRecords SanctionRecord[] @relation("BandSanctions") } model BandMembership { @@ -97,16 +105,19 @@ model BandMembership { } model Reservation { - 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]) + id Int @id @default(autoincrement()) + userId Int? + bandId Int? + startTime DateTime + endTime DateTime + 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 { @@ -121,6 +132,7 @@ model Period { id Int @id @default(autoincrement()) startDate DateTime endDate DateTime + isOpen Boolean @default(false) } model Post { @@ -133,22 +145,36 @@ model Post { User User @relation(fields: [authorId], references: [id]) } -model ReservationConfig { - id Int @id @default(autoincrement()) - userDailyHours Float @default(4) - userWeeklyHours Float @default(8) - bandDailyHours Float @default(6) - bandWeeklyHours Float @default(12) - sanctionTiers SanctionTier[] -} - -model SanctionTier { - id Int @id @default(autoincrement()) - configId Int - config ReservationConfig @relation(fields: [configId], references: [id]) - minPoints Int - userDailyHours Float - userWeeklyHours Float - bandDailyHours Float - bandWeeklyHours Float +model Settings { + id Int @id @default(autoincrement()) + maxHoursPerWeek Float @default(6.0) + maxHoursPerDay Float @default(3.0) + minReservationMinutes Int @default(30) + maxReservationMinutes Int @default(180) + sanctionHourPenaltyPerPoint Float @default(1.0) + maxTotalHoursPerWeek Float @default(12.0) + sanctionTotalHourPenaltyPerPoint Float @default(2.0) + banSanctionPointThreshold Int @default(5) +} + +model OpenedWeek { + id Int @id @default(autoincrement()) + monday DateTime @unique + isOpen Boolean @default(false) +} + +model SanctionRecord { + id Int @id @default(autoincrement()) + userId Int? + bandId Int? + reservationId Int? + points Int + reason String + awardedBy Int + awardedAt DateTime @default(now()) + + user User? @relation("UserSanctions", fields: [userId], references: [id]) + band Band? @relation("BandSanctions", fields: [bandId], references: [id]) + reservation Reservation? @relation(fields: [reservationId], references: [id]) + gateKeeper ClubMembership @relation("GateKeeperSanctions", fields: [awardedBy], references: [id]) } diff --git a/apps/backend/src/admin/admin.controller.ts b/apps/backend/src/admin/admin.controller.ts deleted file mode 100644 index 0bdc134..0000000 --- a/apps/backend/src/admin/admin.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CurrentUser } from '@kir-dev/passport-authsch'; -import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { ApiBearerAuth } from '@nestjs/swagger'; -import { Role, User } from '@prisma/client'; - -import { Roles } from '../auth/decorators/Roles.decorator'; -import { RolesGuard } from '../auth/roles.guard'; -import { AdminService } from './admin.service'; -import { SetRoleDto } from './dto/set-role.dto'; -import { UpdateConfigDto } from './dto/update-config.dto'; - -@Controller('admin') -@ApiBearerAuth() -@UseGuards(AuthGuard('jwt'), RolesGuard) -@Roles(Role.ADMIN) -export class AdminController { - constructor(private readonly adminService: AdminService) {} - - @Get('config') - getConfig() { - return this.adminService.getConfig(); - } - - @Patch('config') - updateConfig(@Body() updateConfigDto: UpdateConfigDto) { - return this.adminService.updateConfig(updateConfigDto); - } - - @Patch('users/:id/role') - setUserRole(@Param('id', ParseIntPipe) targetId: number, @Body() setRoleDto: SetRoleDto, @CurrentUser() user: User) { - return this.adminService.setUserRole(user.id, targetId, setRoleDto.role); - } -} diff --git a/apps/backend/src/admin/admin.module.ts b/apps/backend/src/admin/admin.module.ts deleted file mode 100644 index a8ef137..0000000 --- a/apps/backend/src/admin/admin.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AdminController } from './admin.controller'; -import { AdminService } from './admin.service'; - -@Module({ - controllers: [AdminController], - providers: [AdminService], -}) -export class AdminModule {} diff --git a/apps/backend/src/admin/admin.service.ts b/apps/backend/src/admin/admin.service.ts deleted file mode 100644 index 01f4abf..0000000 --- a/apps/backend/src/admin/admin.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { PrismaService } from 'nestjs-prisma'; - -import { UpdateConfigDto } from './dto/update-config.dto'; - -const CONFIG_ID = 1; - -@Injectable() -export class AdminService { - constructor(private readonly prisma: PrismaService) {} - - async getConfig() { - return this.prisma.reservationConfig.upsert({ - where: { id: CONFIG_ID }, - update: {}, - create: {}, - include: { sanctionTiers: { orderBy: { minPoints: 'asc' } } }, - }); - } - - async updateConfig(dto: UpdateConfigDto) { - try { - return await this.prisma.$transaction(async (tx) => { - const { sanctionTiers, ...configFields } = dto; - - const config = await tx.reservationConfig.upsert({ - where: { id: CONFIG_ID }, - update: configFields, - create: { id: CONFIG_ID, ...configFields }, - }); - - if (sanctionTiers !== undefined) { - await tx.sanctionTier.deleteMany({ where: { configId: config.id } }); - - if (sanctionTiers.length > 0) { - await tx.sanctionTier.createMany({ - data: sanctionTiers.map((tier) => ({ ...tier, configId: config.id })), - }); - } - } - - return tx.reservationConfig.findUnique({ - where: { id: config.id }, - include: { sanctionTiers: { orderBy: { minPoints: 'asc' } } }, - }); - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`Config not found.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - throw e; - } - } - - async setUserRole(requesterId: number, targetId: number, role: string) { - if (requesterId === targetId) { - throw new ForbiddenException('You cannot change your own role.'); - } - try { - return await this.prisma.user.update({ - where: { id: targetId }, - data: { role: role as 'USER' | 'ADMIN' }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`User with id ${targetId} not found.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - throw e; - } - } -} diff --git a/apps/backend/src/admin/dto/set-role.dto.ts b/apps/backend/src/admin/dto/set-role.dto.ts deleted file mode 100644 index 023c36d..0000000 --- a/apps/backend/src/admin/dto/set-role.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Role } from '@prisma/client'; -import { IsEnum } from 'class-validator'; - -export class SetRoleDto { - @IsEnum(Role) - role: Role; -} diff --git a/apps/backend/src/admin/dto/update-config.dto.ts b/apps/backend/src/admin/dto/update-config.dto.ts deleted file mode 100644 index 8e5ba14..0000000 --- a/apps/backend/src/admin/dto/update-config.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNumber, IsOptional, IsPositive, Min, ValidateNested } from 'class-validator'; - -export class SanctionTierDto { - @IsInt() - @Min(0) - minPoints: number; - - @IsNumber() - @IsPositive() - userDailyHours: number; - - @IsNumber() - @IsPositive() - userWeeklyHours: number; - - @IsNumber() - @IsPositive() - bandDailyHours: number; - - @IsNumber() - @IsPositive() - bandWeeklyHours: number; -} - -export class UpdateConfigDto { - @IsNumber() - @IsPositive() - @IsOptional() - userDailyHours?: number; - - @IsNumber() - @IsPositive() - @IsOptional() - userWeeklyHours?: number; - - @IsNumber() - @IsPositive() - @IsOptional() - bandDailyHours?: number; - - @IsNumber() - @IsPositive() - @IsOptional() - bandWeeklyHours?: number; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => SanctionTierDto) - @IsOptional() - sanctionTiers?: SanctionTierDto[]; -} diff --git a/apps/backend/src/admin/entities/reservation-config.entity.ts b/apps/backend/src/admin/entities/reservation-config.entity.ts deleted file mode 100644 index 0709c9f..0000000 --- a/apps/backend/src/admin/entities/reservation-config.entity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNumber, IsOptional, IsPositive, Min, ValidateNested } from 'class-validator'; - -export class SanctionTier { - @IsInt() - @Min(0) - minPoints: number; - - @IsNumber() - @IsPositive() - userDailyHours: number; - - @IsNumber() - @IsPositive() - userWeeklyHours: number; - - @IsNumber() - @IsPositive() - bandDailyHours: number; - - @IsNumber() - @IsPositive() - bandWeeklyHours: number; -} - -export class ReservationConfig { - @IsNumber() - @IsPositive() - id: number; - - @IsNumber() - @IsPositive() - userDailyHours: number; - - @IsNumber() - @IsPositive() - userWeeklyHours: number; - - @IsNumber() - @IsPositive() - bandDailyHours: number; - - @IsNumber() - @IsPositive() - bandWeeklyHours: number; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => SanctionTier) - @IsOptional() - sanctionTiers: SanctionTier[]; -} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 3e73b82..22e246f 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 { BandsModule } from './bands/bands.module'; 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/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 7d0c355..22f2164 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { AuthSchProfile } from '@kir-dev/passport-authsch'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { ClubMembershipStatus, Role } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { User } from 'src/users/entities/user.entity'; @@ -12,10 +13,8 @@ export class AuthService { ) {} login(user: User): string { - return this.jwtService.sign(user, { - secret: process.env.JWT_SECRET, - expiresIn: '7 days', - }); + // Csak az azonosítót írjuk a tokenbe: a JwtStrategy minden kérésnél újratölti a felhasználót. + return this.jwtService.sign({ id: user.id }, { secret: process.env.JWT_SECRET, expiresIn: '7 days' }); } async findOrCreateUser(userProfile: AuthSchProfile): Promise { @@ -35,87 +34,68 @@ export class AuthService { } async syncClubMembership(user: User, userProfile: AuthSchProfile): Promise { + const markSynced = () => + this.prisma.user.update({ where: { id: user.id }, data: { clubMembershipUpdatedAt: new Date() } }); + // Skip if the user's PEK profile has not been updated since the last check if (user.clubMembershipUpdatedAt && userProfile.pek.updatedAt < user.clubMembershipUpdatedAt.valueOf() / 1000) { - return this.prisma.user.update({ where: { id: user.id }, data: { clubMembershipUpdatedAt: new Date() } }); + return markSynced(); } - // If the user is not a member of MMMK - if ( - !( - userProfile.pek.activeMemberAt.some((membership) => membership.groupName === process.env.PEK_GROUP_NAME) || - userProfile.pek.alumniMemberAt.some((membership) => membership.groupName === process.env.MMMK_GROUP_NAME) || - userProfile.pek.executiveAt.some((membership) => membership.groupName === process.env.MMMK_GROUP_NAME) - ) - ) { - // If the user's membership has been terminated, remove their club membership record - this.prisma.clubMembership.delete({ where: { userId: user.id } }); + const group = process.env.PEK_GROUP_NAME; + const isGroup = (membership: { groupName: string }) => membership.groupName === group; + const active = userProfile.pek.activeMemberAt.find(isGroup); + const isExecutive = userProfile.pek.executiveAt.some(isGroup); - return this.prisma.user.update({ where: { id: user.id }, data: { clubMembershipUpdatedAt: new Date() } }); + if (!active && !isExecutive && !userProfile.pek.alumniMemberAt.some(isGroup)) { + // Megszűnt tagság: a rekordot nem töröljük (kiosztott szankciók hivatkoznak rá), csak a jogokat vesszük el. + await this.prisma.clubMembership.updateMany({ + where: { userId: user.id }, + data: { titles: [], isGateKeeper: false, isLeadershipMember: false, hasRoomAccess: false }, + }); + return markSynced(); } - // Active member at MMMK - for (const membership of userProfile.pek.activeMemberAt) { - if (membership.groupName === process.env.PEK_GROUP_NAME) { - const clubMembership = await this.prisma.clubMembership.upsert({ - where: { userId: user.id }, - update: { - status: membership.titles.includes(process.env.PEK_NEWBIE_TITLE) ? 'NEWBIE' : 'ACTIVE', - titles: membership.titles.filter( - (title) => title !== process.env.PEK_NEWBIE_TITLE && title !== process.env.PEK_MEMBER_TITLE - ), - isGateKeeper: membership.titles.includes(process.env.PEK_GATEKEEPER_TITLE), - }, - create: { - user: { connect: { id: user.id } }, - status: membership.titles.includes(process.env.PEK_NEWBIE_TITLE) ? 'NEWBIE' : 'ACTIVE', - titles: membership.titles.filter( + // Aktív tag (vagy csak vezetőként szereplő): a jogok a PEK titulusokból jönnek; különben öregtag. + const titles = active?.titles ?? []; + const isLeader = this.hasLeadershipTitle(titles); + const data = + active || isExecutive + ? { + status: titles.includes(process.env.PEK_NEWBIE_TITLE) + ? ClubMembershipStatus.NEWBIE + : ClubMembershipStatus.ACTIVE, + titles: titles.filter( (title) => title !== process.env.PEK_NEWBIE_TITLE && title !== process.env.PEK_MEMBER_TITLE ), - isGateKeeper: membership.titles.includes( - process.env.PEK_GATEKEEPER_TITLE || process.env.PEK_ROOM_MANAGER_TITLE - ), - hasRoomAccess: false, - isLeadershipMember: false, - }, - }); + isGateKeeper: + titles.includes(process.env.PEK_GATEKEEPER_TITLE) || titles.includes(process.env.PEK_ROOM_MANAGER_TITLE), + isLeadershipMember: isLeader, + } + : { status: ClubMembershipStatus.SENIOR, titles: [], isGateKeeper: false, isLeadershipMember: false }; - return this.prisma.user.update({ - where: { id: user.id }, - data: { - clubMembership: { connect: { id: clubMembership.id } }, - clubMembershipUpdatedAt: new Date(), - }, - }); - } - } + await this.prisma.clubMembership.upsert({ + where: { userId: user.id }, + update: data, + create: { ...data, userId: user.id, hasRoomAccess: false }, + }); - // Senior member at MMMK - for (const membership of userProfile.pek.alumniMemberAt) { - if (membership.groupName === process.env.MMMK_GROUP_NAME) { - const clubMembership = await this.prisma.clubMembership.upsert({ - where: { userId: user.id }, - update: { - status: 'SENIOR', - isGateKeeper: false, - }, - create: { - user: { connect: { id: user.id } }, - status: 'SENIOR', - isGateKeeper: false, - hasRoomAccess: false, - isLeadershipMember: false, - }, - }); + return this.prisma.user.update({ + where: { id: user.id }, + data: { + clubMembershipUpdatedAt: new Date(), + // A körvezetőség automatikusan admin jogot kap. Visszavenni nem vesszük, mert a kézzel + // kiosztott adminokat nem tudnánk megkülönböztetni: a leváltás az admin panelen történik. + ...(isLeader ? { role: Role.ADMIN } : {}), + }, + }); + } - return this.prisma.user.update({ - where: { id: user.id }, - data: { - clubMembership: { connect: { id: clubMembership.id } }, - clubMembershipUpdatedAt: new Date(), - }, - }); - } - } + /** A körvezetőségi (admin jogú) PEK titulusok, a PEK_LEADERSHIP_TITLES változóval felülírhatóan. */ + private hasLeadershipTitle(titles: string[]): boolean { + const leadershipTitles = (process.env.PEK_LEADERSHIP_TITLES || 'körvezető,teremmester,ac') + .split(',') + .map((title) => title.trim().toLowerCase()); + return titles.some((title) => leadershipTitles.includes(title.trim().toLowerCase())); } } diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index b6f61e8..5fd465f 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -14,10 +14,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: User): Promise { + // A korábban kiadott tokenekben a teljes felhasználó szerepel, de abból is csak az id-t használjuk. + async validate(payload: { id: number }): Promise { const user = await this.prisma.user.findUnique({ where: { id: payload.id } }); if (!user) { - throw new UnauthorizedException('User not found'); + throw new UnauthorizedException('A felhasználó nem található, jelentkezz be újra.'); } return user; } diff --git a/apps/backend/src/auth/optional-jwt-auth.guard.ts b/apps/backend/src/auth/optional-jwt-auth.guard.ts index 28726ef..5b1493c 100644 --- a/apps/backend/src/auth/optional-jwt-auth.guard.ts +++ b/apps/backend/src/auth/optional-jwt-auth.guard.ts @@ -1,14 +1,10 @@ import { Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +/** Like the JWT guard, but lets anonymous (or invalid-token) requests through with no user. */ @Injectable() export class OptionalJwtAuthGuard extends AuthGuard('jwt') { - // Override handleRequest so it doesn't throw an error if no user is found - handleRequest(err: any, user: any) { - // If there is an error or no user, just return null (do not throw UnauthorizedException) - if (err || !user) { - return null; - } - return user; + handleRequest(_err: unknown, user: TUser): TUser | null { + return user || null; } } diff --git a/apps/backend/src/bands/bandsController.ts b/apps/backend/src/bands/bands.controller.ts similarity index 54% rename from apps/backend/src/bands/bandsController.ts rename to apps/backend/src/bands/bands.controller.ts index 00247c4..f82b53b 100644 --- a/apps/backend/src/bands/bandsController.ts +++ b/apps/backend/src/bands/bands.controller.ts @@ -1,3 +1,4 @@ +import { CurrentUser } from '@kir-dev/passport-authsch'; import { Body, Controller, @@ -8,106 +9,97 @@ import { ParseIntPipe, Patch, Post, - Req, 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 { OptionalJwtAuthGuard } from 'src/auth/optional-jwt-auth.guard'; import { RolesGuard } from 'src/auth/roles.guard'; +import { User } from 'src/users/entities/user.entity'; import { BandsService } from './bands.service'; import { CreateBandDto } from './dto/create-band.dto'; import { UpdateBandDto } from './dto/update-band.dto'; @Controller('bands') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) export class BandsController { constructor(private readonly bandsService: BandsService) {} @Post() - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) // Ha JWT-t használsz - create(@Body() createBandDto: CreateBandDto, @Req() req: any) { - return this.bandsService.create(createBandDto, req.user.id); + create(@Body() createBandDto: CreateBandDto, @CurrentUser() user: User) { + return this.bandsService.create(createBandDto, user.id); } @Get() - @UseGuards(OptionalJwtAuthGuard) - findAll(@Req() req: any) { - return this.bandsService.findAll(req.user); + findAll(@CurrentUser() user: User) { + return this.bandsService.findAll(user); } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.bandsService.findOne(id); + findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { + return this.bandsService.findOne(id, user); } @Patch(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt'), RolesGuard) - @Roles(Role.ADMIN) - update(@Param('id', ParseIntPipe) id: number, @Body() updateBandDto: UpdateBandDto) { + async update(@Param('id', ParseIntPipe) id: number, @Body() updateBandDto: UpdateBandDto, @CurrentUser() user: User) { + if (user.role !== Role.ADMIN) { + if (updateBandDto.isApproved !== undefined) { + throw new ForbiddenException('Zenekart csak admin hagyhat jóvá.'); + } + await this.assertMember(id, user, 'Csak a zenekar tagjai szerkeszthetik a zenekart.'); + } return this.bandsService.update(id, updateBandDto); } @Delete(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt'), RolesGuard) + @UseGuards(RolesGuard) @Roles(Role.ADMIN) remove(@Param('id', ParseIntPipe) id: number) { return this.bandsService.remove(id); } - @Get(':id/members') - findMembers(@Param('id', ParseIntPipe) id: number) { - return this.bandsService.findMembers(id); - } - @Post(':id/members/:userId') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) async addMember( @Param('id', ParseIntPipe) bandId: number, @Param('userId', ParseIntPipe) userId: number, - @Req() req: any + @CurrentUser() user: User ) { - if (req.user.role !== Role.ADMIN) { - // Requesting user must be an accepted member of the band - const isMember = await this.bandsService.isAcceptedMember(bandId, req.user.id); - if (!isMember) { - throw new ForbiddenException('Csak a zenekar tagjai hívhatnak meg másokat.'); - } + if (user.role !== Role.ADMIN) { + await this.assertMember(bandId, user, 'Csak a zenekar tagjai hívhatnak meg másokat.'); } return this.bandsService.addMember(bandId, userId); } @Delete(':id/members/:userId') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) removeMember( @Param('id', ParseIntPipe) bandId: number, @Param('userId', ParseIntPipe) userId: number, - @Req() req: any + @CurrentUser() user: User ) { - if (req.user.role !== Role.ADMIN && req.user.id !== userId) { + if (user.role !== Role.ADMIN && user.id !== userId) { throw new ForbiddenException('Csak magadat távolíthatod el.'); } return this.bandsService.removeMember(bandId, userId); } @Patch(':id/members/:userId') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) approveMember( @Param('id', ParseIntPipe) bandId: number, @Param('userId', ParseIntPipe) userId: number, - @Req() req: any + @CurrentUser() user: User ) { - if (req.user.role !== Role.ADMIN && req.user.id !== userId) { + if (user.role !== Role.ADMIN && user.id !== userId) { throw new ForbiddenException('Csak a saját meghívódat fogadhatod el.'); } return this.bandsService.approveMember(bandId, userId); } + + private async assertMember(bandId: number, user: User, message: string) { + if (!(await this.bandsService.isAcceptedMember(bandId, user.id))) { + throw new ForbiddenException(message); + } + } } diff --git a/apps/backend/src/bands/bandsModule.ts b/apps/backend/src/bands/bands.module.ts similarity index 54% rename from apps/backend/src/bands/bandsModule.ts rename to apps/backend/src/bands/bands.module.ts index 0171146..ab556d7 100644 --- a/apps/backend/src/bands/bandsModule.ts +++ b/apps/backend/src/bands/bands.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { ReservationsModule } from '../reservations/reservations.module'; +import { BandsController } from './bands.controller'; import { BandsService } from './bands.service'; -import { BandsController } from './bandsController'; @Module({ + imports: [ReservationsModule], controllers: [BandsController], providers: [BandsService], }) diff --git a/apps/backend/src/bands/bands.service.ts b/apps/backend/src/bands/bands.service.ts index da02329..3c29ca0 100644 --- a/apps/backend/src/bands/bands.service.ts +++ b/apps/backend/src/bands/bands.service.ts @@ -1,186 +1,123 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { BandMembership, BandMembershipStatus } from '@prisma/client'; +import { BandMembershipStatus, Prisma, Role } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; +import { ReservationNotificationsService } from 'src/reservations/reservation-notifications.service'; import { User } from 'src/users/entities/user.entity'; +import { publicUserSelect } from 'src/utils/public-user'; import { CreateBandDto } from './dto/create-band.dto'; import { UpdateBandDto } from './dto/update-band.dto'; -import { Band } from './entities/band.entity'; + +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** Elfogadott tagság vagy 7 napnál nem régebbi meghívó. */ +const liveMembership = (): Prisma.BandMembershipWhereInput => ({ + OR: [ + { status: BandMembershipStatus.ACCEPTED }, + { status: BandMembershipStatus.PENDING, createdAt: { gte: new Date(Date.now() - INVITE_TTL_MS) } }, + ], +}); + +const withMembers = (): Prisma.BandInclude => ({ + members: { + where: liveMembership(), + include: { user: { select: publicUserSelect } }, + }, +}); + +/** Az admin minden zenekart lát, más csak a jóváhagyottakat és azokat, amelyeknek tagja vagy meghívottja. */ +const visibleTo = (user: User): Prisma.BandWhereInput => + user.role === Role.ADMIN + ? {} + : { OR: [{ isApproved: true }, { members: { some: { userId: user.id, ...liveMembership() } } }] }; @Injectable() export class BandsService { - constructor(private readonly prisma: PrismaService) {} - - async create(createBandDto: CreateBandDto, userId?: number): Promise { - if (userId) { - return await this.prisma.band.create({ - data: { - ...createBandDto, - members: { - create: { - userId, - status: BandMembershipStatus.ACCEPTED, - }, - }, - }, - }); - } - return await this.prisma.band.create({ data: createBandDto }); + constructor( + private readonly prisma: PrismaService, + private readonly notifications: ReservationNotificationsService + ) {} + + create(createBandDto: CreateBandDto, userId: number) { + return this.prisma.band.create({ + data: { ...createBandDto, members: { create: { userId, status: BandMembershipStatus.ACCEPTED } } }, + }); } - async findAll(user?: User): Promise { - let where: any = { isApproved: true }; - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - - if (user?.role === 'ADMIN') { - where = {}; // Admins see all bands, approved or not - } else if (user?.id) { - // Normal users see approved bands OR bands where they are a member - where = { - OR: [ - { isApproved: true }, - { - members: { - some: { - userId: user.id, - OR: [ - { status: BandMembershipStatus.ACCEPTED }, - { status: BandMembershipStatus.PENDING, createdAt: { gte: sevenDaysAgo } }, - ], - }, - }, - }, - ], - }; - } - - const res = await this.prisma.band.findMany({ - where, - include: { - members: { - where: { - OR: [{ status: 'ACCEPTED' }, { status: 'PENDING', createdAt: { gte: sevenDaysAgo } }], - }, - include: { user: { select: { fullName: true } } }, - }, - }, - }); - return res; + findAll(user: User) { + return this.prisma.band.findMany({ where: visibleTo(user), include: withMembers() }); } - async findOne(id: number): Promise { - try { - const res = await this.prisma.band.findUniqueOrThrow({ where: { id } }); - return res; - } catch (error) { - throw new NotFoundException('No band found'); - } + findOne(id: number, user: User) { + return this.prisma.band.findFirstOrThrow({ where: { id, ...visibleTo(user) }, include: withMembers() }); } - async update(id: number, updateBandDto: UpdateBandDto): Promise { - return await this.prisma.band.update({ where: { id }, data: updateBandDto }); + update(id: number, updateBandDto: UpdateBandDto) { + return this.prisma.band.update({ where: { id }, data: updateBandDto }); } - async remove(id: number): Promise { - try { - const res = await this.prisma.band.delete({ where: { id } }); - return res; - } catch (error) { - throw new NotFoundException('No bands found'); - } + // A tagságokat és a még el nem kezdődött foglalásokat is töröljük; a múltbeliek bandId = null-lal megmaradnak. + async remove(id: number) { + const upcoming = { bandId: id, startTime: { gt: new Date() } }; + const [deleted, , , band] = await this.prisma.$transaction([ + this.prisma.reservation.findMany({ where: upcoming }), + this.prisma.reservation.deleteMany({ where: upcoming }), + this.prisma.bandMembership.deleteMany({ where: { bandId: id } }), + this.prisma.band.delete({ where: { id } }), + ]); + await this.notifications.notifyGatekeepers({ deleted, moved: [] }); + return band; } async isAcceptedMember(bandId: number, userId: number): Promise { const count = await this.prisma.bandMembership.count({ + where: { bandId, userId, status: BandMembershipStatus.ACCEPTED }, + }); + return count > 0; + } + + async addMember(bandId: number, userId: number) { + // Clean up expired pending invites before checking for existing + await this.prisma.bandMembership.deleteMany({ where: { bandId, userId, - status: BandMembershipStatus.ACCEPTED, + status: BandMembershipStatus.PENDING, + createdAt: { lt: new Date(Date.now() - INVITE_TTL_MS) }, }, }); - return count > 0; - } - async findMembers(id: number): Promise { - try { - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const bandmemberships = await this.prisma.bandMembership.findMany({ - where: { - bandId: id, - OR: [ - { status: BandMembershipStatus.ACCEPTED }, - { status: BandMembershipStatus.PENDING, createdAt: { gte: sevenDaysAgo } }, - ], - }, - include: { user: true }, - }); - return bandmemberships.map((membership) => membership.user); - } catch (error) { - throw new NotFoundException('No members found'); - } - } - - async addMember(bandId: number, userId: number): Promise { - try { - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - - // Clean up expired pending invites before checking for existing - await this.prisma.bandMembership.deleteMany({ - where: { - bandId, - userId, - status: BandMembershipStatus.PENDING, - createdAt: { lt: sevenDaysAgo }, - }, - }); - - const existing = await this.prisma.bandMembership.findFirst({ where: { bandId, userId } }); - if (existing) { - throw new ConflictException('User is already a member of this band'); - } - const res = await this.prisma.bandMembership.create({ - data: { band: { connect: { id: bandId } }, user: { connect: { id: userId } } }, - }); - return res; - } catch (error) { - if (error instanceof ConflictException) throw error; - throw new NotFoundException('Member could not be added'); + const existing = await this.prisma.bandMembership.findFirst({ where: { bandId, userId } }); + if (existing) { + throw new ConflictException('A felhasználó már tagja vagy meghívottja a zenekarnak.'); } + return this.prisma.bandMembership.create({ + data: { band: { connect: { id: bandId } }, user: { connect: { id: userId } } }, + }); } async removeMember(bandId: number, userId: number) { - try { - const res = await this.prisma.bandMembership.deleteMany({ where: { bandId, userId } }); - const members = await this.findMembers(bandId); - if (members.length === 0) { - await this.remove(bandId); - } - return res; - } catch (error) { - throw new NotFoundException('No member found'); + const res = await this.prisma.bandMembership.deleteMany({ where: { bandId, userId } }); + // Elfogadott tag nélkül a zenekar megszűnik; a függő meghívók a remove()-val együtt törlődnek. + if (!(await this.prisma.bandMembership.count({ where: { bandId, status: BandMembershipStatus.ACCEPTED } }))) { + await this.remove(bandId); } + return res; } async approveMember(bandId: number, userId: number) { - try { - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const res = await this.prisma.bandMembership.updateMany({ - where: { - bandId, - userId, - status: BandMembershipStatus.PENDING, - createdAt: { gte: sevenDaysAgo }, - }, - data: { status: BandMembershipStatus.ACCEPTED }, - }); - - if (res.count === 0) { - throw new NotFoundException('No valid invitation found'); - } - - return res; - } catch (error) { - throw new NotFoundException('No member found'); + const res = await this.prisma.bandMembership.updateMany({ + where: { + bandId, + userId, + status: BandMembershipStatus.PENDING, + createdAt: { gte: new Date(Date.now() - INVITE_TTL_MS) }, + }, + data: { status: BandMembershipStatus.ACCEPTED }, + }); + if (res.count === 0) { + throw new NotFoundException('Nincs érvényes meghívó ehhez a zenekarhoz.'); } + return res; } } diff --git a/apps/backend/src/bands/dto/update-band.dto.ts b/apps/backend/src/bands/dto/update-band.dto.ts index 0866d35..c68c07c 100644 --- a/apps/backend/src/bands/dto/update-band.dto.ts +++ b/apps/backend/src/bands/dto/update-band.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { PartialType } from '@nestjs/swagger'; import { IsBoolean, IsOptional } from 'class-validator'; import { CreateBandDto } from './create-band.dto'; diff --git a/apps/backend/src/bands/entities/band.entity.ts b/apps/backend/src/bands/entities/band.entity.ts index 1a3e26a..17f3fb7 100644 --- a/apps/backend/src/bands/entities/band.entity.ts +++ b/apps/backend/src/bands/entities/band.entity.ts @@ -1,4 +1,19 @@ -import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsEmail, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUrl, + MaxLength, +} from 'class-validator'; + +// Az űrlap üres mezőt üres stringként küld: ezt null-ra fordítjuk, így a mező törölhető. +const emptyToNull = Transform(({ value }) => (value === '' ? null : value)); export class Band { @IsNotEmpty() @@ -6,22 +21,31 @@ export class Band { @IsPositive() id: number; - @IsNotEmpty() + @IsString() + @IsNotEmpty({ message: 'A zenekar neve kötelező.' }) + @MaxLength(100, { message: 'A zenekar neve legfeljebb 100 karakter lehet.' }) name: string; @IsOptional() - @IsString() - email: string; + @emptyToNull + @IsEmail({}, { message: 'Érvénytelen e-mail-cím.' }) + @MaxLength(255, { message: 'Az e-mail-cím legfeljebb 255 karakter lehet.' }) + email?: string; @IsOptional() - @IsString() - webPage: string; + @emptyToNull + @IsUrl({}, { message: 'Érvénytelen weboldal-cím.' }) + @MaxLength(255, { message: 'A weboldal címe legfeljebb 255 karakter lehet.' }) + webPage?: string; @IsOptional() @IsString() - description: string; + @MaxLength(2000, { message: 'A leírás legfeljebb 2000 karakter lehet.' }) + description?: string; - @IsOptional() @IsArray() + @ArrayNotEmpty({ message: 'Legalább egy műfajt adj meg.' }) + @IsString({ each: true }) + @MaxLength(50, { each: true, message: 'Egy műfaj legfeljebb 50 karakter lehet.' }) genres: string[]; } diff --git a/apps/backend/src/comments/comments.controller.ts b/apps/backend/src/comments/comments.controller.ts index 7ebce3b..ab428f1 100644 --- a/apps/backend/src/comments/comments.controller.ts +++ b/apps/backend/src/comments/comments.controller.ts @@ -1,6 +1,10 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; +import { parseDateQuery } from 'src/utils/dates'; import { CommentsService } from './comments.service'; import { CreateCommentDto } from './dto/create-comment.dto'; @@ -12,31 +16,43 @@ export class CommentsController { @Post() @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) create(@Body() createCommentDto: CreateCommentDto) { return this.commentsService.create(createCommentDto); } @Get() - findAll(@Query('page', ParseIntPipe) page: number, @Query('page_size', ParseIntPipe) pageSize: number) { - return this.commentsService.findAll(page, pageSize); + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findAll( + @Query('page', ParseIntPipe) page: number, + @Query('page_size', ParseIntPipe) pageSize: number, + @Query('from') from?: string, + @Query('to') to?: string + ) { + return this.commentsService.findAll(page, pageSize, parseDateQuery(from), parseDateQuery(to)); } @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) findOne(@Param('id', ParseIntPipe) id: number) { return this.commentsService.findOne(id); } @Patch(':id') @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) update(@Param('id', ParseIntPipe) id: number, @Body() updateCommentDto: UpdateCommentDto) { return this.commentsService.update(id, updateCommentDto); } @Delete(':id') @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) remove(@Param('id', ParseIntPipe) id: number) { return this.commentsService.remove(id); } diff --git a/apps/backend/src/comments/comments.module.ts b/apps/backend/src/comments/comments.module.ts index e4f67c6..96afd37 100644 --- a/apps/backend/src/comments/comments.module.ts +++ b/apps/backend/src/comments/comments.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; +import { ReservationsModule } from '../reservations/reservations.module'; +import { SettingsModule } from '../settings/settings.module'; import { CommentsController } from './comments.controller'; import { CommentsService } from './comments.service'; @Module({ + imports: [SettingsModule, ReservationsModule], controllers: [CommentsController], providers: [CommentsService], }) diff --git a/apps/backend/src/comments/comments.service.ts b/apps/backend/src/comments/comments.service.ts index 8b2b156..26eed15 100644 --- a/apps/backend/src/comments/comments.service.ts +++ b/apps/backend/src/comments/comments.service.ts @@ -1,102 +1,80 @@ -import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Prisma, ReservationStatus } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { PaginationDto } from '../dto/pagination.dto'; +import { ReservationNotificationsService } from '../reservations/reservation-notifications.service'; +import { Cut, cutOverlapping } from '../reservations/reservation-rules'; +import { SettingsService } from '../settings/settings.service'; +import { serializableTransaction } from '../utils/serializable-transaction'; import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; import { Comment } from './entities/comment.entity'; @Injectable() export class CommentsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly settings: SettingsService, + private readonly notifications: ReservationNotificationsService + ) {} create(createCommentDto: CreateCommentDto) { - return this.prisma.comment.create({ - data: { - ...createCommentDto, - }, - }); + return this.saveAndFreeSlot((tx) => tx.comment.create({ data: createCommentDto })); } - findAll(page?: number, pageSize?: number): Promise> { + async findAll(page?: number, pageSize?: number, from?: Date, to?: Date): Promise> { const hasPagination = page !== -1 && pageSize !== -1; - const comments = this.prisma.comment.findMany({ - skip: hasPagination ? (page - 1) * pageSize : undefined, - take: hasPagination ? pageSize : undefined, - orderBy: { - startTime: 'asc', - }, - }); - - const count = this.prisma.reservation.count(); + // A [from, to) intervallumot átfedő felhívások kellenek, nem csak a benne kezdődők. + const where: Prisma.CommentWhereInput = { + endTime: from ? { gt: from } : undefined, + startTime: to ? { lt: to } : undefined, + }; + const [data, count] = await Promise.all([ + this.prisma.comment.findMany({ + where, + skip: hasPagination ? (page - 1) * pageSize : undefined, + take: hasPagination ? pageSize : undefined, + orderBy: { startTime: 'asc' }, + }), + this.prisma.comment.count({ where }), + ]); + return { data, count, page, limit: hasPagination ? Math.floor(count / pageSize) : 0 }; + } - return Promise.all([comments, count]) - .then(([comments, count]) => { - const limit = hasPagination ? Math.floor(count / pageSize) : 0; - return { - data: comments, - count, - page, - limit, - }; - }) - .catch(() => { - throw new InternalServerErrorException('An error occurred.'); - }); + findOne(id: number) { + return this.prisma.comment.findUniqueOrThrow({ where: { id } }); } - async findOne(id: number) { - try { - return await this.prisma.comment.findUniqueOrThrow({ - where: { - id, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This comment doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - } + update(id: number, updateCommentDto: UpdateCommentDto) { + return this.saveAndFreeSlot((tx) => tx.comment.update({ where: { id }, data: updateCommentDto })); } - async update(id: number, updateCommentDto: UpdateCommentDto) { - try { - return await this.prisma.comment.update({ - where: { - id, - }, - data: { - ...updateCommentDto, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This comment doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - } + remove(id: number) { + return this.prisma.comment.delete({ where: { id } }); } - async remove(id: number) { - try { - return await this.prisma.comment.delete({ - where: { - id, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This comment doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); + /** + * Saves the comment (rolled back if its range is empty); a non-reservable one frees its slot from every + * reservation that hasn't started, except admin bookings. Serializable like booking, so a concurrent reservation cannot slip in next to a blocking comment. + */ + private async saveAndFreeSlot(save: (tx: Prisma.TransactionClient) => Promise) { + const { comment, cut } = await serializableTransaction(this.prisma, async (tx) => { + const comment = await save(tx); + if (comment.endTime <= comment.startTime) { + throw new BadRequestException('A felhívás végének a kezdete után kell lennie.'); } - } + const cut: Cut = comment.isReservable + ? { deleted: [], moved: [] } + : await cutOverlapping( + tx, + { status: { not: ReservationStatus.ADMINMADE }, startTime: { gt: new Date() } }, + comment, + await this.settings.get(tx) + ); + return { comment, cut }; + }); + await this.notifications.notifyGatekeepers(cut); + return comment; } } diff --git a/apps/backend/src/comments/dto/update-comment.dto.ts b/apps/backend/src/comments/dto/update-comment.dto.ts index 6e54b3b..9a51778 100644 --- a/apps/backend/src/comments/dto/update-comment.dto.ts +++ b/apps/backend/src/comments/dto/update-comment.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { PartialType } from '@nestjs/swagger'; import { CreateCommentDto } from './create-comment.dto'; diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index f8f107f..3bfcba0 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,9 +1,17 @@ import { ValidationPipe } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { PrismaClientExceptionFilter } from 'nestjs-prisma'; import { AppModule } from './app.module'; +// Pin the process timezone so all server-side week/day calculations (reservation quotas, opened +// weeks, period boundaries) use the club's local time regardless of where the server is hosted. +// Without this, a UTC-hosted server computes week boundaries differently from the (local-time) +// frontend, which breaks the exact-match lookup of opened weeks. The club timezone is fixed, +// including on hosts that explicitly set TZ=UTC. +process.env.TZ = 'Europe/Budapest'; + async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors({ @@ -23,18 +31,22 @@ async function bootstrap() { }) ); + // Prisma hibák HTTP-re fordítása + app.useGlobalFilters( + new PrismaClientExceptionFilter(app.get(HttpAdapterHost).httpAdapter, { + P2000: { statusCode: 400, errorMessage: 'Túl hosszú érték.' }, + P2002: { statusCode: 409, errorMessage: 'Ilyen adattal már létezik bejegyzés.' }, + P2003: { statusCode: 400, errorMessage: 'A hivatkozott elem nem létezik.' }, + P2025: { statusCode: 404, errorMessage: 'A keresett elem nem található.' }, + P2034: { statusCode: 409, errorMessage: 'Ütköző módosítás történt, próbáld újra.' }, + }) + ); + //OpenAPI const config = new DocumentBuilder().setTitle('MMMK Web API').setVersion('1.0').addBearerAuth().build(); const documentFactory = () => SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, documentFactory); - //CORS - app.enableCors({ - origin: [process.env.FRONTEND_URL], - methods: 'GET,PUT,PATCH,POST,DELETE', - credentials: true, - }); - await app.listen(process.env.PORT || 3030); } diff --git a/apps/backend/src/memberships/dto/create-membership.dto.ts b/apps/backend/src/memberships/dto/create-membership.dto.ts deleted file mode 100644 index 4e58e0e..0000000 --- a/apps/backend/src/memberships/dto/create-membership.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OmitType } from '@nestjs/swagger'; - -import { ClubMembership } from '../entities/membership.entity'; - -export class CreateMembershipDto extends OmitType(ClubMembership, ['id']) {} diff --git a/apps/backend/src/memberships/dto/update-membership.dto.ts b/apps/backend/src/memberships/dto/update-membership.dto.ts index aee2dab..1b4c186 100644 --- a/apps/backend/src/memberships/dto/update-membership.dto.ts +++ b/apps/backend/src/memberships/dto/update-membership.dto.ts @@ -1,5 +1,5 @@ -import { PartialType } from '@nestjs/swagger'; +import { OmitType, PartialType } from '@nestjs/swagger'; -import { CreateMembershipDto } from './create-membership.dto'; +import { ClubMembership } from '../entities/membership.entity'; -export class UpdateMembershipDto extends PartialType(CreateMembershipDto) {} +export class UpdateMembershipDto extends PartialType(OmitType(ClubMembership, ['id', 'userId'] as const)) {} diff --git a/apps/backend/src/memberships/entities/membership.entity.ts b/apps/backend/src/memberships/entities/membership.entity.ts index 4c67a9b..d7e5dc9 100644 --- a/apps/backend/src/memberships/entities/membership.entity.ts +++ b/apps/backend/src/memberships/entities/membership.entity.ts @@ -1,5 +1,5 @@ import { ClubMembershipStatus } from '@prisma/client'; -import { IsArray, IsBoolean, IsEnum, IsNumber, IsPositive } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsNumber, IsPositive, IsString } from 'class-validator'; export class ClubMembership { @IsNumber() @@ -14,6 +14,7 @@ export class ClubMembership { status: ClubMembershipStatus; @IsArray() + @IsString({ each: true }) titles: string[]; @IsBoolean() diff --git a/apps/backend/src/memberships/memberships.controller.ts b/apps/backend/src/memberships/memberships.controller.ts index 046e1be..01f6b5a 100644 --- a/apps/backend/src/memberships/memberships.controller.ts +++ b/apps/backend/src/memberships/memberships.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; import { Role } from '@prisma/client'; @@ -8,33 +8,22 @@ import { RolesGuard } from 'src/auth/roles.guard'; import { UpdateMembershipDto } from './dto/update-membership.dto'; import { MembershipsService } from './memberships.service'; +// A tagsági rekordokat a bejelentkezéskori PEK szinkron hozza létre; törlés nincs (szankciók hivatkoznak rájuk). @Controller('memberships') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) export class MembershipsController { constructor(private readonly membershipsService: MembershipsService) {} @Get() - async findAll() { + findAll() { return this.membershipsService.findAll(); } - @Get(':id') - async findOne(@Param('id', ParseIntPipe) id: number) { - return this.membershipsService.findOne(id); - } - @Patch(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt'), RolesGuard) + @UseGuards(RolesGuard) @Roles(Role.ADMIN) - async update(@Param('id', ParseIntPipe) id: number, @Body() updateMembershipDto: UpdateMembershipDto) { + update(@Param('id', ParseIntPipe) id: number, @Body() updateMembershipDto: UpdateMembershipDto) { return this.membershipsService.update(id, updateMembershipDto); } - - @Delete(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt'), RolesGuard) - @Roles(Role.ADMIN) - async remove(@Param('id', ParseIntPipe) id: number) { - return this.membershipsService.remove(id); - } } diff --git a/apps/backend/src/memberships/memberships.service.ts b/apps/backend/src/memberships/memberships.service.ts index 0416dba..544a9cc 100644 --- a/apps/backend/src/memberships/memberships.service.ts +++ b/apps/backend/src/memberships/memberships.service.ts @@ -1,5 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from 'nestjs-prisma'; import { UpdateMembershipDto } from './dto/update-membership.dto'; @@ -8,41 +7,11 @@ import { UpdateMembershipDto } from './dto/update-membership.dto'; export class MembershipsService { constructor(private readonly prisma: PrismaService) {} - async findAll() { + findAll() { return this.prisma.clubMembership.findMany(); } - async findOne(id: number) { - const membership = await this.prisma.clubMembership.findUnique({ - where: { id }, - }); - if (!membership) { - throw new NotFoundException('Membership with id ${id} not found'); - } - return membership; - } - - async update(id: number, updateMembershipDto: UpdateMembershipDto) { - try { - return await this.prisma.clubMembership.update({ - where: { - id, - }, - data: updateMembershipDto, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`Membership with id ${id} not found`); - } - } - throw e; - } - } - - async remove(id: number) { - return this.prisma.clubMembership.delete({ - where: { id }, - }); + update(id: number, updateMembershipDto: UpdateMembershipDto) { + return this.prisma.clubMembership.update({ where: { id }, data: updateMembershipDto }); } } diff --git a/apps/backend/src/opened-weeks/dto/update-opened-week.dto.ts b/apps/backend/src/opened-weeks/dto/update-opened-week.dto.ts new file mode 100644 index 0000000..d0ec60a --- /dev/null +++ b/apps/backend/src/opened-weeks/dto/update-opened-week.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsDateString, IsNotEmpty } from 'class-validator'; + +export class UpdateOpenedWeekDto { + @IsNotEmpty() + @IsDateString() + monday: string; + + @IsNotEmpty() + @IsBoolean() + isOpen: boolean; +} diff --git a/apps/backend/src/opened-weeks/opened-weeks.controller.ts b/apps/backend/src/opened-weeks/opened-weeks.controller.ts new file mode 100644 index 0000000..9ae1f88 --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; + +import { UpdateOpenedWeekDto } from './dto/update-opened-week.dto'; +import { OpenedWeeksService } from './opened-weeks.service'; + +@Controller('opened-weeks') +export class OpenedWeeksController { + constructor(private readonly openedWeeksService: OpenedWeeksService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findAll() { + return this.openedWeeksService.findAll(); + } + + @Put() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + upsert(@Body() dto: UpdateOpenedWeekDto) { + return this.openedWeeksService.upsert(dto); + } +} diff --git a/apps/backend/src/opened-weeks/opened-weeks.module.ts b/apps/backend/src/opened-weeks/opened-weeks.module.ts new file mode 100644 index 0000000..a21a341 --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { OpenedWeeksController } from './opened-weeks.controller'; +import { OpenedWeeksService } from './opened-weeks.service'; + +@Module({ + providers: [OpenedWeeksService], + controllers: [OpenedWeeksController], +}) +export class OpenedWeeksModule {} diff --git a/apps/backend/src/opened-weeks/opened-weeks.service.ts b/apps/backend/src/opened-weeks/opened-weeks.service.ts new file mode 100644 index 0000000..413256e --- /dev/null +++ b/apps/backend/src/opened-weeks/opened-weeks.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { mondayOf } from '../utils/dates'; +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) { + // Same canonical key (local Monday midnight) that the reservation validation looks up. + const monday = mondayOf(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..52c852b --- /dev/null +++ b/apps/backend/src/periods/periods.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { startOfDay } from '../utils/dates'; +import { CreatePeriodDto } from './dto/create-period.dto'; +import { UpdatePeriodDto } from './dto/update-period.dto'; + +/** Periods are whole calendar days: both dates are stored as local midnight and endDate's day is bookable. */ +function calendarDays(startDate: string | Date, endDate: string | Date) { + const days = { startDate: startOfDay(new Date(startDate)), endDate: startOfDay(new Date(endDate)) }; + if (days.startDate > days.endDate) { + throw new BadRequestException('A kezdő dátum nem lehet későbbi a befejező dátumnál.'); + } + return days; +} + +@Injectable() +export class PeriodsService { + constructor(private prisma: PrismaService) {} + + async create(dto: CreatePeriodDto) { + return this.prisma.period.create({ data: { ...dto, ...calendarDays(dto.startDate, dto.endDate) } }); + } + + async findAll() { + return this.prisma.period.findMany({ + orderBy: { startDate: 'asc' }, + }); + } + + async findOne(id: number) { + return this.prisma.period.findUniqueOrThrow({ + where: { id }, + }); + } + + async update(id: number, dto: UpdatePeriodDto) { + const existing = await this.prisma.period.findUniqueOrThrow({ where: { id } }); + const days = calendarDays(dto.startDate ?? existing.startDate, dto.endDate ?? existing.endDate); + return this.prisma.period.update({ where: { id }, data: { ...dto, ...days } }); + } + + async remove(id: number) { + return this.prisma.period.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/posts/dto/create-post.dto.ts b/apps/backend/src/posts/dto/create-post.dto.ts index e7b72f6..40242d6 100644 --- a/apps/backend/src/posts/dto/create-post.dto.ts +++ b/apps/backend/src/posts/dto/create-post.dto.ts @@ -2,4 +2,4 @@ import { OmitType } from '@nestjs/swagger'; import { Post } from '../entities/post.entity'; -export class CreatePostDto extends OmitType(Post, ['id', 'createdAt']) {} +export class CreatePostDto extends OmitType(Post, ['id', 'createdAt', 'authorId']) {} diff --git a/apps/backend/src/posts/posts.controller.ts b/apps/backend/src/posts/posts.controller.ts index 84eeef1..3b847aa 100644 --- a/apps/backend/src/posts/posts.controller.ts +++ b/apps/backend/src/posts/posts.controller.ts @@ -1,4 +1,10 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common'; +import { CurrentUser } from '@kir-dev/passport-authsch'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Role, User } from '@prisma/client'; +import { Roles } from 'src/auth/decorators/Roles.decorator'; +import { RolesGuard } from 'src/auth/roles.guard'; import { CreatePostDto } from './dto/create-post.dto'; import { UpdatePostDto } from './dto/update-post.dto'; @@ -9,8 +15,11 @@ export class PostsController { constructor(private readonly postsService: PostsService) {} @Post() - create(@Body() createPostDto: CreatePostDto) { - return this.postsService.create(createPostDto); + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) + create(@Body() createPostDto: CreatePostDto, @CurrentUser() user: User) { + return this.postsService.create(createPostDto, user.id); } @Get() @@ -24,16 +33,25 @@ export class PostsController { } @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) update(@Param('id', ParseIntPipe) id: number, @Body() updatePostDto: UpdatePostDto) { return this.postsService.update(id, updatePostDto); } @Patch(':id/pin') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) togglePin(@Param('id', ParseIntPipe) id: number) { return this.postsService.togglePin(id); } @Delete(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles(Role.ADMIN) remove(@Param('id', ParseIntPipe) id: number) { return this.postsService.remove(id); } diff --git a/apps/backend/src/posts/posts.service.ts b/apps/backend/src/posts/posts.service.ts index fa27bb1..cdd3b13 100644 --- a/apps/backend/src/posts/posts.service.ts +++ b/apps/backend/src/posts/posts.service.ts @@ -1,5 +1,4 @@ -import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from 'nestjs-prisma'; import { PaginationDto } from 'src/dto/pagination.dto'; @@ -11,106 +10,37 @@ import { Post } from './entities/post.entity'; export class PostsService { constructor(private readonly prisma: PrismaService) {} - create(createPostDto: CreatePostDto) { - return this.prisma.post.create({ - data: { - ...createPostDto, - }, - }); + create(createPostDto: CreatePostDto, authorId: number) { + return this.prisma.post.create({ data: { ...createPostDto, authorId } }); } async findAll(page?: number, pageSize?: number): Promise> { const hasPagination = page !== -1 && pageSize !== -1; - const posts = this.prisma.post.findMany({ - skip: hasPagination ? (page - 1) * pageSize : undefined, - take: hasPagination ? pageSize : undefined, - orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }], - }); - - const count = this.prisma.post.count(); - - try { - const [postsa, counta] = await Promise.all([posts, count]); - const limit = hasPagination ? Math.floor(counta / pageSize) : 0; - return { - data: postsa, - count: counta, - page, - limit, - }; - } catch { - throw new InternalServerErrorException('An error occurred.'); - } + const [data, count] = await Promise.all([ + this.prisma.post.findMany({ + skip: hasPagination ? (page - 1) * pageSize : undefined, + take: hasPagination ? pageSize : undefined, + orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }], + }), + this.prisma.post.count(), + ]); + return { data, count, page, limit: hasPagination ? Math.floor(count / pageSize) : 0 }; } - async findOne(id: number) { - try { - return await this.prisma.post.findUniqueOrThrow({ - where: { - id, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This post doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - } + findOne(id: number) { + return this.prisma.post.findUniqueOrThrow({ where: { id } }); } - async update(id: number, updatePostDto: UpdatePostDto) { - try { - return await this.prisma.post.update({ - where: { - id, - }, - data: { - ...updatePostDto, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This post doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - } + update(id: number, updatePostDto: UpdatePostDto) { + return this.prisma.post.update({ where: { id }, data: updatePostDto }); } remove(id: number) { - try { - return this.prisma.post.delete({ - where: { - id, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This post doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - } + return this.prisma.post.delete({ where: { id } }); } async togglePin(id: number) { - try { - const post = await this.prisma.post.findUniqueOrThrow({ where: { id } }); - return await this.prisma.post.update({ - where: { id }, - data: { isPinned: !post.isPinned }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2025') { - throw new NotFoundException(`This post doesn't exist.`); - } - throw new InternalServerErrorException('An error occurred.'); - } - } + const post = await this.prisma.post.findUniqueOrThrow({ where: { id } }); + return this.prisma.post.update({ where: { id }, data: { isPinned: !post.isPinned } }); } } diff --git a/apps/backend/src/reservations/admission-stats.spec.ts b/apps/backend/src/reservations/admission-stats.spec.ts new file mode 100644 index 0000000..faec29d --- /dev/null +++ b/apps/backend/src/reservations/admission-stats.spec.ts @@ -0,0 +1,58 @@ +import { Role, User } from '@prisma/client'; +import { PrismaService } from 'nestjs-prisma'; + +import { SettingsService } from '../settings/settings.service'; +import { ReservationNotificationsService } from './reservation-notifications.service'; +import { ReservationsService } from './reservations.service'; + +describe('ReservationsService.admissionStats', () => { + const prisma = { + reservation: { findMany: jest.fn() }, + clubMembership: { findUnique: jest.fn(), findMany: jest.fn() }, + }; + const service = new ReservationsService( + prisma as unknown as PrismaService, + {} as SettingsService, + {} as ReservationNotificationsService + ); + const user = (id: number, fullName: string) => ({ id, fullName, email: `${id}@example.test`, phone: null }); + const slot = (gateKeeperId: number, hours: number) => ({ + gateKeeperId, + startTime: new Date('2026-01-05T10:00:00Z'), + endTime: new Date(new Date('2026-01-05T10:00:00Z').getTime() + hours * 3_600_000), + }); + + beforeEach(() => { + jest.resetAllMocks(); + prisma.clubMembership.findUnique.mockResolvedValue({ isGateKeeper: true }); + }); + + it('sums finished admissions in hours and lists idle gatekeepers with 0 hours', async () => { + prisma.reservation.findMany.mockResolvedValue([slot(1, 1.5), slot(2, 1), slot(2, 2)]); + prisma.clubMembership.findMany.mockResolvedValue([ + { id: 3, user: user(3, 'Cili') }, + { id: 1, user: user(1, 'Anna') }, + { id: 2, user: user(2, 'Béla') }, + { id: 4, user: user(4, 'Ádám') }, + ]); + const stats = await service.admissionStats({ id: 1, role: Role.USER } as User); + expect(stats.map((row) => [row.gateKeeperId, row.hours])).toEqual([ + [2, 3], + [1, 1.5], + [4, 0], + [3, 0], + ]); + // Only finished admissions count; non-gatekeepers are listed only if they have such admissions. + const finished = { endTime: { lte: expect.any(Date) } }; + expect(prisma.reservation.findMany.mock.calls[0][0].where).toEqual({ ...finished, gateKeeperId: { not: null } }); + expect(prisma.clubMembership.findMany.mock.calls[0][0].where).toEqual({ + OR: [{ isGateKeeper: true }, { gateKeepingRecords: { some: finished } }], + }); + }); + + it('is forbidden for users who are neither gatekeepers nor admins', async () => { + prisma.clubMembership.findUnique.mockResolvedValue({ isGateKeeper: false }); + await expect(service.admissionStats({ id: 5, role: Role.USER } as User)).rejects.toThrow('statisztikát'); + expect(prisma.reservation.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/reservations/dto/create-reservation.dto.ts b/apps/backend/src/reservations/dto/create-reservation.dto.ts index 199c762..ecfac24 100644 --- a/apps/backend/src/reservations/dto/create-reservation.dto.ts +++ b/apps/backend/src/reservations/dto/create-reservation.dto.ts @@ -1,5 +1,13 @@ -import { OmitType } from '@nestjs/swagger'; +import { PickType } from '@nestjs/swagger'; import { Reservation } from '../entities/reservation.entity'; -export class CreateReservationDto extends OmitType(Reservation, ['id']) {} +/** The server decides the status; only admins may send ADMINMADE to book on someone's behalf. */ +export class CreateReservationDto extends PickType(Reservation, [ + 'userId', + 'bandId', + 'startTime', + 'endTime', + 'needToBeLetIn', + 'status', +] as const) {} diff --git a/apps/backend/src/reservations/dto/simple-reservation.dto.ts b/apps/backend/src/reservations/dto/simple-reservation.dto.ts deleted file mode 100644 index 7ddcf96..0000000 --- a/apps/backend/src/reservations/dto/simple-reservation.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OmitType } from '@nestjs/swagger'; - -import { Reservation } from '../entities/reservation.entity'; - -export class SimpleReservationDto extends OmitType(Reservation, ['id', 'userId']) {} diff --git a/apps/backend/src/reservations/dto/update-admission.dto.ts b/apps/backend/src/reservations/dto/update-admission.dto.ts new file mode 100644 index 0000000..d90bcab --- /dev/null +++ b/apps/backend/src/reservations/dto/update-admission.dto.ts @@ -0,0 +1,8 @@ +import { GateKeeperPriority } from '@prisma/client'; +import { IsEnum, ValidateIf } from 'class-validator'; + +export class UpdateAdmissionDto { + @ValidateIf((_object, value) => value !== null) + @IsEnum(GateKeeperPriority) + priority: GateKeeperPriority | null; +} diff --git a/apps/backend/src/reservations/dto/update-reservation.dto.ts b/apps/backend/src/reservations/dto/update-reservation.dto.ts index 9f3246b..698f873 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 { OmitType, PartialType } from '@nestjs/swagger'; -import { SimpleReservationDto } from './simple-reservation.dto'; +import { CreateReservationDto } from './create-reservation.dto'; -export class UpdateReservationDto extends PartialType(SimpleReservationDto) { - @IsNumber() - @IsOptional() - gateKeeperId?: number; -} +export class UpdateReservationDto extends PartialType(OmitType(CreateReservationDto, ['status'] as const)) {} diff --git a/apps/backend/src/reservations/entities/reservation.entity.ts b/apps/backend/src/reservations/entities/reservation.entity.ts index 6c29282..bbe60f7 100644 --- a/apps/backend/src/reservations/entities/reservation.entity.ts +++ b/apps/backend/src/reservations/entities/reservation.entity.ts @@ -1,16 +1,17 @@ -import { ReservationStatus } from '@prisma/client'; -import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; +import { GateKeeperPriority, ReservationStatus } from '@prisma/client'; +import { IsBoolean, IsDate, IsEnum, IsInt, IsNotEmpty, IsOptional } from 'class-validator'; export class Reservation { - @IsNotEmpty() - @IsNumber() + @IsInt() id: number; - @IsNumber() - userId: number; + @IsOptional() + @IsInt() + userId?: number | null; - @IsNumber() - bandId: number; + @IsOptional() + @IsInt() + bandId?: number | null; @IsNotEmpty() @IsDate() @@ -20,11 +21,20 @@ export class Reservation { @IsDate() endTime: Date; - @IsNumber() @IsOptional() - gateKeeperId: number; + @IsInt() + gateKeeperId?: number | null; - @IsNotEmpty() + @IsOptional() + @IsEnum(GateKeeperPriority) + gateKeeperPriority?: GateKeeperPriority | null; + + @IsOptional() @IsEnum(ReservationStatus) - status: ReservationStatus; + status?: ReservationStatus; + + // Optional: the DB column defaults to false (see schema.prisma), so clients may omit it. + @IsOptional() + @IsBoolean() + needToBeLetIn?: boolean; } diff --git a/apps/backend/src/reservations/reservation-notifications.service.spec.ts b/apps/backend/src/reservations/reservation-notifications.service.spec.ts new file mode 100644 index 0000000..78f92eb --- /dev/null +++ b/apps/backend/src/reservations/reservation-notifications.service.spec.ts @@ -0,0 +1,144 @@ +import { Logger } from '@nestjs/common'; +import { Reservation, User } from '@prisma/client'; +import { PrismaService } from 'nestjs-prisma'; + +import { ReservationNotificationsService } from './reservation-notifications.service'; + +describe('ReservationNotificationsService', () => { + const originalUrl = process.env.KIR_MAIL_URL; + const originalToken = process.env.KIR_MAIL_TOKEN; + const slot = { startTime: new Date('2030-01-07T10:00:00Z'), endTime: new Date('2030-01-07T11:00:00Z') }; + const booking = { id: 1, gateKeeperId: 10, ...slot } as Reservation; + const prisma = { + reservation: { findUniqueOrThrow: jest.fn() }, + clubMembership: { findUniqueOrThrow: jest.fn() }, + user: { findUniqueOrThrow: jest.fn() }, + bandMembership: { findMany: jest.fn() }, + }; + const originalFetch = globalThis.fetch; + let fetchMock: jest.Mock; + let service: ReservationNotificationsService; + + beforeEach(() => { + jest.resetAllMocks(); + process.env.KIR_MAIL_URL = 'https://mail.example.test'; + process.env.KIR_MAIL_TOKEN = 'test-only'; + fetchMock = jest.fn().mockResolvedValue({ ok: true } as Response); + globalThis.fetch = fetchMock; + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + service = new ReservationNotificationsService(prisma as unknown as PrismaService); + prisma.reservation.findUniqueOrThrow.mockResolvedValue({ + ...slot, + user: { email: 'owner@example.test' }, + band: null, + gateKeeper: { user: { id: 2, fullName: 'Gatekeeper', email: 'keeper@example.test', phone: '+36301234567' } }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + globalThis.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.KIR_MAIL_URL; + else process.env.KIR_MAIL_URL = originalUrl; + if (originalToken === undefined) delete process.env.KIR_MAIL_TOKEN; + else process.env.KIR_MAIL_TOKEN = originalToken; + }); + + const messages = () => fetchMock.mock.calls.map(([, request]) => JSON.parse(request.body)); + + it('sends the gatekeeper name, e-mail and phone to the reservation owner', async () => { + await service.notifyGatekeeperChange(booking, null); + expect(messages()).toEqual([expect.objectContaining({ to: 'owner@example.test', replyTo: 'keeper@example.test' })]); + expect(messages()[0].html).toMatch(/Gatekeeper.*keeper@example\.test.*\+36301234567/); + }); + + it('tells the gatekeeper and an owner who did not make the change about deletions and moves', async () => { + prisma.clubMembership.findUniqueOrThrow.mockResolvedValue({ user: { email: 'keeper@example.test' } }); + prisma.user.findUniqueOrThrow.mockResolvedValue({ id: 1, email: 'owner@example.test' }); + const own = { ...booking, userId: 1 }; + const unassigned = { ...own, gateKeeperId: null }; + await service.notifyGatekeepers({ deleted: [own], moved: [unassigned], actorId: 3 }); + expect(prisma.clubMembership.findUniqueOrThrow.mock.calls[0][0].where).toEqual({ id: 10 }); + expect(messages().map((message) => [message.to, message.html])).toEqual([ + ['keeper@example.test', expect.stringContaining('Az általad beengedésre vállalt foglalás (')], + ['owner@example.test', expect.stringMatching(/^A foglalásod \(.*törölve/)], + ['owner@example.test', expect.stringContaining('Új időpont: 2030')], + ]); + }); + + it('mails accepted band members, but no owner when one of them made the change', async () => { + const bandBooking = { ...booking, gateKeeperId: null, userId: null, bandId: 4 }; + prisma.bandMembership.findMany.mockResolvedValue([ + { user: { id: 5, email: 'a@example.test' } }, + { user: { id: 6, email: 'b@example.test' } }, + ]); + await service.notifyGatekeepers({ deleted: [bandBooking], moved: [], actorId: 3 }); + expect(prisma.bandMembership.findMany.mock.calls[0][0].where).toEqual({ bandId: 4, status: 'ACCEPTED' }); + expect(messages().map((message) => message.to)).toEqual(['a@example.test', 'b@example.test']); + await service.notifyGatekeepers({ deleted: [bandBooking], moved: [], actorId: 6 }); + expect(messages()).toHaveLength(2); + }); + + it('uses accepted band memberships and deduplicates recipients', async () => { + prisma.reservation.findUniqueOrThrow.mockResolvedValue({ + ...slot, + user: null, + gateKeeper: null, + band: { + members: [ + { user: { email: 'member@example.test' } }, + { user: { email: 'member@example.test' } }, + { user: null }, + ], + }, + }); + // The gatekeeper withdrew themselves (actorId 2), so only the booker side is mailed. + await service.notifyGatekeeperChange(booking, { id: 2, email: 'keeper@example.test' } as User, 2); + expect(prisma.reservation.findUniqueOrThrow.mock.calls[0][0].include.band.include.members.where).toEqual({ + status: 'ACCEPTED', + }); + expect(messages()).toHaveLength(1); + expect(messages()[0]).toMatchObject({ to: 'member@example.test', html: expect.stringContaining('visszamondta') }); + }); + + it('tells a gatekeeper removed by an admin, but not one who withdrew', async () => { + prisma.reservation.findUniqueOrThrow.mockResolvedValue({ + ...slot, + user: { email: 'owner@example.test' }, + band: null, + }); + const removed = { id: 2, email: 'keeper@example.test' } as User; + await service.notifyGatekeeperChange(booking, removed, 2); + expect(messages().map((message) => message.to)).toEqual(['owner@example.test']); + await service.notifyGatekeeperChange(booking, removed, 3); + expect(messages()[2]).toMatchObject({ to: 'keeper@example.test', html: expect.stringContaining('levett') }); + }); + + it('notifies the overridden secondary gatekeeper', async () => { + await service.notifyGatekeeperChange(booking, { id: 3, email: 'previous@example.test' } as User); + expect( + messages() + .map((message) => message.to) + .sort() + ).toEqual(['owner@example.test', 'previous@example.test']); + }); + + it('escapes user-provided names before sending HTML', async () => { + prisma.reservation.findUniqueOrThrow.mockResolvedValue({ + ...slot, + user: { email: 'owner@example.test' }, + band: null, + gateKeeper: { user: { id: 2, fullName: '', email: 'keeper@example.test' } }, + }); + await service.notifyGatekeeperChange(booking, null); + expect(messages()[0].html).toContain('<script>"hello"</script>'); + expect(messages()[0].html).not.toContain('