first version

This commit is contained in:
2025-05-31 20:22:20 +02:00
parent f483590a7a
commit 7383ce6977
27 changed files with 186 additions and 534 deletions
+20
View File
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/src/main.ts",
"preLaunchTask": "npm: build",
"console": "integratedTerminal"
}
]
}
+6 -18
View File
@@ -3,14 +3,10 @@ import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { PollsModule } from './polls/polls.module'; import { VoteController } from './vote/vote.controller';
import { Answer } from './polls/entities/answer.entity'; import { VoteService } from './vote/vote.service';
import { Choice } from './polls/entities/choice.entity'; import { Answer, Vote } from './vote/vote.entity';
import { InviteCode } from './polls/entities/inviteCode.entity'; import { VoteModule } from './vote/vote.module';
import { Poll } from './polls/entities/poll.entity';
import { Question } from './polls/entities/question.entity';
import { QuestionOption } from './polls/entities/questionOptions.entity';
import { User } from './polls/entities/user.entity';
void ConfigModule.forRoot({ void ConfigModule.forRoot({
envFilePath: '.env', envFilePath: '.env',
@@ -28,17 +24,9 @@ const configService = new ConfigService();
username: configService.get('PG_USER'), username: configService.get('PG_USER'),
database: configService.get('PG_DATABASE'), database: configService.get('PG_DATABASE'),
synchronize: configService.get('DEV_MODE') == 'true', synchronize: configService.get('DEV_MODE') == 'true',
entities: [ entities: [Vote, Answer],
Answer,
Choice,
InviteCode,
Poll,
Question,
QuestionOption,
User,
],
}), }),
PollsModule, VoteModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+1
View File
@@ -7,6 +7,7 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe()); app.useGlobalPipes(new ValidationPipe());
app.enableCors({ origin: '*' });
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Vote au jugement majoritaire') .setTitle('Vote au jugement majoritaire')
-15
View File
@@ -1,15 +0,0 @@
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
@@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
export class ChoiceReadDto {
@Expose()
@ApiProperty()
value: string;
@Expose()
@ApiProperty()
order: string;
}
-36
View File
@@ -1,36 +0,0 @@
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
@@ -1,19 +0,0 @@
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[];
}
-36
View File
@@ -1,36 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
ManyToOne,
Unique,
JoinColumn,
} from 'typeorm';
import { Question } from './question.entity';
import { User } from './user.entity';
import { QuestionOption } from './questionOptions.entity';
import { Choice } from './choice.entity';
@Entity()
@Unique(['questionOption', 'user'])
export class Answer {
@PrimaryGeneratedColumn({})
id: number;
@ManyToOne(() => Question, (question: Question) => question.answers)
@JoinColumn([{ name: 'questionId', referencedColumnName: 'id' }])
question: Question;
@ManyToOne(() => Choice)
@JoinColumn([{ name: 'choiceId', referencedColumnName: 'id' }])
choice: Choice;
@ManyToOne(() => QuestionOption)
@JoinColumn([
{ name: 'questionId', referencedColumnName: 'question' },
{ name: 'choiceId', referencedColumnName: 'choice' },
])
questionOption: QuestionOption;
@ManyToOne(() => User)
user: User;
}
-14
View File
@@ -1,14 +0,0 @@
import { Entity, Column, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity()
@Unique(['value'])
export class Choice {
@PrimaryGeneratedColumn({})
id: number;
@Column()
value: string;
@Column()
order: string;
}
-29
View File
@@ -1,29 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
Unique,
Generated,
ManyToOne,
} from 'typeorm';
import { Poll } from './poll.entity';
@Entity()
@Unique(['value'])
export class InviteCode {
@PrimaryGeneratedColumn({})
id: number;
@Column()
description: string;
@Column({ nullable: true, default: null })
maxUsages: number;
@Column()
@Generated('uuid')
value: string;
@ManyToOne(() => Poll, (poll) => poll.codes)
poll: Poll;
}
-40
View File
@@ -1,40 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToMany,
Generated,
Unique,
} from 'typeorm';
import { Question } from './question.entity';
import { InviteCode } from './inviteCode.entity';
@Entity()
@Unique(['editCode'])
export class Poll {
@PrimaryGeneratedColumn({})
id: number;
@Column({ default: 'Sondage' })
name: string;
@Column({ nullable: true })
description: string;
@Column()
@Generated('uuid')
publicCode: string;
@Column()
@Generated('uuid')
editCode: string;
@Column({ default: false })
published: boolean;
@OneToMany(() => InviteCode, (code) => code.poll, { cascade: true })
codes: InviteCode;
@OneToMany(() => Question, (question) => question.poll, { cascade: true })
questions: Question[];
}
-53
View File
@@ -1,53 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
OneToMany,
Unique,
ManyToMany,
JoinTable,
} from 'typeorm';
import { Poll } from './poll.entity';
import { Answer } from './answer.entity';
import { Choice } from './choice.entity';
@Entity()
@Unique(['poll', 'order'])
export class Question {
@PrimaryGeneratedColumn({})
id: number;
@Column()
value: string;
@Column()
mandatory: boolean;
@Column()
order: number;
@ManyToOne(() => Poll, (poll: Poll) => poll.questions)
poll: Poll;
// @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[];
}
@@ -1,16 +0,0 @@
import { Entity, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { Choice } from './choice.entity';
import { Question } from './question.entity';
@Entity()
@Unique(['question', 'choice'])
export class QuestionOption {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Choice)
choice: Choice;
@ManyToOne(() => Question)
question: Question;
}
-10
View File
@@ -1,10 +0,0 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn({})
id: number;
@Column()
username: string;
}
-96
View File
@@ -1,96 +0,0 @@
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);
}
}
-13
View File
@@ -1,13 +0,0 @@
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, InviteCode])],
controllers: [PollController],
providers: [PollsService],
})
export class PollsModule {}
-50
View File
@@ -1,50 +0,0 @@
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
@@ -1,52 +0,0 @@
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
@@ -1,20 +0,0 @@
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;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { VoteController } from './vote.controller';
describe('VoteController', () => {
let controller: VoteController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [VoteController],
}).compile();
controller = module.get<VoteController>(VoteController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { VoteCreateDTO } from './vote.dto';
import { VoteService } from './vote.service';
import { ApiOperation } from '@nestjs/swagger';
@Controller('')
export class VoteController {
constructor(private readonly voteService: VoteService) {}
@Post('vote/:accessCode')
@ApiOperation({ summary: 'Vote for a poll' })
async vote(
@Param('accessCode') accessCode: string,
@Body() answer: VoteCreateDTO,
) {
await this.voteService.create(answer);
}
@Get('results/:accessCode')
@ApiOperation({ summary: 'Get results' })
async results(@Param('accessCode') accessCode: string) {
return await this.voteService.getResults();
}
}
+9
View File
@@ -0,0 +1,9 @@
export class VoteCreateDTO {
'1': string;
'2.1': string;
}
export class VoteResultsDTO {
'1': number[];
'2': number[];
}
+37
View File
@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
OneToMany,
ManyToOne,
Column,
} from 'typeorm';
@Entity()
export class Vote {
@PrimaryGeneratedColumn({})
id: number;
@OneToMany(() => Answer, (answer) => answer.vote, {
cascade: true,
eager: true,
})
answers: Answer[];
}
@Entity()
export class Answer {
@PrimaryGeneratedColumn({})
id: number;
@ManyToOne(() => Vote, (vote) => vote.answers, {
eager: false,
onDelete: 'CASCADE',
})
vote: Promise<Vote>;
@Column()
question_id: string;
@Column()
answer: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { Answer, Vote } from './vote.entity';
import { VoteController } from './vote.controller';
import { VoteService } from './vote.service';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Vote, Answer])],
controllers: [VoteController],
providers: [VoteService],
})
export class VoteModule {}
@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { PollsService } from './polls.service'; import { VoteService } from './vote.service';
describe('PollsService', () => { describe('VoteService', () => {
let service: PollsService; let service: VoteService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [PollsService], providers: [VoteService],
}).compile(); }).compile();
service = module.get<PollsService>(PollsService); service = module.get<VoteService>(VoteService);
}); });
it('should be defined', () => { it('should be defined', () => {
+54
View File
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { VoteCreateDTO } from './vote.dto';
import { Vote } from './vote.entity';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
interface IAnswer {
question_id: string;
answer: string;
}
interface IVote {
answers: IAnswer[];
}
@Injectable()
export class VoteService {
constructor(
@InjectRepository(Vote) private voteRepository: Repository<Vote>,
) {}
async create(value: VoteCreateDTO) {
const vote = Object.entries(value).reduce<IVote>(
(acc, [question_id, a]) => {
const answer = String(a);
acc.answers.push({ question_id, answer });
return acc;
},
{
answers: [],
},
);
const voteEntity = this.voteRepository.create(vote);
await this.voteRepository.save(voteEntity);
}
async getResults() {
const all_votes = await this.voteRepository.find();
const compiled_results: { [question: string]: { [vote: string]: number } } =
{};
for (const vote of all_votes) {
for (const answer of vote.answers) {
if (!(answer.question_id in compiled_results)) {
compiled_results[answer.question_id] = {};
}
if (!(answer.answer in compiled_results[answer.question_id])) {
compiled_results[answer.question_id][answer.answer] = 0;
}
compiled_results[answer.question_id][answer.answer]++;
}
}
return compiled_results;
}
}