diff --git a/src/app.module.ts b/src/app.module.ts index c749cc8..d372038 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,10 @@ import redisConfig from './config/redis.config'; import { APP_FILTER } from '@nestjs/core'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { ScheduleModule } from '@nestjs/schedule'; +import { TestsModule } from './modules/tests/tests.module'; +import { QuestionsModule } from './modules/questions/questions.module'; +import { SubmissionsModule } from './modules/submissions/submissions.module'; +import { ScoringModule } from './modules/scoring/scoring.module'; const ENV = process.env.NODE_ENV; @@ -71,6 +75,10 @@ const ENV = process.env.NODE_ENV; OtpModule, HashingModule, RedisModule, + TestsModule, + QuestionsModule, + SubmissionsModule, + ScoringModule, ], providers: [ { diff --git a/src/common/decorators/active-user.decorator.ts b/src/common/decorators/active-user.decorator.ts index 8f11840..fe1833f 100644 --- a/src/common/decorators/active-user.decorator.ts +++ b/src/common/decorators/active-user.decorator.ts @@ -1,4 +1,4 @@ -import { User } from '@/modules/users/user.entity'; +import { User } from '@/modules/users/entities/user.entity'; import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import type { Request } from 'express'; import { REQUEST_USER_KEY } from '../constants/request-headers.constants'; diff --git a/src/common/enums/roles.enum.ts b/src/common/enums/roles.enum.ts new file mode 100644 index 0000000..e44bee7 --- /dev/null +++ b/src/common/enums/roles.enum.ts @@ -0,0 +1,5 @@ +export enum Role { + SUPERUSER = 'superuser', + ADMIN = 'admin', + USER = 'user', +} diff --git a/src/common/modules/redis/providers/redis.service.ts b/src/common/modules/redis/providers/redis.service.ts index 4f391fb..6879869 100644 --- a/src/common/modules/redis/providers/redis.service.ts +++ b/src/common/modules/redis/providers/redis.service.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { Inject, Injectable } from '@nestjs/common'; import { REDIS } from '../constants/redis.constants'; diff --git a/src/modules/auth/providers/generate-token.provider.ts b/src/modules/auth/providers/generate-token.provider.ts index c86a8b3..5b797c8 100644 --- a/src/modules/auth/providers/generate-token.provider.ts +++ b/src/modules/auth/providers/generate-token.provider.ts @@ -1,5 +1,5 @@ import jwtConfig from '@/config/jwt.config'; -import { User } from '@/modules/users/user.entity'; +import { User } from '@/modules/users/entities/user.entity'; import { Inject, Injectable } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index 529243d..00a4db0 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -4,7 +4,7 @@ import type { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { AccessTokenPayload } from '../interfaces/jwt.interface'; -import { User } from '@/modules/users/user.entity'; +import { User } from '@/modules/users/entities/user.entity'; import { UsersService } from '@/modules/users/providers/users.service'; @Injectable() diff --git a/src/modules/otp/otp.entity.ts b/src/modules/otp/entities/otp.entity.ts similarity index 100% rename from src/modules/otp/otp.entity.ts rename to src/modules/otp/entities/otp.entity.ts diff --git a/src/modules/otp/otp.module.ts b/src/modules/otp/otp.module.ts index 42c162b..c6f4860 100644 --- a/src/modules/otp/otp.module.ts +++ b/src/modules/otp/otp.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { OtpService } from './providers/otp.service'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { OTP } from './otp.entity'; +import { OTP } from './entities/otp.entity'; import { HashingModule } from '@/common/modules/hashing/hashing.module'; @Module({ diff --git a/src/modules/otp/providers/otp.service.ts b/src/modules/otp/providers/otp.service.ts index 9e76498..2773550 100644 --- a/src/modules/otp/providers/otp.service.ts +++ b/src/modules/otp/providers/otp.service.ts @@ -5,7 +5,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { LessThan, MoreThanOrEqual, Repository } from 'typeorm'; -import { OTP } from '../otp.entity'; +import { OTP } from '../entities/otp.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { Cron } from '@nestjs/schedule'; import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider'; diff --git a/src/modules/questions/entities/choice.entity.ts b/src/modules/questions/entities/choice.entity.ts new file mode 100644 index 0000000..f365b72 --- /dev/null +++ b/src/modules/questions/entities/choice.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Question } from './question.entity'; + +@Entity() +export class Choice { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Question, (question) => question.choices, { + onDelete: 'CASCADE', + }) + question: Question; + + @Column({ + type: 'varchar', + length: 255, + nullable: false, + }) + text: string; + + @Column({ type: 'int', nullable: false }) + value: number; + + @Column({ type: 'int', nullable: false }) + order: number; +} diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts new file mode 100644 index 0000000..acbd6ca --- /dev/null +++ b/src/modules/questions/entities/question.entity.ts @@ -0,0 +1,48 @@ +import { Test } from '@/modules/tests/entities/test.entity'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { QuestionType } from '../enums/question-type.enum'; +import type { QuestionMetadata } from '../types/question-metadata.type'; +import { Choice } from './choice.entity'; + +@Entity() +export class Question { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Test, (test) => test.questions, { + onDelete: 'CASCADE', + }) + test: Test; + + @Column({ + type: 'enum', + enum: QuestionType, + default: QuestionType.MULTI_CHOICE, + }) + type: QuestionType; + + @Column({ type: 'text' }) + content: string; + + @Column({ type: 'int', nullable: false }) + order: number; + + @Column({ type: 'boolean', default: true }) + required: boolean; + + @Column({ type: 'jsonb', nullable: true }) + metadata: QuestionMetadata; + + @OneToMany(() => Choice, (choice) => choice.question) + choices?: Array; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/questions/enums/question-type.enum.ts b/src/modules/questions/enums/question-type.enum.ts new file mode 100644 index 0000000..9a157e3 --- /dev/null +++ b/src/modules/questions/enums/question-type.enum.ts @@ -0,0 +1,5 @@ +export enum QuestionType { + MULTI_CHOICE = 'multi-choice', + NUMERIC = 'numeric', + TEXT = 'text', +} diff --git a/src/modules/questions/interfaces/multi-choice-metadata.interface.ts b/src/modules/questions/interfaces/multi-choice-metadata.interface.ts new file mode 100644 index 0000000..e49191c --- /dev/null +++ b/src/modules/questions/interfaces/multi-choice-metadata.interface.ts @@ -0,0 +1,3 @@ +export interface MultiChoiceMetadata { + allowOther?: boolean; +} diff --git a/src/modules/questions/interfaces/numeric-metadata.interface.ts b/src/modules/questions/interfaces/numeric-metadata.interface.ts new file mode 100644 index 0000000..2732d53 --- /dev/null +++ b/src/modules/questions/interfaces/numeric-metadata.interface.ts @@ -0,0 +1,5 @@ +export interface NumericMetadata { + min: number; + max: number; + step?: number; +} diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts new file mode 100644 index 0000000..4cdf16b --- /dev/null +++ b/src/modules/questions/questions.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Question } from './entities/question.entity'; +import { Choice } from './entities/choice.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Question, Choice])], +}) +export class QuestionsModule {} diff --git a/src/modules/questions/types/question-metadata.type.ts b/src/modules/questions/types/question-metadata.type.ts new file mode 100644 index 0000000..9a98e20 --- /dev/null +++ b/src/modules/questions/types/question-metadata.type.ts @@ -0,0 +1,4 @@ +import { MultiChoiceMetadata } from '../interfaces/multi-choice-metadata.interface'; +import { NumericMetadata } from '../interfaces/numeric-metadata.interface'; + +export type QuestionMetadata = NumericMetadata | MultiChoiceMetadata; diff --git a/src/modules/scoring/entities/result-range.entity.ts b/src/modules/scoring/entities/result-range.entity.ts new file mode 100644 index 0000000..0e3f43b --- /dev/null +++ b/src/modules/scoring/entities/result-range.entity.ts @@ -0,0 +1,38 @@ +import { Test } from '@/modules/tests/entities/test.entity'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class ResultRange { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Test, (test) => test.resultRanges, { + onDelete: 'CASCADE', + }) + test: Test; + + @Column({ + type: 'int', + nullable: false, + }) + minScore: number; + + @Column({ + type: 'int', + nullable: false, + }) + maxScore: number; + + @Column({ + type: 'varchar', + length: 255, + nullable: false, + }) + title: string; + + @Column({ + type: 'text', + nullable: false, + }) + description: string; +} diff --git a/src/modules/scoring/entities/rule-set.entity.ts b/src/modules/scoring/entities/rule-set.entity.ts new file mode 100644 index 0000000..13f4b8d --- /dev/null +++ b/src/modules/scoring/entities/rule-set.entity.ts @@ -0,0 +1,21 @@ +import { Test } from '@/modules/tests/entities/test.entity'; +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +export class RuleSet { + @PrimaryGeneratedColumn() + id: number; + + @OneToOne(() => Test, (test) => test.ruleSet) + @JoinColumn() + test: Test; + + @Column({ type: 'jsonb', nullable: false }) + config: any; +} diff --git a/src/modules/scoring/scoring.module.ts b/src/modules/scoring/scoring.module.ts new file mode 100644 index 0000000..3ce975e --- /dev/null +++ b/src/modules/scoring/scoring.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ResultRange } from './entities/result-range.entity'; +import { RuleSet } from './entities/rule-set.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([ResultRange, RuleSet])], +}) +export class ScoringModule {} diff --git a/src/modules/submissions/entities/submission-answer.entity.ts b/src/modules/submissions/entities/submission-answer.entity.ts new file mode 100644 index 0000000..185827b --- /dev/null +++ b/src/modules/submissions/entities/submission-answer.entity.ts @@ -0,0 +1,31 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Submission } from './submission.entity'; +import { Question } from '@/modules/questions/entities/question.entity'; +import { Choice } from '@/modules/questions/entities/choice.entity'; + +@Entity() +export class SubmissionAnswer { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Submission, (submission) => submission.answers, { + onDelete: 'CASCADE', + }) + submission: Submission; + + @ManyToOne(() => Question, { onDelete: 'CASCADE' }) + question: Question; + + @ManyToOne(() => Choice, { nullable: true, onDelete: 'CASCADE' }) + choice?: Choice; + + @Column({ type: 'int', nullable: true }) + numericValue?: number; + + @Column({ + type: 'varchar', + length: 255, + nullable: true, + }) + textValue?: string; +} diff --git a/src/modules/submissions/entities/submission-result.entity.ts b/src/modules/submissions/entities/submission-result.entity.ts new file mode 100644 index 0000000..fcaeedc --- /dev/null +++ b/src/modules/submissions/entities/submission-result.entity.ts @@ -0,0 +1,32 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Submission } from './submission.entity'; +import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; + +@Entity() +@Check(`"probability" >= 0 AND "probability" <= 100`) +export class SubmissionResult { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Submission, { onDelete: 'CASCADE' }) + submission: Submission; + + @Column({ type: 'int', nullable: false }) + score: number; + + @Column({ type: 'int', nullable: false }) + probability: number; + + @ManyToOne(() => ResultRange, { onDelete: 'CASCADE' }) + resultRange: ResultRange; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/submissions/entities/submission.entity.ts b/src/modules/submissions/entities/submission.entity.ts new file mode 100644 index 0000000..6bdd714 --- /dev/null +++ b/src/modules/submissions/entities/submission.entity.ts @@ -0,0 +1,37 @@ +import { Test } from '@/modules/tests/entities/test.entity'; +import { User } from '@/modules/users/entities/user.entity'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { SubmissionStatus } from '../enums/submission-status.enum'; +import { SubmissionAnswer } from './submission-answer.entity'; + +@Entity() +export class Submission { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + user?: User; + + @ManyToOne(() => Test, { onDelete: 'CASCADE' }) + test: Test; + + @Column({ + type: 'enum', + enum: SubmissionStatus, + default: SubmissionStatus.IN_PROGRESS, + }) + status: SubmissionStatus; + + @OneToMany(() => SubmissionAnswer, (answer) => answer.submission) + answers: Array; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/submissions/enums/submission-status.enum.ts b/src/modules/submissions/enums/submission-status.enum.ts new file mode 100644 index 0000000..582355b --- /dev/null +++ b/src/modules/submissions/enums/submission-status.enum.ts @@ -0,0 +1,4 @@ +export enum SubmissionStatus { + IN_PROGRESS = 'in-progress', + COMPLETED = 'completed', +} diff --git a/src/modules/submissions/submissions.module.ts b/src/modules/submissions/submissions.module.ts new file mode 100644 index 0000000..bb5c8d7 --- /dev/null +++ b/src/modules/submissions/submissions.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Submission } from './entities/submission.entity'; +import { SubmissionAnswer } from './entities/submission-answer.entity'; +import { SubmissionResult } from './entities/submission-result.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Submission, SubmissionAnswer, SubmissionResult]), + ], +}) +export class SubmissionsModule {} diff --git a/src/modules/tests/entities/test.entity.ts b/src/modules/tests/entities/test.entity.ts new file mode 100644 index 0000000..bb67e92 --- /dev/null +++ b/src/modules/tests/entities/test.entity.ts @@ -0,0 +1,62 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { AccessType } from '../enums/access-types.enum'; +import { Question } from '@/modules/questions/entities/question.entity'; +import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; +import { RuleSet } from '@/modules/scoring/entities/rule-set.entity'; + +@Entity() +export class Test { + @PrimaryGeneratedColumn() + id: number; + + @Column({ + type: 'varchar', + length: 255, + nullable: false, + }) + title: string; + + @Column({ + type: 'text', + nullable: false, + }) + description: string; + + @Column({ + type: 'enum', + enum: AccessType, + default: AccessType.LOGIN, + }) + accessType: AccessType; + + @Column({ + type: 'bigint', + nullable: true, + }) + price: string; + + @Column({ + type: 'boolean', + default: false, + }) + isActive: boolean; + + @OneToMany(() => Question, (question) => question.test) + questions: Array; + + @OneToOne(() => RuleSet, (ruleSet) => ruleSet.test) + ruleSet: RuleSet; + + @OneToMany(() => ResultRange, (range) => range.test) + resultRanges: Array; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/tests/enums/access-types.enum.ts b/src/modules/tests/enums/access-types.enum.ts new file mode 100644 index 0000000..d03fdb7 --- /dev/null +++ b/src/modules/tests/enums/access-types.enum.ts @@ -0,0 +1,5 @@ +export enum AccessType { + FREE = 'free', + LOGIN = 'login', + PAID = 'paid', +} diff --git a/src/modules/tests/tests.module.ts b/src/modules/tests/tests.module.ts new file mode 100644 index 0000000..84e9fe1 --- /dev/null +++ b/src/modules/tests/tests.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Test } from './entities/test.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Test])], +}) +export class TestsModule {} diff --git a/src/modules/users/user.entity.ts b/src/modules/users/entities/user.entity.ts similarity index 83% rename from src/modules/users/user.entity.ts rename to src/modules/users/entities/user.entity.ts index 84309ed..cb7e370 100644 --- a/src/modules/users/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,3 +1,4 @@ +import { Role } from '@/common/enums/roles.enum'; import { Column, CreateDateColumn, @@ -42,6 +43,13 @@ export class User { }) password?: string; + @Column({ + type: 'enum', + enum: Role, + default: Role.USER, + }) + role: Role; + @CreateDateColumn() createdAt: Date; diff --git a/src/modules/users/providers/user-profile.provider.ts b/src/modules/users/providers/user-profile.provider.ts index 1f42eb4..cbc8a95 100644 --- a/src/modules/users/providers/user-profile.provider.ts +++ b/src/modules/users/providers/user-profile.provider.ts @@ -1,7 +1,7 @@ import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider'; import { Injectable, RequestTimeoutException } from '@nestjs/common'; import { I18nService } from 'nestjs-i18n'; -import { User } from '../user.entity'; +import { User } from '../entities/user.entity'; import { UpdateProfileDTO } from '../dto/update-profile.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index 0799a6a..b00e2fb 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -4,7 +4,7 @@ import { RequestTimeoutException, } from '@nestjs/common'; import { Repository } from 'typeorm'; -import { User } from '../user.entity'; +import { User } from '../entities/user.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { UpdateProfileDTO } from '../dto/update-profile.dto'; import { UserProfileProvider } from './user-profile.provider'; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 0c2bafb..6061f32 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Patch } from '@nestjs/common'; import { UsersService } from './providers/users.service'; import { ActiveUser } from '@/common/decorators/active-user.decorator'; -import { User } from './user.entity'; +import { User } from './entities/user.entity'; import { UpdateProfileDTO } from './dto/update-profile.dto'; @Controller('users') diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index f21a8a8..9b77ae9 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './providers/users.service'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from './user.entity'; +import { User } from './entities/user.entity'; import { HashingModule } from '@/common/modules/hashing/hashing.module'; import { UserProfileProvider } from './providers/user-profile.provider';