diff --git a/prisma/migrations/20250515092742_adding_user_type/migration.sql b/prisma/migrations/20250515092742_adding_user_type/migration.sql new file mode 100644 index 0000000..3f02eb4 --- /dev/null +++ b/prisma/migrations/20250515092742_adding_user_type/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('PLANNER', 'SPEAKER'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'SPEAKER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2560de0..526e90e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,11 @@ enum TalkLevel { ADVANCED } +enum UserType { + PLANNER + SPEAKER +} + model Room { id String @id @default(cuid()) name String @@ -67,6 +72,7 @@ model User { id String @id @default(cuid()) email String @unique password String + type UserType @default(SPEAKER) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/adapters/api/controller/room.controller.ts b/src/adapters/api/controller/room.controller.ts index 8316ebf..78af165 100644 --- a/src/adapters/api/controller/room.controller.ts +++ b/src/adapters/api/controller/room.controller.ts @@ -17,6 +17,8 @@ import { ApiOperation, ApiUnauthorizedResponse, } from '@nestjs/swagger'; +import { Roles } from '../decorator/roles.decorator'; +import { UserType } from '../../../core/domain/type/UserType'; @Controller('/rooms') export class RoomController { @@ -63,6 +65,7 @@ export class RoomController { } @UseGuards(JwtAuthGuard) + @Roles(UserType.PLANNER) @Post() @ApiOperation({ summary: 'Create a new room' }) @ApiCreatedResponse({ diff --git a/src/adapters/api/controller/talk.controller.ts b/src/adapters/api/controller/talk.controller.ts index 3a31bf8..906a6e9 100644 --- a/src/adapters/api/controller/talk.controller.ts +++ b/src/adapters/api/controller/talk.controller.ts @@ -32,6 +32,8 @@ import { GetAllTalksByStatusUseCase } from '../../../core/usecases/get-all-talks import { TalkStatus } from '../../../core/domain/type/TalkStatus'; import { GetAllTalksWithRoomDetailResponse } from '../response/get-all-talks-with-room-detail.response'; import { GetAllTalksWithRoomDetailMapper } from '../mapper/get-all-talks-with-room-detail.mapper'; +import { UserType } from '../../../core/domain/type/UserType'; +import { Roles } from '../decorator/roles.decorator'; @Controller('/talks') export class TalkController { @@ -68,6 +70,7 @@ export class TalkController { @UseGuards(JwtAuthGuard) @Post() + @Roles(UserType.PLANNER, UserType.SPEAKER) @ApiOperation({ summary: 'Create a new talk' }) @ApiCreatedResponse({ description: 'Talk successfully created', @@ -99,6 +102,7 @@ export class TalkController { } @UseGuards(JwtAuthGuard) + @Roles(UserType.PLANNER) @Post('/:talkId/approve-or-reject') @ApiOperation({ summary: 'Accept or reject a talk' }) @ApiNoContentResponse({ diff --git a/src/adapters/api/decorator/roles.decorator.ts b/src/adapters/api/decorator/roles.decorator.ts new file mode 100644 index 0000000..bbf9e99 --- /dev/null +++ b/src/adapters/api/decorator/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/adapters/api/guards/roles.guard.ts b/src/adapters/api/guards/roles.guard.ts new file mode 100644 index 0000000..b81334f --- /dev/null +++ b/src/adapters/api/guards/roles.guard.ts @@ -0,0 +1,47 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { UserType } from '../../../core/domain/type/UserType'; +import { ROLES_KEY } from '../decorator/roles.decorator'; + +type AuthenticatedUser = { + id: string; + email: string; + type: UserType; +}; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context + .switchToHttp() + .getRequest(); + const user = request.user; + + if (!user) { + throw new UnauthorizedException('User not authenticated'); + } + + if (!requiredRoles.includes(user.type)) { + throw new UnauthorizedException('Insufficient permissions'); + } + + return true; + } +} diff --git a/src/adapters/in-memory/in-memory-user.repository.ts b/src/adapters/in-memory/in-memory-user.repository.ts index 74a32d9..66bbe4e 100644 --- a/src/adapters/in-memory/in-memory-user.repository.ts +++ b/src/adapters/in-memory/in-memory-user.repository.ts @@ -6,11 +6,12 @@ import { Injectable } from '@nestjs/common'; export class InMemoryUserRepository implements UserRepository { private users: Map = new Map(); - create(data: Pick): User { + create(data: Pick): User { const user = new User( crypto.randomUUID(), data.email, data.password, + data.type, new Date(), new Date(), ); diff --git a/src/adapters/prisma/mapper/prisma-user.mapper.ts b/src/adapters/prisma/mapper/prisma-user.mapper.ts index dbd0900..5fe1d04 100644 --- a/src/adapters/prisma/mapper/prisma-user.mapper.ts +++ b/src/adapters/prisma/mapper/prisma-user.mapper.ts @@ -1,8 +1,10 @@ import { EntityMapper } from '../../../core/base/entity-mapper'; import { User } from '../../../core/domain/model/User'; -import { User as UserEntity } from '@prisma/client'; +import { User as UserEntity, $Enums } from '@prisma/client'; import { Injectable } from '@nestjs/common'; +import { UserType } from '../../../core/domain/type/UserType'; + @Injectable() export class PrismaUserMapper implements EntityMapper { fromDomain(model: User): UserEntity { @@ -10,6 +12,7 @@ export class PrismaUserMapper implements EntityMapper { id: model.id, email: model.email, password: model.password, + type: model.type, updatedAt: model.updatedAt, createdAt: model.createdAt, }; @@ -20,8 +23,20 @@ export class PrismaUserMapper implements EntityMapper { id: entity.id, email: entity.email, password: entity.password, + type: this.mapUserTypeToDomain(entity.type), updatedAt: entity.updatedAt, createdAt: entity.createdAt, }; } + + private mapUserTypeToDomain(type: $Enums.UserType): UserType { + switch (type) { + case 'PLANNER': + return UserType.PLANNER; + case 'SPEAKER': + return UserType.SPEAKER; + default: + throw new Error('Invalid user type'); + } + } } diff --git a/src/adapters/prisma/prisma-user.repository.ts b/src/adapters/prisma/prisma-user.repository.ts index 07ab9d0..c6d60c5 100644 --- a/src/adapters/prisma/prisma-user.repository.ts +++ b/src/adapters/prisma/prisma-user.repository.ts @@ -40,9 +40,10 @@ export class PrismaUserRepository implements UserRepository { } async update(id: string, user: User): Promise { + const entity = this.mapper.fromDomain(user); const updatedEntity = await this.prisma.user.update({ where: { id }, - data: user, + data: entity, }); if (!updatedEntity) { return null; diff --git a/src/core/domain/model/User.ts b/src/core/domain/model/User.ts index 8ff4468..f25eeaf 100644 --- a/src/core/domain/model/User.ts +++ b/src/core/domain/model/User.ts @@ -1,8 +1,10 @@ import { DomainModel } from '../../base/domain-model'; +import { UserType } from '../type/UserType'; export class User extends DomainModel { email: string; password: string; + type: UserType; updatedAt: Date; createdAt: Date; @@ -10,12 +12,14 @@ export class User extends DomainModel { id: string, email: string, password: string, + type: UserType, updatedAt?: Date, createdAt?: Date, ) { super(id); this.email = email; this.password = password; + this.type = type; this.updatedAt = updatedAt || new Date(); this.createdAt = createdAt || new Date(); } diff --git a/src/core/domain/type/UserType.ts b/src/core/domain/type/UserType.ts new file mode 100644 index 0000000..470da80 --- /dev/null +++ b/src/core/domain/type/UserType.ts @@ -0,0 +1,4 @@ +export enum UserType { + PLANNER = 'PLANNER', + SPEAKER = 'SPEAKER', +} diff --git a/src/core/usecases/__test__/create-user.use-case.spec.ts b/src/core/usecases/__test__/create-user.use-case.spec.ts index 6fac74c..9e764e7 100644 --- a/src/core/usecases/__test__/create-user.use-case.spec.ts +++ b/src/core/usecases/__test__/create-user.use-case.spec.ts @@ -34,6 +34,7 @@ describe('CreateUserUseCase', () => { email: 'john.doe@example.com', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment password: expect.any(String), + type: 'SPEAKER', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment createdAt: expect.any(Date), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/src/core/usecases/create-user.use-case.ts b/src/core/usecases/create-user.use-case.ts index cac4f5b..71804f7 100644 --- a/src/core/usecases/create-user.use-case.ts +++ b/src/core/usecases/create-user.use-case.ts @@ -4,6 +4,7 @@ import { WrongEmailFormatError } from '../domain/error/WrongEmailFormatError'; import { WrongPasswordFormatError } from '../domain/error/WrongPasswordFormatError'; import { User } from '../domain/model/User'; import { UserRepository } from '../domain/repository/user.repository'; +import { UserType } from '../domain/type/UserType'; import * as bcrypt from 'bcryptjs'; export type CreateUserCommand = { @@ -36,7 +37,12 @@ export class CreateUserUseCase implements UseCase { } const hashedPassword = await bcrypt.hash(password, 10); - const user = new User(this.generateId(), email, hashedPassword); + const user = new User( + this.generateId(), + email, + hashedPassword, + UserType.SPEAKER, + ); await this.userRepository.create(user); return user;