From 11971398cb70c632714d439ddb68942105b21d23 Mon Sep 17 00:00:00 2001 From: OkaykOrhmn Date: Sun, 13 Jul 2025 15:54:33 +0330 Subject: [PATCH] 2nd commit, question added, not tested --- src/question/dto/create-question.dto.ts | 113 +++++++++++++++++++ src/question/dto/update-question.dto.ts | 4 + src/question/entity/attachment.entity.ts | 39 +++++++ src/question/entity/option.entity.ts | 15 +++ src/question/entity/question.entity.ts | 68 ++++++++++++ src/question/question.controller.ts | 47 ++++++++ src/question/question.module.ts | 16 +++ src/question/question.service.ts | 133 +++++++++++++++++++++++ test/app.e2e-spec.ts | 2 +- 9 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/question/dto/create-question.dto.ts create mode 100644 src/question/dto/update-question.dto.ts create mode 100644 src/question/entity/attachment.entity.ts create mode 100644 src/question/entity/option.entity.ts create mode 100644 src/question/entity/question.entity.ts create mode 100644 src/question/question.controller.ts create mode 100644 src/question/question.module.ts create mode 100644 src/question/question.service.ts diff --git a/src/question/dto/create-question.dto.ts b/src/question/dto/create-question.dto.ts new file mode 100644 index 0000000..ec79fc2 --- /dev/null +++ b/src/question/dto/create-question.dto.ts @@ -0,0 +1,113 @@ +import { IsString, IsBoolean, IsOptional, IsUUID, ValidateNested, IsArray, IsEnum, IsUrl,} from 'class-validator'; +import { Type } from 'class-transformer'; + +// DTO for Metadata entries, consistent with participant DTO +class MetadataEntryDto { + @IsString() + key: string; + + @IsString() + value: string; +} + +class MetadataDto { + @IsOptional() + @IsUUID() + id?: string; + + @ValidateNested({ each: true }) + @Type(() => MetadataEntryDto) + entries: MetadataEntryDto[]; +} + +// DTO for embedded Option +class CreateOptionDto { + @IsOptional() + @IsUUID() + id?: string; + + @IsString() + text: string; + + @IsOptional() + @ValidateNested() + @Type(() => MetadataDto) + metadata?: MetadataDto; + + @IsOptional() + @IsEnum(['active', 'inactive']) + status?: 'active' | 'inactive'; +} + +// DTO for embedded Attachment +class CreateAttachmentDto { + @IsOptional() + @IsUUID() + id?: string; + + @IsString() + displayName: string; + + @IsString() + filePath: string; + + @IsString() + fileType: string; + + @IsUrl() + storageUrl: string; + + @IsUUID() + ownerId: string; + + @IsOptional() + @ValidateNested() + @Type(() => MetadataDto) + metadata?: MetadataDto; + + @IsOptional() + @IsEnum(['active', 'inactive']) + status?: 'active' | 'inactive'; +} + +// Main DTO for creating a Question +export class CreateQuestionDto { + @IsOptional() + @IsUUID() + id?: string; + + @IsString() + text: string; + + @IsEnum(['multiple-choice', 'single-choice', 'text', 'rating', 'file']) + type: string; + + @IsOptional() + @IsBoolean() + isRequired?: boolean; + + @IsOptional() + @IsBoolean() + isConfidential?: boolean; + + @IsOptional() + @IsString() + validationRules?: string; + + @IsOptional() + @ValidateNested() + @Type(() => MetadataDto) + metadata?: MetadataDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateOptionDto) + options?: CreateOptionDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateAttachmentDto) + attachments?: CreateAttachmentDto[]; +} diff --git a/src/question/dto/update-question.dto.ts b/src/question/dto/update-question.dto.ts new file mode 100644 index 0000000..4bac4cf --- /dev/null +++ b/src/question/dto/update-question.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateQuestionDto } from './create-question.dto'; + +export class UpdateQuestionDto extends PartialType(CreateQuestionDto) {} diff --git a/src/question/entity/attachment.entity.ts b/src/question/entity/attachment.entity.ts new file mode 100644 index 0000000..8d488ff --- /dev/null +++ b/src/question/entity/attachment.entity.ts @@ -0,0 +1,39 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { BaseEntity } from 'src/_core/_base.entity'; + +@Schema({ _id: false, id: false }) // Embedded document, inheriting from BaseEntity +export class Attachment extends BaseEntity { + @Prop({ + type: String, + required: true, + }) + displayName: string; + + @Prop({ + type: String, + required: true, + }) + filePath: string; + + @Prop({ + type: String, + required: true, + }) + fileType: string; + + @Prop({ + type: String, + required: true, + }) + storageUrl: string; + + @Prop({ + type: String, + required: true, + }) + ownerId: string; // UUID of the user who uploaded it +} + +export type AttachmentDocument = Attachment & Document; +export const AttachmentSchema = SchemaFactory.createForClass(Attachment); diff --git a/src/question/entity/option.entity.ts b/src/question/entity/option.entity.ts new file mode 100644 index 0000000..f8a680e --- /dev/null +++ b/src/question/entity/option.entity.ts @@ -0,0 +1,15 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { BaseEntity } from 'src/_core/_base.entity'; + +@Schema({ _id: false, id: false }) // Embedded document, inheriting from BaseEntity +export class Option extends BaseEntity { + @Prop({ + type: String, + required: true, + }) + text: string; +} + +export type OptionDocument = Option & Document; +export const OptionSchema = SchemaFactory.createForClass(Option); diff --git a/src/question/entity/question.entity.ts b/src/question/entity/question.entity.ts new file mode 100644 index 0000000..afb4757 --- /dev/null +++ b/src/question/entity/question.entity.ts @@ -0,0 +1,68 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { BaseEntity } from 'src/_core/_base.entity'; +import * as mongoosePaginate from 'mongoose-paginate-v2'; +import { Attachment, AttachmentSchema } from './attachment.entity'; +import { Option, OptionSchema } from './option.entity'; + +@Schema({ _id: false, id: false }) // Disable _id and virtual id +export class Question extends BaseEntity { + @Prop({ + type: String, + required: true, + }) + text: string; + + @Prop({ + type: String, + required: true, + // Example types: 'multiple-choice', 'text', 'rating', etc. + enum: ['multiple-choice', 'single-choice', 'text', 'rating', 'file'], + default: 'text', + }) + type: string; + + @Prop({ + type: Boolean, + default: false, + required: true, + }) + isRequired: boolean; + + @Prop({ + type: Boolean, + default: false, + required: true, + }) + isConfidential: boolean; + + @Prop({ + type: String, + required: false, // Assuming validation rules might not always be present + }) + validationRules?: string; + + @Prop({ + type: [OptionSchema], + default: [], + }) + options: Option[]; + + @Prop({ + type: [AttachmentSchema], + default: [], + }) + attachments: Attachment[]; +} + +export type QuestionDocument = Question & Document; +export const QuestionSchema = SchemaFactory.createForClass(Question); +QuestionSchema.plugin(mongoosePaginate); + +// Transform the output to remove the internal '_id' +QuestionSchema.set('toJSON', { + transform: (doc: QuestionDocument, ret: Question & { _id?: any }) => { + delete ret._id; + return ret; + }, +}); diff --git a/src/question/question.controller.ts b/src/question/question.controller.ts new file mode 100644 index 0000000..b0f02ac --- /dev/null +++ b/src/question/question.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query, ParseUUIDPipe, HttpCode, HttpStatus,} from '@nestjs/common'; +import { QuestionService } from './question.service'; +import { CreateQuestionDto } from './dto/create-question.dto'; +import { UpdateQuestionDto } from './dto/update-question.dto'; +import { Question } from './entity/question.entity'; + +@Controller('questions') +export class QuestionController { + constructor(private readonly questionService: QuestionService) {} + + @Post() + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async create(@Body() body: CreateQuestionDto): Promise { + return this.questionService.create(body); + } + + @Get('findAll') + async findAll( + @Query('page') page: string = '1', + @Query('limit') limit: string = '10', + ) { + // The service returns the full pagination object + return this.questionService.findAll(parseInt(page, 10), parseInt(limit, 10)); + } + + @Get(':id') + async findOne( + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { + return this.questionService.findById(id); + } + + @Patch(':id') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() body: UpdateQuestionDto, + ): Promise { + return this.questionService.update(id, body); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return this.questionService.remove(id); + } +} diff --git a/src/question/question.module.ts b/src/question/question.module.ts new file mode 100644 index 0000000..e3fe5f2 --- /dev/null +++ b/src/question/question.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { QuestionService } from './question.service'; +import { QuestionController } from './question.controller'; +import { Question, QuestionSchema } from './entity/question.entity'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Question.name, schema: QuestionSchema }, + ]), + ], + controllers: [QuestionController], + providers: [QuestionService], +}) +export class QuestionModule {} diff --git a/src/question/question.service.ts b/src/question/question.service.ts new file mode 100644 index 0000000..4205717 --- /dev/null +++ b/src/question/question.service.ts @@ -0,0 +1,133 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Question, QuestionDocument } from './entity/question.entity'; +import { CreateQuestionDto } from './dto/create-question.dto'; +import { UpdateQuestionDto } from './dto/update-question.dto'; + +// Interface matching the mongoose-paginate-v2 plugin +interface PaginateModel extends Model { + paginate: ( + query?: any, + options?: { page: number; limit: number; lean?: boolean; select?: string }, + ) => Promise<{ + docs: T[]; + totalDocs: number; + limit: number; + page: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; + nextPage: number | null; + prevPage: number | null; + }>; +} + +@Injectable() +export class QuestionService { + constructor( + @InjectModel(Question.name) + private readonly questionModel: PaginateModel, + ) {} + + /** + * Creates a new question. + * @param data - The data to create the question with. + * @returns The newly created question. + */ + async create(data: CreateQuestionDto): Promise { + const question = new this.questionModel({ + ...data, + id: data.id || undefined, // Let BaseEntity generate UUID if not provided + metadata: data.metadata || { entries: [] }, + }); + return question.save(); + } + + /** + * Finds all questions with pagination. + * @param page - The current page number. + * @param limit - The number of items per page. + * @returns A paginated list of questions. + */ + async findAll( + page = 1, + limit = 10, + ): Promise<{ + docs: Question[]; + totalDocs: number; + limit: number; + page: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; + nextPage: number | null; + prevPage: number | null; + }> { + // Selects all fields from the Question entity for the response + const selectFields = + 'id text type isRequired isConfidential validationRules options attachments metadata status createdAt updatedAt'; + return this.questionModel.paginate( + {}, + { page, limit, lean: true, select: selectFields }, + ); + } + + /** + * Finds a single question by its UUID. + * @param id - The UUID of the question. + * @returns The found question or null. + */ + async findById(id: string): Promise { + const selectFields = + 'id text type isRequired isConfidential validationRules options attachments metadata status createdAt updatedAt'; + const question = await this.questionModel + .findOne({ id }) + .select(selectFields) + .lean() + .exec(); + if (!question) { + throw new NotFoundException(`Question with ID "${id}" not found`); + } + return question; + } + + /** + * Updates a question by its UUID. + * @param id - The UUID of the question to update. + * @param data - The data to update the question with. + * @returns The updated question or null. + */ + async update( + id: string, + data: UpdateQuestionDto, + ): Promise { + const selectFields = + 'id text type isRequired isConfidential validationRules options attachments metadata status createdAt updatedAt'; + const updatedQuestion = await this.questionModel + .findOneAndUpdate( + { id }, + { $set: { ...data, updatedAt: new Date() } }, + { new: true }, + ) + .select(selectFields) + .lean() + .exec(); + + if (!updatedQuestion) { + throw new NotFoundException(`Question with ID "${id}" not found`); + } + return updatedQuestion; + } + + /** + * Removes a question by its UUID. + * @param id - The UUID of the question to remove. + */ + async remove(id: string): Promise { + const result = await this.questionModel.deleteOne({ id }).exec(); + if (result.deletedCount === 0) { + throw new NotFoundException(`Question with ID "${id}" not found`); + } + } +} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 4df6580..5ffa3c2 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; +import { AppModule } from '../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication;