This commit is contained in:
2025-05-30 15:53:13 +02:00
parent 297de56c46
commit f483590a7a
20 changed files with 401 additions and 10 deletions
+40
View File
@@ -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",
+2
View File
@@ -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",
+1 -1
View File
@@ -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({
+3
View File
@@ -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')
+15
View File
@@ -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;
}
+12
View File
@@ -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;
}
+36
View File
@@ -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;
}
+19
View File
@@ -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[];
}
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -18,7 +18,7 @@ export class InviteCode {
description: string;
@Column({ nullable: true, default: null })
max_usages: number;
maxUsages: number;
@Column()
@Generated('uuid')
+6 -2
View File
@@ -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;
+22 -3
View File
@@ -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[];
@@ -11,6 +11,6 @@ export class QuestionOption {
@ManyToOne(() => Choice)
choice: Choice;
@ManyToOne(() => Question, (question) => question.options)
@ManyToOne(() => Question)
question: Question;
}
+96
View File
@@ -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<PollReadAdminDTO> {
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<PollReadAdminDTO> {
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<PollReadDTO> {
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<PollReadAdminDTO> {
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);
}
}
+6 -1
View File
@@ -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 {}
+18
View File
@@ -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>(PollsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+50
View File
@@ -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<Poll>,
@InjectRepository(InviteCode)
private inviteRepository: Repository<InviteCode>,
) {}
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,
},
});
}
}
+52
View File
@@ -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);
}
}
+20
View File
@@ -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<Question>,
) {}
get(poll: Poll, questionId: number) {
const question = poll.questions.find((q) => q.id == questionId);
return question;
}
}