From f483590a7a0d9a2d3a763bc79795c40a2bb73f3e Mon Sep 17 00:00:00 2001 From: legonzaur Date: Fri, 30 May 2025 15:53:13 +0200 Subject: [PATCH] wip --- package-lock.json | 40 ++++++++ package.json | 2 + src/app.module.ts | 2 +- src/main.ts | 3 + src/polls/dto/answer.dto.ts | 15 +++ src/polls/dto/choice.dto.ts | 12 +++ src/polls/dto/poll.dto.ts | 36 +++++++ src/polls/dto/question.dto.ts | 19 ++++ src/polls/dto/questionOptions.dto.ts | 0 src/polls/entities/answer.entity.ts | 2 +- src/polls/entities/inviteCode.entity.ts | 2 +- src/polls/entities/poll.entity.ts | 8 +- src/polls/entities/question.entity.ts | 25 ++++- ...onOptions.ts => questionOptions.entity.ts} | 2 +- src/polls/polls.controller.ts | 96 +++++++++++++++++++ src/polls/polls.module.ts | 7 +- src/polls/polls.service.spec.ts | 18 ++++ src/polls/polls.service.ts | 50 ++++++++++ src/polls/questions.controller.ts | 52 ++++++++++ src/polls/questions.service.ts | 20 ++++ 20 files changed, 401 insertions(+), 10 deletions(-) create mode 100644 src/polls/dto/answer.dto.ts create mode 100644 src/polls/dto/choice.dto.ts create mode 100644 src/polls/dto/poll.dto.ts create mode 100644 src/polls/dto/question.dto.ts create mode 100644 src/polls/dto/questionOptions.dto.ts rename src/polls/entities/{questionOptions.ts => questionOptions.entity.ts} (85%) create mode 100644 src/polls/polls.controller.ts create mode 100644 src/polls/polls.service.spec.ts create mode 100644 src/polls/polls.service.ts create mode 100644 src/polls/questions.controller.ts create mode 100644 src/polls/questions.service.ts diff --git a/package-lock.json b/package-lock.json index 39d6f5e..85e99c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "pg": "^8.16.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -3562,6 +3564,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.1.tgz", + "integrity": "sha512-9gG6ogYcoI2mCMLdcO0NYI0AYrbxIjv0MDmy/5Ywo6CpWWrqYayc+mmgxRsCgtcGJm9BSbXkMsmxGah1iGHAAQ==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -5508,6 +5516,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -8657,6 +8682,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.8.tgz", + "integrity": "sha512-f1KakiQJa9tdc7w1phC2ST+DyxWimy9c3g3yeF+84QtEanJr2K77wAmBPP22riU05xldniHsvXuflnLZ4oysqA==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11918,6 +11949,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 75b60f4..5346681 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "pg": "^8.16.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/src/app.module.ts b/src/app.module.ts index 594617e..ceeacdd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,7 +9,7 @@ import { Choice } from './polls/entities/choice.entity'; import { InviteCode } from './polls/entities/inviteCode.entity'; import { Poll } from './polls/entities/poll.entity'; import { Question } from './polls/entities/question.entity'; -import { QuestionOption } from './polls/entities/questionOptions'; +import { QuestionOption } from './polls/entities/questionOptions.entity'; import { User } from './polls/entities/user.entity'; void ConfigModule.forRoot({ diff --git a/src/main.ts b/src/main.ts index 19f0bdc..43de1c2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,13 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalPipes(new ValidationPipe()); + const config = new DocumentBuilder() .setTitle('Vote au jugement majoritaire') .setDescription('API description') diff --git a/src/polls/dto/answer.dto.ts b/src/polls/dto/answer.dto.ts new file mode 100644 index 0000000..fb0d42d --- /dev/null +++ b/src/polls/dto/answer.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNumber, IsString } from 'class-validator'; + +export class AnswerCreateDto { + @Expose() + @IsNumber() + @ApiProperty() + questionId: number; + + @Expose() + @IsString() + @ApiProperty() + choice: string; +} diff --git a/src/polls/dto/choice.dto.ts b/src/polls/dto/choice.dto.ts new file mode 100644 index 0000000..a6f791f --- /dev/null +++ b/src/polls/dto/choice.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class ChoiceReadDto { + @Expose() + @ApiProperty() + value: string; + + @Expose() + @ApiProperty() + order: string; +} diff --git a/src/polls/dto/poll.dto.ts b/src/polls/dto/poll.dto.ts new file mode 100644 index 0000000..2252dd1 --- /dev/null +++ b/src/polls/dto/poll.dto.ts @@ -0,0 +1,36 @@ +import { Expose } from 'class-transformer'; +import { QuestionUpdateDto } from './question.dto'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class PollUpdateDTO { + @Expose() + @ApiProperty() + name: string; + + @Expose() + @ApiProperty() + description: string; + + @Expose() + @ApiProperty() + questions: QuestionUpdateDto[]; + + @Expose() + @IsUUID() + @ApiProperty() + publicCode: string; +} + +export class PollReadDTO extends PollUpdateDTO { + @Expose() + @ApiProperty() + published: boolean; +} + +export class PollReadAdminDTO extends PollReadDTO { + @Expose() + @IsUUID() + @ApiProperty() + editCode: string; +} diff --git a/src/polls/dto/question.dto.ts b/src/polls/dto/question.dto.ts new file mode 100644 index 0000000..aa90217 --- /dev/null +++ b/src/polls/dto/question.dto.ts @@ -0,0 +1,19 @@ +import { Expose } from 'class-transformer'; +import { ChoiceReadDto } from './choice.dto'; + +export class QuestionUpdateDto { + @Expose() + id: number; + + @Expose() + value: string; + + @Expose() + mandatory: boolean; + + @Expose() + order: number; + + @Expose() + choices: ChoiceReadDto[]; +} diff --git a/src/polls/dto/questionOptions.dto.ts b/src/polls/dto/questionOptions.dto.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/polls/entities/answer.entity.ts b/src/polls/entities/answer.entity.ts index b24f516..ccabf41 100644 --- a/src/polls/entities/answer.entity.ts +++ b/src/polls/entities/answer.entity.ts @@ -7,7 +7,7 @@ import { } from 'typeorm'; import { Question } from './question.entity'; import { User } from './user.entity'; -import { QuestionOption } from './questionOptions'; +import { QuestionOption } from './questionOptions.entity'; import { Choice } from './choice.entity'; @Entity() diff --git a/src/polls/entities/inviteCode.entity.ts b/src/polls/entities/inviteCode.entity.ts index 2011536..1f9d170 100644 --- a/src/polls/entities/inviteCode.entity.ts +++ b/src/polls/entities/inviteCode.entity.ts @@ -18,7 +18,7 @@ export class InviteCode { description: string; @Column({ nullable: true, default: null }) - max_usages: number; + maxUsages: number; @Column() @Generated('uuid') diff --git a/src/polls/entities/poll.entity.ts b/src/polls/entities/poll.entity.ts index 8a05485..3837ac6 100644 --- a/src/polls/entities/poll.entity.ts +++ b/src/polls/entities/poll.entity.ts @@ -15,12 +15,16 @@ export class Poll { @PrimaryGeneratedColumn({}) id: number; - @Column() + @Column({ default: 'Sondage' }) name: string; - @Column() + @Column({ nullable: true }) description: string; + @Column() + @Generated('uuid') + publicCode: string; + @Column() @Generated('uuid') editCode: string; diff --git a/src/polls/entities/question.entity.ts b/src/polls/entities/question.entity.ts index 8687a74..0ee9c3e 100644 --- a/src/polls/entities/question.entity.ts +++ b/src/polls/entities/question.entity.ts @@ -4,12 +4,16 @@ import { PrimaryGeneratedColumn, ManyToOne, OneToMany, + Unique, + ManyToMany, + JoinTable, } from 'typeorm'; import { Poll } from './poll.entity'; import { Answer } from './answer.entity'; -import { QuestionOption } from './questionOptions'; +import { Choice } from './choice.entity'; @Entity() +@Unique(['poll', 'order']) export class Question { @PrimaryGeneratedColumn({}) id: number; @@ -26,8 +30,23 @@ export class Question { @ManyToOne(() => Poll, (poll: Poll) => poll.questions) poll: Poll; - @OneToMany(() => QuestionOption, (questionOption) => questionOption.question) - options: QuestionOption[]; + // @OneToMany(() => QuestionOption, (questionOption) => questionOption.question) + // options: QuestionOption[]; + + @ManyToMany(() => Choice, { eager: true }) + @JoinTable({ + name: 'question_options', + joinColumn: { + name: 'question', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'choice', + referencedColumnName: 'id', + }, + synchronize: false, + }) + choices: Choice[]; @OneToMany(() => Answer, (answer) => answer.question) answers: Answer[]; diff --git a/src/polls/entities/questionOptions.ts b/src/polls/entities/questionOptions.entity.ts similarity index 85% rename from src/polls/entities/questionOptions.ts rename to src/polls/entities/questionOptions.entity.ts index 9c265ee..16e2250 100644 --- a/src/polls/entities/questionOptions.ts +++ b/src/polls/entities/questionOptions.entity.ts @@ -11,6 +11,6 @@ export class QuestionOption { @ManyToOne(() => Choice) choice: Choice; - @ManyToOne(() => Question, (question) => question.options) + @ManyToOne(() => Question) question: Question; } diff --git a/src/polls/polls.controller.ts b/src/polls/polls.controller.ts new file mode 100644 index 0000000..8ba3ac2 --- /dev/null +++ b/src/polls/polls.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Get, + HttpException, + HttpStatus, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PollsService } from './polls.service'; +import { PollReadAdminDTO, PollReadDTO, PollUpdateDTO } from './dto/poll.dto'; +import { plainToInstance } from 'class-transformer'; + +@ApiTags('polls') +@Controller('polls') +export class PollController { + constructor(private readonly pollService: PollsService) {} + + @Post() + @ApiOperation({ summary: 'Create poll' }) + @ApiResponse({ + status: 201, + description: 'Poll successfully created.', + type: PollReadAdminDTO, + }) + async create(): Promise { + const poll = await this.pollService.create(); + return plainToInstance(PollReadAdminDTO, poll); + } + + @Post(':publicCode/:editCode') + @ApiOperation({ summary: 'Update a poll' }) + @ApiResponse({ + status: 201, + description: 'Poll successfully created.', + type: PollReadAdminDTO, + }) + async update( + @Param('publicCode', new ParseUUIDPipe()) publicCode: string, + @Param('editCode', new ParseUUIDPipe()) editCode: string, + @Body() pollUpdate: PollUpdateDTO, + ): Promise { + const poll = await this.pollService.readAdmin(publicCode, editCode); + if (!poll) { + throw new HttpException( + 'Poll not found or invalid editCode', + HttpStatus.NOT_FOUND, + ); + } + await this.pollService.update(poll, pollUpdate); + return plainToInstance(PollReadAdminDTO, poll); + } + + @Get(':publicCode') + @ApiOperation({ summary: 'Get public information about a poll' }) + @ApiResponse({ + status: 200, + description: 'Found', + type: PollReadDTO, + }) + async get( + @Param('publicCode', new ParseUUIDPipe()) publicCode: string, + ): Promise { + const poll = await this.pollService.read(publicCode); + if (!poll) { + throw new HttpException('Poll not found', HttpStatus.NOT_FOUND); + } + if (!poll?.published) { + throw new HttpException('Poll not yet published', HttpStatus.FORBIDDEN); + } + return plainToInstance(PollReadDTO, poll); + } + + @Get(':publicCode/:editCode') + @ApiOperation({ summary: 'Get admin information about a poll' }) + @ApiResponse({ + status: 200, + description: 'Found', + type: PollReadAdminDTO, + }) + async getAdmin( + @Param('publicCode', new ParseUUIDPipe()) publicCode: string, + @Param('editCode', new ParseUUIDPipe()) editCode: string, + ): Promise { + const poll = await this.pollService.readAdmin(publicCode, editCode); + if (!poll) { + throw new HttpException( + 'Poll not found or invalid editCode', + HttpStatus.NOT_FOUND, + ); + } + return plainToInstance(PollReadAdminDTO, poll); + } +} diff --git a/src/polls/polls.module.ts b/src/polls/polls.module.ts index f63ebff..c2317f0 100644 --- a/src/polls/polls.module.ts +++ b/src/polls/polls.module.ts @@ -1,8 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Poll } from './entities/poll.entity'; +import { PollsService } from './polls.service'; +import { PollController } from './polls.controller'; +import { InviteCode } from './entities/inviteCode.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Poll])], + imports: [TypeOrmModule.forFeature([Poll, InviteCode])], + controllers: [PollController], + providers: [PollsService], }) export class PollsModule {} diff --git a/src/polls/polls.service.spec.ts b/src/polls/polls.service.spec.ts new file mode 100644 index 0000000..a4f1324 --- /dev/null +++ b/src/polls/polls.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PollsService } from './polls.service'; + +describe('PollsService', () => { + let service: PollsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PollsService], + }).compile(); + + service = module.get(PollsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/polls/polls.service.ts b/src/polls/polls.service.ts new file mode 100644 index 0000000..393944b --- /dev/null +++ b/src/polls/polls.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Poll } from './entities/poll.entity'; +import { Repository } from 'typeorm'; +import { PollUpdateDTO } from './dto/poll.dto'; +import { InviteCode } from './entities/inviteCode.entity'; + +@Injectable() +export class PollsService { + constructor( + @InjectRepository(Poll) private pollRepository: Repository, + @InjectRepository(InviteCode) + private inviteRepository: Repository, + ) {} + + async create() { + const poll = this.pollRepository.create(); + await this.pollRepository.save(poll); + return poll; + } + + async update(poll: Poll, pollUpdate: PollUpdateDTO) { + if (poll.published) { + throw Error('This poll is closed !'); + } + await this.pollRepository.update(poll, pollUpdate); + } + + async read(pollId: string) { + const poll = await this.pollRepository.findOneBy({ publicCode: pollId }); + return poll; + } + + async readAdmin(pollId: string, editCode: string) { + const poll = await this.pollRepository.findOneBy({ + publicCode: pollId, + editCode: editCode, + }); + return poll; + } + + async hasAccess(poll: Poll, accessCode: string) { + return await this.inviteRepository.exists({ + where: { + poll: { id: poll.id }, + value: accessCode, + }, + }); + } +} diff --git a/src/polls/questions.controller.ts b/src/polls/questions.controller.ts new file mode 100644 index 0000000..e5c1006 --- /dev/null +++ b/src/polls/questions.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + HttpException, + HttpStatus, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PollsService } from './polls.service'; +import { PollReadAdminDTO } from './dto/poll.dto'; +import { QuestionsService } from './questions.service'; +import { AnswerCreateDto } from './dto/answer.dto'; + +@ApiTags('questions') +@Controller('questions') +export class QuestionsController { + constructor( + private readonly questionsService: QuestionsService, + private readonly pollService: PollsService, + ) {} + + @Post(':publicCode/:accessCode') + @ApiOperation({ summary: 'Vote for a question' }) + @ApiResponse({ + status: 200, + description: 'Vote successfully saved.', + type: PollReadAdminDTO, + }) + async vote( + @Param('publicCode', new ParseUUIDPipe()) publicCode: string, + @Param('accessCode', new ParseUUIDPipe()) accessCode: string, + @Body() answer: AnswerCreateDto, + ) { + const poll = await this.pollService.read(publicCode); + if (!poll) { + throw new HttpException('Poll not found', HttpStatus.NOT_FOUND); + } + if (!poll?.published) { + throw new HttpException('Poll not yet published', HttpStatus.FORBIDDEN); + } + if (!(await this.pollService.hasAccess(poll, accessCode))) { + throw new HttpException('Invalid access code', HttpStatus.UNAUTHORIZED); + } + const question = this.questionsService.get(poll, answer.questionId); + if (!question) { + throw new HttpException('Question not found', HttpStatus.BAD_REQUEST); + } + await this.questionsService.vote(question, null, answer.choice); + } +} diff --git a/src/polls/questions.service.ts b/src/polls/questions.service.ts new file mode 100644 index 0000000..ca0aee3 --- /dev/null +++ b/src/polls/questions.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { Question } from './entities/question.entity'; +import { Poll } from './entities/poll.entity'; + +@Injectable() +export class QuestionsService { + constructor( + @InjectRepository(Question) + private questionRepository: Repository, + ) {} + + get(poll: Poll, questionId: number) { + const question = poll.questions.find((q) => q.id == questionId); + return question; + } +}