diff --git a/src/modules/event/dto/create-event.dto.ts b/src/modules/event/dto/create-event.dto.ts new file mode 100644 index 00000000..29ba9a43 --- /dev/null +++ b/src/modules/event/dto/create-event.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsNotEmpty, IsOptional, IsDateString, IsInt, Min } from 'class-validator'; + +export class CreateEventDto { + @IsString() + @IsNotEmpty() + title: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsNotEmpty() + location: string; + + @IsDateString() + startDate: string; + + @IsDateString() + endDate: string; + + @IsInt() + @Min(0) + capacity: number; +} diff --git a/src/modules/event/dto/update-event.dto.ts b/src/modules/event/dto/update-event.dto.ts new file mode 100644 index 00000000..304f9500 --- /dev/null +++ b/src/modules/event/dto/update-event.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateEventDto } from './create-event.dto'; + +export class UpdateEventDto extends PartialType(CreateEventDto) {} diff --git a/src/modules/event/event.controller.spec.ts b/src/modules/event/event.controller.spec.ts new file mode 100644 index 00000000..1fab6d8c --- /dev/null +++ b/src/modules/event/event.controller.spec.ts @@ -0,0 +1,60 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventController } from './event.controller'; +import { EventService } from './event.service'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { User } from '../../user/user.entity'; + +describe('EventController', () => { + let controller: EventController; + let service: EventService; + const organizer: User = { id: 'org-1', email: 'org@test.com', passwordHash: '', role: 'organizer', createdAt: new Date() }; + + const mockService = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EventController], + providers: [ + { provide: EventService, useValue: mockService }, + ], + }).compile(); + controller = module.get(EventController); + service = module.get(EventService); + jest.clearAllMocks(); + }); + + it('should create event', async () => { + const dto: CreateEventDto = { title: 't', location: 'l', startDate: '2025-10-01', endDate: '2025-10-02', capacity: 10 } as any; + mockService.create.mockResolvedValue({ ...dto, organizer }); + const req = { user: organizer }; + const result = await controller.create(dto, req); + expect(result.organizer).toBe(organizer); + }); + + it('should list events', async () => { + mockService.findAll.mockResolvedValue([{ id: '1', organizer }]); + const req = { user: organizer }; + const result = await controller.findAll(req); + expect(result[0].organizer).toBe(organizer); + }); + + it('should get event by id', async () => { + mockService.findOne.mockResolvedValue({ id: '1', organizer }); + const req = { user: organizer }; + const result = await controller.findOne('1', req); + expect(result.organizer).toBe(organizer); + }); + + it('should update event', async () => { + mockService.update.mockResolvedValue({ id: '1', organizer, title: 'new' }); + const req = { user: organizer }; + const result = await controller.update('1', { title: 'new' } as UpdateEventDto, req); + expect(result.title).toBe('new'); + }); +}); diff --git a/src/modules/event/event.controller.ts b/src/modules/event/event.controller.ts new file mode 100644 index 00000000..b11611ad --- /dev/null +++ b/src/modules/event/event.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Post, Get, Patch, Param, Body, Request, UseGuards } from '@nestjs/common'; +import { EventService } from './event.service'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { User } from '../../user/user.entity'; + +@UseGuards(JwtAuthGuard) +@Controller('events') +export class EventController { + constructor(private readonly eventService: EventService) {} + + @Post() + async create(@Body() createEventDto: CreateEventDto, @Request() req: any) { + // Organizer is the authenticated user + return this.eventService.create(createEventDto, req.user as User); + } + + @Get() + async findAll(@Request() req: any) { + return this.eventService.findAll(req.user as User); + } + + @Get(':id') + async findOne(@Param('id') id: string, @Request() req: any) { + return this.eventService.findOne(id, req.user as User); + } + + @Patch(':id') + async update(@Param('id') id: string, @Body() updateEventDto: UpdateEventDto, @Request() req: any) { + return this.eventService.update(id, updateEventDto, req.user as User); + } +} diff --git a/src/modules/event/event.entity.ts b/src/modules/event/event.entity.ts new file mode 100644 index 00000000..09c0775c --- /dev/null +++ b/src/modules/event/event.entity.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { User } from '../../user/user.entity'; + +@Entity() +export class Event { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column() + location: string; + + @Column({ type: 'timestamp' }) + startDate: Date; + + @Column({ type: 'timestamp' }) + endDate: Date; + + @Column({ type: 'int' }) + capacity: number; + + @ManyToOne(() => User, user => user.id, { eager: true }) + organizer: User; +} diff --git a/src/modules/event/event.module.ts b/src/modules/event/event.module.ts new file mode 100644 index 00000000..9d3f37a9 --- /dev/null +++ b/src/modules/event/event.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Event } from './event.entity'; +import { EventService } from './event.service'; +import { EventController } from './event.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Event])], + controllers: [EventController], + providers: [EventService], + exports: [EventService], +}) +export class EventModule {} diff --git a/src/modules/event/event.service.spec.ts b/src/modules/event/event.service.spec.ts new file mode 100644 index 00000000..a6d9a5e6 --- /dev/null +++ b/src/modules/event/event.service.spec.ts @@ -0,0 +1,87 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventService } from './event.service'; +import { Event } from './event.entity'; +import { User } from '../../user/user.entity'; +import { Repository } from 'typeorm'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; + +describe('EventService', () => { + let service: EventService; + let repo: Repository; + const mockRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }; + const organizer: User = { id: 'org-1', email: 'org@test.com', passwordHash: '', role: 'organizer', createdAt: new Date() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventService, + { provide: getRepositoryToken(Event), useValue: mockRepo }, + ], + }).compile(); + service = module.get(EventService); + repo = module.get>(getRepositoryToken(Event)); + jest.clearAllMocks(); + }); + + it('should create event with valid data', async () => { + const dto = { title: 't', location: 'l', startDate: '2025-10-01', endDate: '2025-10-02', capacity: 10 }; + mockRepo.create.mockReturnValue({ ...dto, organizer }); + mockRepo.save.mockResolvedValue({ ...dto, organizer, id: '1' }); + const result = await service.create(dto as any, organizer); + expect(result.organizer).toBe(organizer); + expect(mockRepo.save).toHaveBeenCalled(); + }); + + it('should throw if startDate >= endDate', async () => { + const dto = { title: 't', location: 'l', startDate: '2025-10-02', endDate: '2025-10-01', capacity: 10 }; + await expect(service.create(dto as any, organizer)).rejects.toThrow(ForbiddenException); + }); + + it('should throw if capacity < 0', async () => { + const dto = { title: 't', location: 'l', startDate: '2025-10-01', endDate: '2025-10-02', capacity: -1 }; + await expect(service.create(dto as any, organizer)).rejects.toThrow(ForbiddenException); + }); + + it('should find all events for organizer', async () => { + mockRepo.find.mockResolvedValue([{ id: '1', organizer }]); + const result = await service.findAll(organizer); + expect(result[0].organizer).toBe(organizer); + }); + + it('should find one event for organizer', async () => { + mockRepo.findOne.mockResolvedValue({ id: '1', organizer }); + const result = await service.findOne('1', organizer); + expect(result.organizer).toBe(organizer); + }); + + it('should throw NotFound if event not found', async () => { + mockRepo.findOne.mockResolvedValue(undefined); + await expect(service.findOne('1', organizer)).rejects.toThrow(NotFoundException); + }); + + it('should update event with valid data', async () => { + const event = { id: '1', organizer, startDate: new Date('2025-10-01'), endDate: new Date('2025-10-02'), capacity: 10 }; + mockRepo.findOne.mockResolvedValue(event); + mockRepo.save.mockResolvedValue({ ...event, title: 'new' }); + const result = await service.update('1', { title: 'new' }, organizer); + expect(result.title).toBe('new'); + }); + + it('should throw if update has invalid dates', async () => { + const event = { id: '1', organizer, startDate: new Date('2025-10-01'), endDate: new Date('2025-10-02'), capacity: 10 }; + mockRepo.findOne.mockResolvedValue(event); + await expect(service.update('1', { startDate: '2025-10-03', endDate: '2025-10-02' }, organizer)).rejects.toThrow(ForbiddenException); + }); + + it('should throw if update has negative capacity', async () => { + const event = { id: '1', organizer, startDate: new Date('2025-10-01'), endDate: new Date('2025-10-02'), capacity: 10 }; + mockRepo.findOne.mockResolvedValue(event); + await expect(service.update('1', { capacity: -5 }, organizer)).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/src/modules/event/event.service.ts b/src/modules/event/event.service.ts new file mode 100644 index 00000000..e5594d62 --- /dev/null +++ b/src/modules/event/event.service.ts @@ -0,0 +1,50 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Event } from './event.entity'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { User } from '../../user/user.entity'; + +@Injectable() +export class EventService { + constructor( + @InjectRepository(Event) + private eventRepository: Repository, + ) {} + + async create(createEventDto: CreateEventDto, organizer: User): Promise { + if (new Date(createEventDto.startDate) >= new Date(createEventDto.endDate)) { + throw new ForbiddenException('Start date must be before end date'); + } + if (createEventDto.capacity < 0) { + throw new ForbiddenException('Capacity must be non-negative'); + } + const event = this.eventRepository.create({ ...createEventDto, organizer }); + return this.eventRepository.save(event); + } + + async findAll(organizer: User): Promise { + return this.eventRepository.find({ where: { organizer } }); + } + + async findOne(id: string, organizer: User): Promise { + const event = await this.eventRepository.findOne({ where: { id, organizer } }); + if (!event) throw new NotFoundException('Event not found'); + return event; + } + + async update(id: string, updateEventDto: UpdateEventDto, organizer: User): Promise { + const event = await this.findOne(id, organizer); + if (updateEventDto.startDate && updateEventDto.endDate) { + if (new Date(updateEventDto.startDate) >= new Date(updateEventDto.endDate)) { + throw new ForbiddenException('Start date must be before end date'); + } + } + if (updateEventDto.capacity !== undefined && updateEventDto.capacity < 0) { + throw new ForbiddenException('Capacity must be non-negative'); + } + Object.assign(event, updateEventDto); + return this.eventRepository.save(event); + } +}