3 Commits
Author SHA1 Message Date
legonzaur 4906b1ddde fix build
release-tag / release-image (push) Failing after 1m1s
2025-06-01 13:13:23 +02:00
legonzaur 495ac35937 add invite code support 2025-06-01 13:07:45 +02:00
legonzaur 7383ce6977 first version 2025-05-31 20:22:20 +02:00
29 changed files with 332 additions and 534 deletions
+49
View File
@@ -0,0 +1,49 @@
name: release-tag
on:
push:
tags:
- '**'
jobs:
release-image:
runs-on: ubuntu-latest
env:
REPO_NAME: ${{ gitea.repository }}
ENDPOINT: git.legonzaur.fr
steps:
- name: Checkout
uses: actions/checkout@v4
# - name: Login to DockerHub
# uses: docker/login-action@v2
# with:
# registry: ${{ env.ENDPOINT }}
# username: ${{ secrets.DOCKER_USERNAME }}
# password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.ENDPOINT }}/${{ env.REPO_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Containerfile
build-args: |
NUXT_PUBLIC_API_BASE=${{ vars.NUXT_PUBLIC_API_BASE }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+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"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
FROM node:lts-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx nest build
# étape de production
FROM node:lts-alpine AS production-stage
WORKDIR /app
COPY --from=build-stage /app/dist/ /app
COPY --from=build-stage /app/node_modules/ /app/node_modules
EXPOSE 3000
ENV API_PREFIX=/api
ENV PG_HOST=127.0.0.1
ENV PG_USER=polls
ENV PG_PASSWORD=polls
ENV PG_DATABASE=polls
CMD ["node", "main"]
+4 -18
View File
@@ -3,14 +3,8 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PollsModule } from './polls/polls.module';
import { Answer } from './polls/entities/answer.entity';
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.entity';
import { User } from './polls/entities/user.entity';
import { Answer, InviteCode, Vote } from './vote/vote.entity';
import { VoteModule } from './vote/vote.module';
void ConfigModule.forRoot({
envFilePath: '.env',
@@ -28,17 +22,9 @@ const configService = new ConfigService();
username: configService.get('PG_USER'),
database: configService.get('PG_DATABASE'),
synchronize: configService.get('DEV_MODE') == 'true',
entities: [
Answer,
Choice,
InviteCode,
Poll,
Question,
QuestionOption,
User,
],
entities: [Vote, Answer, InviteCode],
}),
PollsModule,
VoteModule,
],
controllers: [AppController],
providers: [AppService],
+3
View File
@@ -7,6 +7,9 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.enableCors({ origin: '*' });
app.setGlobalPrefix(process.env.API_PREFIX ?? '');
const config = new DocumentBuilder()
.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();
});
});
+53
View File
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Param,
ParseUUIDPipe,
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) {}
@Get('check/:accessCode')
@ApiOperation({ summary: 'Checks if a code has been used' })
async check(@Param('accessCode', new ParseUUIDPipe()) accessCode: string) {
const value = await this.voteService.check(accessCode);
if (value === null) {
throw new HttpException('Invalid accessCode', HttpStatus.NOT_FOUND);
}
if (value === false) {
throw new HttpException('AccessCode expired', HttpStatus.FORBIDDEN);
}
return;
}
@Post('vote/:accessCode')
@ApiOperation({ summary: 'Vote for a poll' })
async vote(
@Param('accessCode', new ParseUUIDPipe()) accessCode: string,
@Body() answer: VoteCreateDTO,
) {
const code = await this.voteService.check(accessCode);
if (code === null) {
throw new HttpException('Invalid accessCode', HttpStatus.NOT_FOUND);
}
if (code === false) {
throw new HttpException('AccessCode expired', HttpStatus.FORBIDDEN);
}
await this.voteService.create(answer, code);
}
@Get('results/:accessCode')
@ApiOperation({ summary: 'Get results' })
async results(@Param('accessCode', new ParseUUIDPipe()) 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[];
}
+65
View File
@@ -0,0 +1,65 @@
import {
Entity,
PrimaryGeneratedColumn,
OneToMany,
ManyToOne,
Column,
OneToOne,
Generated,
} from 'typeorm';
@Entity()
export class Vote {
@PrimaryGeneratedColumn({})
id: number;
@OneToMany(() => Answer, (answer) => answer.vote, {
cascade: true,
eager: true,
})
answers: Answer[];
@OneToOne(() => InviteCode, (code) => code.vote, {
eager: false,
onDelete: 'CASCADE',
nullable: false,
})
code: Promise<InviteCode>;
}
@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;
}
@Entity()
export class InviteCode {
@PrimaryGeneratedColumn({})
id: number;
@OneToOne(() => Vote, (vote) => vote.code, {
eager: false,
onDelete: 'CASCADE',
})
vote: Promise<Vote>;
@Column({ default: 1 })
usages_left: number;
@Column()
@Generated('uuid')
value: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { InviteCode, Vote } from './vote.entity';
import { VoteController } from './vote.controller';
import { VoteService } from './vote.service';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Vote, InviteCode])],
controllers: [VoteController],
providers: [VoteService],
})
export class VoteModule {}
@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PollsService } from './polls.service';
import { VoteService } from './vote.service';
describe('PollsService', () => {
let service: PollsService;
describe('VoteService', () => {
let service: VoteService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PollsService],
providers: [VoteService],
}).compile();
service = module.get<PollsService>(PollsService);
service = module.get<VoteService>(VoteService);
});
it('should be defined', () => {
+72
View File
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { VoteCreateDTO } from './vote.dto';
import { InviteCode, 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>,
@InjectRepository(InviteCode)
private inviteCodeRepository: Repository<InviteCode>,
) {}
async create(value: VoteCreateDTO, accessCode: InviteCode) {
accessCode.usages_left--;
await this.inviteCodeRepository.save(accessCode);
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;
}
async check(inviteCode: string) {
const code = await this.inviteCodeRepository.findOneBy({
value: inviteCode,
});
if (!code) {
return null;
}
if (code.usages_left > 0) {
return code;
}
return false;
}
}