Compare commits

..

No commits in common. "a5f76f01adaf4fe076c2badf46f0ed9cfde47c14" and "aeb7a73c4cfe98cb25a58bc5acb54690023467f4" have entirely different histories.

38 changed files with 358 additions and 325 deletions

View File

@ -1,24 +1,9 @@
import { IsEnum, IsOptional, IsUUID, ValidateNested, IsArray, IsString } from 'class-validator'; import { IsString, IsEnum, IsOptional, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { MetadataDto } from './metadataEntry.dto';
export class MetadataEntryDto {
@IsString()
key: string;
@IsString()
value: string;
}
export class MetadataDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => MetadataEntryDto)
@IsOptional()
entries?: { key: string; value: string }[];
}
export class BaseDto { export class BaseDto {
@IsUUID('4') @IsUUID()
@IsOptional() @IsOptional()
id?: string; id?: string;
@ -30,4 +15,4 @@ export class BaseDto {
@Type(() => MetadataDto) @Type(() => MetadataDto)
@IsOptional() @IsOptional()
metadata?: MetadataDto; metadata?: MetadataDto;
} }

View File

@ -0,0 +1,16 @@
import { IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class MetadataEntryDto {
@IsString()
key: string;
@IsString()
value: string;
}
export class MetadataDto {
@ValidateNested({ each: true })
@Type(() => MetadataEntryDto)
entries: MetadataEntryDto[];
}

View File

@ -1,11 +1,7 @@
import { Column, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn, BeforeInsert } from 'typeorm'; import { Column, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn, BeforeInsert } from 'typeorm';
import { Metadata } from './_metadata.entity';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export class Metadata {
@Column({ type: 'jsonb', nullable: true, default: () => "'[]'" })
entries?: { key: string; value: string }[];
}
export abstract class BaseEntity { export abstract class BaseEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@ -17,7 +13,7 @@ export abstract class BaseEntity {
updatedAt: Date; updatedAt: Date;
@Column(() => Metadata) @Column(() => Metadata)
metadata?: Metadata; metadata: Metadata = new Metadata();
@Column({ @Column({
type: 'enum', type: 'enum',
@ -32,4 +28,5 @@ export abstract class BaseEntity {
this.id = uuidv4(); this.id = uuidv4();
} }
} }
}
}

View File

@ -0,0 +1,17 @@
import { Column, Entity } from 'typeorm';
@Entity({ name: 'metadata' })
export class Metadata {
@Column({
type: 'uuid',
primary: true,
default: () => 'uuid_generate_v4()',
})
id?: string;
@Column({
type: 'jsonb',
default: () => "'[]'",
})
entries?: { key: string; value: string }[];
}

View File

@ -1,9 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query, ParseUUIDPipe, HttpCode, HttpStatus, Request } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
import { AnswerService } from './answer.service'; import { AnswerService } from './answer.service';
import { CreateAnswerDto } from './dto/create-answer.dto'; import { CreateAnswerDto } from './dto/create-answer.dto';
import { UpdateAnswerDto } from './dto/update-answer.dto'; import { UpdateAnswerDto } from './dto/update-answer.dto';
import { Answer } from './entity/answer.entity'; import { Answer } from './entity/answer.entity';
import { AuthRequest } from '../middleware/jwtMiddleware';
@Controller('answers') @Controller('answers')
export class AnswerController { export class AnswerController {
@ -11,8 +10,8 @@ export class AnswerController {
@Post() @Post()
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async create(@Body() body: CreateAnswerDto, @Request() req: AuthRequest): Promise<Answer> { async create(@Body() body: CreateAnswerDto): Promise<Answer> {
return this.answerService.create(body, req.user); return this.answerService.create(body);
} }
@Get('findAll') @Get('findAll')

View File

@ -4,7 +4,6 @@ import { Repository } from 'typeorm';
import { Answer } from './entity/answer.entity'; import { Answer } from './entity/answer.entity';
import { CreateAnswerDto } from './dto/create-answer.dto'; import { CreateAnswerDto } from './dto/create-answer.dto';
import { UpdateAnswerDto } from './dto/update-answer.dto'; import { UpdateAnswerDto } from './dto/update-answer.dto';
import { AuthRequest } from '../middleware/jwtMiddleware';
@Injectable() @Injectable()
export class AnswerService { export class AnswerService {
@ -13,15 +12,23 @@ export class AnswerService {
private readonly answerRepository: Repository<Answer>, private readonly answerRepository: Repository<Answer>,
) {} ) {}
async create(data: CreateAnswerDto, user: AuthRequest['user']): Promise<Answer> { async create(data: CreateAnswerDto): Promise<Answer> {
const answer = this.answerRepository.create({ try {
...data, const answer = this.answerRepository.create({
participantId: user.sub, ...data,
}); metadata: data.metadata || { entries: [] },
return await this.answerRepository.save(answer); status: data.status || 'active',
});
return await this.answerRepository.save(answer);
} catch (error) {
throw new Error(`Failed to create answer: ${error.message}`);
}
} }
async findAll(page = 1, limit = 10): Promise<{ async findAll(
page = 1,
limit = 10,
): Promise<{
docs: Answer[]; docs: Answer[];
totalDocs: number; totalDocs: number;
limit: number; limit: number;

View File

@ -2,10 +2,10 @@ import { IsString, IsUUID } from 'class-validator';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
export class CreateAnswerDto extends BaseDto { export class CreateAnswerDto extends BaseDto {
@IsUUID('4') @IsUUID()
participantId: string; participantId: string;
@IsUUID('4') @IsUUID()
questionId: string; questionId: string;
@IsString() @IsString()

View File

@ -1,14 +1,22 @@
import { Column, Entity } from 'typeorm'; import { Column, Entity, ManyToOne } from 'typeorm';
import { BaseEntity } from 'src/_core/entity/_base.entity'; import { BaseEntity } from 'src/_core/entity/_base.entity';
import { Question } from '../../question/entity/question.entity';
@Entity({ name: 'answers' }) @Entity({ name: 'answers' })
export class Answer extends BaseEntity { export class Answer extends BaseEntity {
@Column({ type: 'uuid' }) @Column({
type: 'uuid',
})
participantId: string; participantId: string;
@Column({ type: 'uuid' }) @Column({
type: 'uuid',
})
questionId: string; questionId: string;
@Column() @Column()
value: string; value: string;
@ManyToOne(() => Question, question => question.answers)
question: Question;
} }

View File

@ -1,9 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query, ParseUUIDPipe, HttpCode, HttpStatus, Request } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
import { AttachmentService } from './attachment.service'; import { AttachmentService } from './attachment.service';
import { CreateAttachmentDto } from './dto/create_attachment.dto'; import { CreateAttachmentDto } from './dto/create_attachment.dto';
import { UpdateAttachmentDto } from './dto/update_attachment.dto'; import { UpdateAttachmentDto } from './dto/update_attachment.dto';
import { Attachment } from './entity/attachment.entity'; import { Attachment } from './entity/attachment.entity';
import { AuthRequest } from '../middleware/jwtMiddleware';
@Controller('attachments') @Controller('attachments')
export class AttachmentController { export class AttachmentController {
@ -11,8 +10,8 @@ export class AttachmentController {
@Post() @Post()
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async create(@Body() body: CreateAttachmentDto, @Request() req: AuthRequest): Promise<Attachment> { async create(@Body() body: CreateAttachmentDto): Promise<Attachment> {
return this.attachmentService.create(body, req.user); return this.attachmentService.create(body);
} }
@Get('findAll') @Get('findAll')

View File

@ -1,10 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Attachment } from './entity/attachment.entity'; import { Attachment } from './entity/attachment.entity';
import { CreateAttachmentDto } from './dto/create_attachment.dto'; import { CreateAttachmentDto } from './dto/create_attachment.dto';
import { UpdateAttachmentDto } from './dto/update_attachment.dto'; import { UpdateAttachmentDto } from './dto/update_attachment.dto';
import { AuthRequest } from '../middleware/jwtMiddleware'; import axios from 'axios';
import { decode } from 'jsonwebtoken';
@Injectable() @Injectable()
export class AttachmentService { export class AttachmentService {
@ -13,12 +14,47 @@ export class AttachmentService {
private readonly attachmentRepository: Repository<Attachment>, private readonly attachmentRepository: Repository<Attachment>,
) {} ) {}
async create(data: CreateAttachmentDto, user: AuthRequest['user']): Promise<Attachment> { async create(data: CreateAttachmentDto): Promise<Attachment> {
const attachment = this.attachmentRepository.create({ try {
...data, // Here we send a sample request, in final implementation, there'll be no need for that, we'll get the decoded token
ownerId: user.sub, const authResponse = await axios.post(
}); 'https://auth.didvan.com/realms/didvan/protocol/openid-connect/token',
return await this.attachmentRepository.save(attachment); new URLSearchParams({
client_id: 'didvan-app',
username: 'bob',
password: 'developer_password',
grant_type: 'password',
}),
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
},
);
// Decode the access token to extract the 'sub' claim
const token = authResponse.data.access_token;
const decodedToken: any = decode(token);
if (!decodedToken || !decodedToken.sub) {
throw new InternalServerErrorException('Failed to decode token or extract sub');
}
// Create the attachment with ownerId set to sub
const attachment = this.attachmentRepository.create({
...data,
ownerId: decodedToken.sub, // Set ownerId to the token's sub
metadata: data.metadata
? { entries: data.metadata.entries }
: { entries: [] },
status: data.status || 'active',
});
// Save the attachment to the database
return await this.attachmentRepository.save(attachment);
} catch (error) {
throw new InternalServerErrorException(`Failed to create attachment: ${error.message}`);
}
} }
async findAll( async findAll(

View File

@ -1,4 +1,5 @@
import { IsEnum, IsOptional, IsString, IsUrl, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUrl, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
export class CreateAttachmentDto extends BaseDto { export class CreateAttachmentDto extends BaseDto {
@ -14,7 +15,7 @@ export class CreateAttachmentDto extends BaseDto {
@IsUrl() @IsUrl()
storageUrl: string; storageUrl: string;
// @IsUUID('4') @IsUUID()
// @IsOptional() @IsOptional()
// ownerId?: string; ownerId?: string;
} }

View File

@ -1,5 +1,8 @@
import { Column, Entity } from 'typeorm'; import { Column, Entity, ManyToOne } from 'typeorm';
import { BaseEntity } from 'src/_core/entity/_base.entity'; import { BaseEntity } from 'src/_core/entity/_base.entity';
import { Question } from 'src/question/entity/question.entity';
import { FormSection } from '../../formSection/entity/formSection.entity';
import { Form } from '../../form/entity/form.entity';
@Entity({ name: 'attachments' }) @Entity({ name: 'attachments' })
export class Attachment extends BaseEntity { export class Attachment extends BaseEntity {
@ -18,6 +21,17 @@ export class Attachment extends BaseEntity {
@Column() @Column()
storageUrl: string; storageUrl: string;
@Column({ type: 'uuid' }) @Column({
type: 'uuid',
})
ownerId: string; ownerId: string;
@ManyToOne(() => Question, (question) => question.attachments)
question: Question;
@ManyToOne(() => FormSection, formSection => formSection.attachments)
formSection: FormSection;
@ManyToOne(() => Form, form => form.attachments)
form: Form;
} }

View File

@ -1,6 +1,7 @@
// src/config/database.config.ts // src/config/database.config.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Participant } from '../participant/entity/participant.entity'; import { Participant } from '../participant/entity/participant.entity';
import { Metadata } from '../_core/entity/_metadata.entity';
import { Form } from '../form/entity/form.entity'; import { Form } from '../form/entity/form.entity';
import { Realm } from '../realm/entity/realm.entity'; import { Realm } from '../realm/entity/realm.entity';
import { Question } from '../question/entity/question.entity'; import { Question } from '../question/entity/question.entity';
@ -11,7 +12,7 @@ import { FormSection } from '../formSection/entity/formSection.entity';
import { FormPage } from '../formPage/entity/formPage.entity'; import { FormPage } from '../formPage/entity/formPage.entity';
import { FormResult } from '../formResult/entity/formResult.entity'; import { FormResult } from '../formResult/entity/formResult.entity';
import { ParticipantGroup } from '../participantGroup/entity/participantGroup.entity'; import { ParticipantGroup } from '../participantGroup/entity/participantGroup.entity';
import { BaseEntity, Metadata } from '../_core/entity/_base.entity'; import { BaseEntity } from '../_core/entity/_base.entity';
export const typeOrmConfig: TypeOrmModuleOptions = { export const typeOrmConfig: TypeOrmModuleOptions = {
type: 'postgres', type: 'postgres',

View File

@ -1,5 +1,9 @@
import { IsString, IsOptional, IsArray, IsUUID, IsBoolean } from 'class-validator'; import { IsString, IsOptional, ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
import { CreateFormPageDto } from '../../formPage/dto/create-formPage.dto';
import { CreateParticipantDto } from '../../participant/dto/create-participant.dto';
import { CreateAttachmentDto } from '../../attachment/dto/create_attachment.dto';
export class CreateFormDto extends BaseDto { export class CreateFormDto extends BaseDto {
@IsString() @IsString()
@ -8,25 +12,21 @@ export class CreateFormDto extends BaseDto {
@IsString() @IsString()
description: string; description: string;
@IsBoolean()
isEnded: boolean;
@IsOptional()
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
pageIds?: string[]; @Type(() => CreateAttachmentDto)
@IsOptional() @IsOptional()
attachments?: CreateAttachmentDto[]; // Keep if attachments are still part of the DTO
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
participantIds?: string[]; @Type(() => CreateFormPageDto)
@IsOptional() @IsOptional()
pages?: CreateFormPageDto[];
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
attachmentIds?: string[]; @Type(() => CreateParticipantDto)
@IsOptional() @IsOptional()
@IsUUID('4') participants?: CreateParticipantDto[];
formResultId?: string; }
}

View File

@ -1,7 +1,11 @@
import { Entity, Column } from 'typeorm'; import { Entity, Column, OneToMany, ManyToOne } from 'typeorm';
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';
import { FormPage } from '../../formPage/entity/formPage.entity';
import { Participant } from '../../participant/entity/participant.entity';
import { Realm } from '../../realm/entity/realm.entity';
import { Attachment } from '../../attachment/entity/attachment.entity';
@Entity({ name: 'forms' }) @Entity()
export class Form extends BaseEntity { export class Form extends BaseEntity {
@Column() @Column()
title: string; title: string;
@ -9,18 +13,20 @@ export class Form extends BaseEntity {
@Column() @Column()
description: string; description: string;
@Column({ default: false }) // One-to-Many relationship with FormPage
isEnded: boolean; @OneToMany(() => FormPage, formPage => formPage.form, { cascade: true, eager: true })
pages: FormPage[];
@Column('simple-array', { nullable: true }) // One-to-Many relationship with Participant
pageIds: string[]; @OneToMany(() => Participant, participant => participant.form, { cascade: true, eager: true })
participants: Participant[];
@Column('simple-array', { nullable: true })
participantIds: string[];
@Column('simple-array', { nullable: true }) // One-to-Many relationship with Attachment
attachmentIds: string[]; @OneToMany(() => Attachment, attachment => attachment.form, { cascade: true, eager: true })
attachments: Attachment[];
@Column({ type: 'uuid', nullable: true }) @ManyToOne(() => Realm, realm => realm.forms, { /*cascade: true, eager: true */})
formResultId: string; realm: Realm;
}
}

View File

@ -1,5 +1,7 @@
import { IsString, IsArray, IsOptional, IsUUID } from 'class-validator'; import { IsString, ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
import { CreateFormSectionDto } from '../../formSection/dto/create-formSection.dto';
export class CreateFormPageDto extends BaseDto { export class CreateFormPageDto extends BaseDto {
@IsString() @IsString()
@ -8,8 +10,8 @@ export class CreateFormPageDto extends BaseDto {
@IsString() @IsString()
description: string; description: string;
@IsOptional()
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
formSectionIds?: string[]; @Type(() => CreateFormSectionDto)
formSections: CreateFormSectionDto[];
} }

View File

@ -1,6 +1,7 @@
import { Entity, Column } from 'typeorm'; import { Entity, Column, OneToMany, ManyToOne } from 'typeorm';
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';
import { FormSection } from '../../formSection/entity/formSection.entity';
import { Form } from '../../form/entity/form.entity';
@Entity() @Entity()
export class FormPage extends BaseEntity { export class FormPage extends BaseEntity {
@ -10,6 +11,12 @@ export class FormPage extends BaseEntity {
@Column() @Column()
description: string; description: string;
@Column('simple-array', { nullable: true }) // One-to-Many relationship with FormSection
formSectionIds: string[]; @OneToMany(() => FormSection, formSection => formSection.formPage, { cascade: true, eager: true })
formSections: FormSection[];
// Many-to-One relationship with Form
@ManyToOne(() => Form, form => form.pages) // Ensure 'pages' matches the property name in Form entity
form: Form;
} }

View File

@ -17,7 +17,7 @@ class CreateOpinionDto {
@Type(() => CreateOptionsDto) @Type(() => CreateOptionsDto)
options: CreateOptionsDto; options: CreateOptionsDto;
@IsUUID('4') @IsUUID()
questionId: string; questionId: string;
} }

View File

@ -1,7 +1,5 @@
import { Entity, Column, ManyToOne, OneToOne } from 'typeorm'; import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';
import { Realm } from '../../realm/entity/realm.entity';
import { Form } from '../../form/entity/form.entity';
// Options class (embedded) // Options class (embedded)
export class Options { export class Options {
@ -26,9 +24,6 @@ export class Opinion {
@Entity() @Entity()
export class FormResult extends BaseEntity { export class FormResult extends BaseEntity {
@Column({ type: 'uuid' })
formId: string;
@Column({ type: 'int', default: 0 }) @Column({ type: 'int', default: 0 })
numParticipants: number; numParticipants: number;

View File

@ -23,7 +23,7 @@ export class FormResultController {
} }
@Get(':id') @Get(':id')
async findOne( // just gets the data in database async findOne(
@Param('id', new ParseUUIDPipe()) id: string, @Param('id', new ParseUUIDPipe()) id: string,
): Promise<FormResult | null> { ): Promise<FormResult | null> {
return this.formResultService.findById(id); return this.formResultService.findById(id);
@ -43,11 +43,4 @@ export class FormResultController {
async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> { async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> {
return this.formResultService.remove(id); return this.formResultService.remove(id);
} }
@Get(':id/refresh')
async getFormStatistics( // updates data in database and then returns it
@Param('formId', new ParseUUIDPipe()) formId: string,
): Promise<FormResult> {
return this.formResultService.getFormStatistics(formId);
}
} }

View File

@ -3,13 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FormResultService } from './formResult.service'; import { FormResultService } from './formResult.service';
import { FormResultController } from './formResult.controller'; import { FormResultController } from './formResult.controller';
import { FormResult } from './entity/formResult.entity'; import { FormResult } from './entity/formResult.entity';
import { Form } from '../form/entity/form.entity';
import { Question } from '../question/entity/question.entity';
import { Answer } from '../answer/entity/answer.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([FormResult, Form, Question, Answer]), TypeOrmModule.forFeature([
FormResult,
]),
], ],
controllers: [FormResultController], controllers: [FormResultController],
providers: [FormResultService], providers: [FormResultService],

View File

@ -1,35 +1,15 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FormResult } from './entity/formResult.entity'; import { FormResult } from './entity/formResult.entity';
import { CreateFormResultDto } from './dto/create-formResult.dto'; import { CreateFormResultDto } from './dto/create-formResult.dto';
import { UpdateFormResultDto } from './dto/update-formResult.dto'; import { UpdateFormResultDto } from './dto/update-formResult.dto';
import { Answer } from 'src/answer/entity/answer.entity';
import { Question } from '../question/entity/question.entity';
import { Form } from '../form/entity/form.entity';
import { FormSection } from '../formSection/entity/formSection.entity';
import { FormPage } from '../formPage/entity/formPage.entity';
import { Repository, In } from 'typeorm';
@Injectable() @Injectable()
export class FormResultService { export class FormResultService {
constructor( constructor(
@InjectRepository(Form)
private readonly formRepository: Repository<Form>,
@InjectRepository(FormResult) @InjectRepository(FormResult)
private readonly formResultRepo: Repository<FormResult>, private readonly formResultRepo: Repository<FormResult>,
@InjectRepository(Question)
private readonly questionRepository: Repository<Question>,
@InjectRepository(Answer)
private readonly answerRepository: Repository<Answer>,
@InjectRepository(Form)
@InjectRepository(FormPage)
private readonly formPageRepository: Repository<FormPage>,
@InjectRepository(FormSection)
private readonly formSectionRepository: Repository<FormSection>,
@InjectRepository(Question)
@InjectRepository(Answer)
@InjectRepository(FormResult)
private readonly formResultRepository: Repository<FormResult>,
) {} ) {}
async create(data: CreateFormResultDto): Promise<FormResult> { async create(data: CreateFormResultDto): Promise<FormResult> {
@ -105,73 +85,4 @@ export class FormResultService {
throw new NotFoundException(`FormResult with ID "${id}" not found`); throw new NotFoundException(`FormResult with ID "${id}" not found`);
} }
} }
async getFormStatistics(formId: string): Promise<FormResult> {
// 1. Find the Form entity
const form = await this.formRepository.findOne({ where: { id: formId } });
if (!form) {
throw new NotFoundException(`Form with ID "${formId}" not found`);
}
// 2. Compute numQuestions
const pageIds = form.pageIds || [];
const formPages = await this.formPageRepository.find({ where: { id: In(pageIds) } });
const formSectionIds = formPages.flatMap(page => page.formSectionIds || []);
const formSections = await this.formSectionRepository.find({ where: { id: In(formSectionIds) } });
const questionIds = formSections.flatMap(section => section.questionIds || []);
const numQuestions = questionIds.length;
// 3. Compute numParticipants (distinct participantId from answers)
const numParticipantsResult = await this.answerRepository
.createQueryBuilder('answer')
.select('COUNT(DISTINCT answer.participantId)', 'count')
.where('answer.questionId IN (:...questionIds)', { questionIds: questionIds.length ? questionIds : ['none'] }) // Handle empty questionIds
.getRawOne();
const numParticipants = numParticipantsResult ? Number(numParticipantsResult.count) : 0;
// 4. Compute numAnswers
const numAnswers = await this.answerRepository.count({
where: { questionId: In(questionIds.length ? questionIds : ['none']) }, // Handle empty questionIds
});
// 5. Compute numCompleteParticipants (participants who answered all questions)
const subQuery = this.answerRepository
.createQueryBuilder('answer')
.select('answer.participantId')
.where('answer.questionId IN (:...questionIds)', { questionIds: questionIds.length ? questionIds : ['none'] }) // Handle empty questionIds
.groupBy('answer.participantId')
.having('COUNT(DISTINCT answer.questionId) = :numQuestions', { numQuestions: numQuestions || 1 }); // Avoid division by zero
const numCompleteParticipantsResult = await this.answerRepository.manager
.createQueryBuilder()
.select('COUNT(*)', 'count')
.from(`(${subQuery.getQuery()})`, 'subquery')
.setParameters(subQuery.getParameters())
.getRawOne();
const numCompleteParticipants = numCompleteParticipantsResult ? Number(numCompleteParticipantsResult.count) : 0;
// 6. Find or create FormResult for the Form
let formResult = await this.formResultRepository.findOne({ where: { formId } });
if (!formResult) {
formResult = this.formResultRepository.create({
formId, // Link to Form via formId
numQuestions,
numParticipants,
numAnswers,
numComplete: numCompleteParticipants,
opinions: [], // Initialize as empty or compute if needed
status: 'active',
});
} else {
formResult.numQuestions = numQuestions;
formResult.numParticipants = numParticipants;
formResult.numAnswers = numAnswers;
formResult.numComplete = numCompleteParticipants;
// Preserve existing opinions or update if needed
}
// 7. Save and return the FormResult
return await this.formResultRepository.save(formResult);
}
} }

View File

@ -1,19 +1,21 @@
import { IsString, IsOptional, IsUUID, ValidateNested, IsArray, IsEnum } from 'class-validator'; import { IsString, IsOptional, IsUUID, ValidateNested, IsArray, IsEnum, IsDateString } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
import { CreateAttachmentDto } from '../../attachment/dto/create_attachment.dto';
import { CreateQuestionDto } from '../../question/dto/create-question.dto';
// DTO for DisplayCondition // DTO for DisplayCondition
export class CreateDisplayConditionDto { export class CreateDisplayConditionDto {
@IsUUID('4') @IsUUID()
answer: string; // UUID referencing Answer.id answer: string;
@IsUUID('4') @IsUUID()
question: string; // UUID referencing Question.id question: string;
@IsEnum(['equal', 'contains', 'not_equal', 'not_contains']) @IsEnum(['equal', 'contains', 'not_equal', 'not_contains'])
relation: 'equal' | 'contains' | 'not_equal' | 'not_contains'; relation: 'equal' | 'contains' | 'not_equal' | 'not_contains';
} }
//\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\//\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\//\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/
export class CreateFormSectionDto extends BaseDto { export class CreateFormSectionDto extends BaseDto {
@IsString() @IsString()
title: string; title: string;
@ -21,19 +23,19 @@ export class CreateFormSectionDto extends BaseDto {
@IsString() @IsString()
description: string; description: string;
@IsOptional()
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
attachmentIds?: string[]; @Type(() => CreateAttachmentDto)
@IsOptional() @IsOptional()
@IsArray() attachments?: CreateAttachmentDto[];
@IsUUID('4', { each: true })
questionIds?: string[];
@IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => CreateDisplayConditionDto) @Type(() => CreateDisplayConditionDto)
displayCondition?: CreateDisplayConditionDto[]; displayCondition: CreateDisplayConditionDto[];
}
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateQuestionDto)
questions: CreateQuestionDto[];
}

View File

@ -1,8 +1,12 @@
import { Entity, Column } from 'typeorm'; // Removed Embeddable import // src/formSection/entity/formSection.entity.ts
import { Entity, Column, OneToMany, ManyToOne } from 'typeorm'; // Removed Embeddable import
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';
import { Attachment } from '../../attachment/entity/attachment.entity';
import { Question } from '../../question/entity/question.entity';
import { FormPage } from 'src/formPage/entity/formPage.entity';
// DisplayCondition is now a regular class, embedded using @Column(() => DisplayCondition) // DisplayCondition is now a regular class, embedded using @Column(() => DisplayCondition)
export class DisplayCondition { // needs clarification export class DisplayCondition {
@Column() @Column()
answer: string; // Assuming this is an ID reference to an Answer entity answer: string; // Assuming this is an ID reference to an Answer entity
@ -24,12 +28,19 @@ export class FormSection extends BaseEntity {
@Column() @Column()
description: string; description: string;
@Column('simple-array', { nullable: true }) // One-to-Many relationship with Attachment
attachmentIds: string[]; @OneToMany(() => Attachment, attachment => attachment.formSection, { cascade: true, eager: true })
attachments: Attachment[];
@Column('simple-array', { nullable: true }) // Using @Column(() => DisplayCondition) for embedded array of objects
questionIds: string[]; @Column(() => DisplayCondition)
@Column(() => DisplayCondition) // needs to be checked
displayCondition: DisplayCondition[]; displayCondition: DisplayCondition[];
// One-to-Many relationship with Question
@OneToMany(() => Question, question => question.formSection, { cascade: true, eager: true })
questions: Question[];
// Many-to-One relationship with FormPage
@ManyToOne(() => FormPage, formPage => formPage.formSections)
formPage: FormPage;
} }

View File

@ -1,11 +1,9 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { JwtMiddleware } from './middleware/jwtMiddleware';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors({ origin: 'http://localhost:3001' }); app.enableCors({ origin: 'http://localhost:3001' });
app.use(new JwtMiddleware().use);
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();

View File

@ -1,38 +0,0 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
import { validate as isValidUUID } from 'uuid';
@Injectable()
export class JwtMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid Authorization header');
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.decode(token) as any;
if (!decoded || !decoded.sub) {
throw new UnauthorizedException('Invalid token: missing sub');
}
if (!isValidUUID(decoded.sub)) {
throw new UnauthorizedException('Invalid token: sub is not a valid UUIDv4');
}
const { sub, realm_access, resource_access } = decoded;
const roles = [ // extract the role you wanna use in guards
...(realm_access?.roles || []),
// ...(resource_access?.account?.roles || []),
];
req['user'] = { sub, roles };
next();
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}
export interface AuthRequest extends Request {
user: { sub: string; roles: string[] };
}

View File

@ -6,4 +6,9 @@ import { Question } from 'src/question/entity/question.entity';
export class Option extends BaseEntity { export class Option extends BaseEntity {
@Column() @Column()
text: string; text: string;
@ManyToOne(() => Question, (question) => question.options)
question: Question;
} }

View File

@ -1,10 +1,9 @@
import { IsString, IsEnum, IsOptional, IsUUID } from 'class-validator'; import { IsString, IsEnum, IsOptional, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { MetadataDto } from '../../_core/dto/metadataEntry.dto';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
export class CreateParticipantDto extends BaseDto { export class CreateParticipantDto extends BaseDto {
@IsUUID('4')
userId: string;
@IsString() @IsString()
displayName: string; displayName: string;

View File

@ -1,5 +1,7 @@
import { Column, Entity } from 'typeorm'; import { Column, Entity, ManyToOne } from 'typeorm';
import { BaseEntity } from 'src/_core/entity/_base.entity'; import { BaseEntity } from 'src/_core/entity/_base.entity';
import { Form } from 'src/form/entity/form.entity';
import { Realm } from '../../realm/entity/realm.entity';
@Entity({ name: 'participants' }) @Entity({ name: 'participants' })
export class Participant extends BaseEntity { export class Participant extends BaseEntity {
@ -18,4 +20,12 @@ export class Participant extends BaseEntity {
@Column() @Column()
displayName: string; displayName: string;
// Many-to-One relationship with Form
@ManyToOne(() => Form, form => form.participants)
form: Form;
// Many-to-One relationship with Realm (if a participant belongs to a realm)
@ManyToOne(() => Realm, realm => realm.participants)
realm: Realm;
} }

View File

@ -1,9 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query, Request } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Param, Body, UsePipes, ValidationPipe, Query } from '@nestjs/common';
import { ParticipantService } from './participant.service'; import { ParticipantService } from './participant.service';
import { CreateParticipantDto } from './dto/create-participant.dto'; import { CreateParticipantDto } from './dto/create-participant.dto';
import { UpdateParticipantDto } from './dto/update-participant.dto'; import { UpdateParticipantDto } from './dto/update-participant.dto';
import { Participant } from './entity/participant.entity'; import { Participant } from './entity/participant.entity';
import { AuthRequest } from '../middleware/jwtMiddleware';
@Controller('participants') @Controller('participants')
export class ParticipantController { export class ParticipantController {
@ -11,8 +10,8 @@ export class ParticipantController {
@Post() @Post()
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() body: CreateParticipantDto, @Request() req: AuthRequest): Promise<Participant> { async create(@Body() body: CreateParticipantDto): Promise<Participant> {
return this.participantService.create(body, req.user); return this.participantService.create(body);
} }
@Get('findAll') @Get('findAll')

View File

@ -1,10 +1,11 @@
import { Injectable, InternalServerErrorException, ConflictException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CreateParticipantDto } from './dto/create-participant.dto'; import { CreateParticipantDto } from './dto/create-participant.dto';
import { Participant } from './entity/participant.entity'; import { Participant } from './entity/participant.entity';
import { UpdateParticipantDto } from './dto/update-participant.dto'; import { UpdateParticipantDto } from './dto/update-participant.dto';
import { AuthRequest } from '../middleware/jwtMiddleware'; import axios from 'axios';
import { decode } from 'jsonwebtoken';
@Injectable() @Injectable()
export class ParticipantService { export class ParticipantService {
@ -13,24 +14,51 @@ export class ParticipantService {
private readonly participantRepository: Repository<Participant>, private readonly participantRepository: Repository<Participant>,
) {} ) {}
async create(data: CreateParticipantDto, user: AuthRequest['user']): Promise<Participant> { async create(data: CreateParticipantDto): Promise<Participant> {
try { try {
const existingParticipant = await this.participantRepository.findOne({ where: { userId: user.sub } }); // Here we send a sample request, in final implementation, there'll be no need for that, we'll get the decoded token
if (existingParticipant) { const authResponse = await axios.post(
throw new ConflictException(`Participant with userId ${user.sub} already exists`); 'https://auth.didvan.com/realms/didvan/protocol/openid-connect/token',
new URLSearchParams({
client_id: 'didvan-app',
username: 'bob',
password: 'developer_password',
grant_type: 'password',
}),
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
},
);
// Decode the access token to extract the 'sub' claim
const token = authResponse.data.access_token;
const decodedToken: any = decode(token);
if (!decodedToken || !decodedToken.sub) {
throw new InternalServerErrorException('Failed to decode token or extract sub');
} }
// Check if participant with this sub already exists
const existingParticipant = await this.participantRepository.findOne({ where: { id: decodedToken.sub } });
if (existingParticipant) {
throw new InternalServerErrorException(`Participant with ID ${decodedToken.sub} already exists`);
}
// Create the participant with the 'sub' as the ID
const participant = this.participantRepository.create({ const participant = this.participantRepository.create({
...data, ...data,
userId: user.sub, // id: , let it assign a random id
role: data.role || 'user', userId: decodedToken.sub, // Set userId to the same sub
metadata: data.metadata
? { entries: data.metadata.entries }
: { entries: [] },
status: data.status || 'active',
}); });
// Save the participant to the database
return await this.participantRepository.save(participant); return await this.participantRepository.save(participant);
} catch (error) { } catch (error) {
if (error instanceof ConflictException) {
throw error;
}
throw new InternalServerErrorException(`Failed to create participant: ${error.message}`); throw new InternalServerErrorException(`Failed to create participant: ${error.message}`);
} }
} }

View File

@ -1,4 +1,6 @@
import { IsString, IsEnum } from 'class-validator'; import { IsString, IsEnum, IsOptional, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { MetadataDto } from '../../_core/dto/metadataEntry.dto';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
export class CreateParticipantGroupDto extends BaseDto{ export class CreateParticipantGroupDto extends BaseDto{

View File

@ -1,6 +1,9 @@
import { import {
Entity, Entity,
PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';

View File

@ -4,9 +4,12 @@ import {
IsOptional, IsOptional,
IsArray, IsArray,
IsEnum, IsEnum,
IsUUID, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
import { CreateOptionDto } from '../../option/dto/create-option.dto';
import { CreateAttachmentDto } from '../../attachment/dto/create_attachment.dto';
export class CreateQuestionDto extends BaseDto { export class CreateQuestionDto extends BaseDto {
@IsString() @IsString()
@ -29,11 +32,13 @@ export class CreateQuestionDto extends BaseDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
optionIds?: string[]; @Type(() => CreateOptionDto)
options?: CreateOptionDto[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
attachmentIds?: string[]; @Type(() => CreateAttachmentDto)
} attachments?: CreateAttachmentDto[];
}

View File

@ -35,9 +35,15 @@ export class Question extends BaseEntity {
}) })
validationRules?: string; validationRules?: string;
@Column('simple-array', { nullable: true }) @OneToMany(() => Option, (option) => option.question, { cascade: true })
optionIds: string[]; options: Option[];
@Column('simple-array', { nullable: true }) @OneToMany(() => Attachment, (attachment) => attachment.question, { cascade: true })
attachmentIds: string[]; attachments: Attachment[];
@OneToMany(() => Answer, answer => answer.question, { cascade: true })
answers: Answer[]; // Answers to this question
@ManyToOne(() => FormSection, formSection => formSection.questions)
formSection: FormSection;
} }

View File

@ -1,5 +1,8 @@
import { IsString, IsOptional, IsArray, IsUUID } from 'class-validator'; import { IsString, IsOptional, ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { BaseDto } from '../../_core/dto/base.dto'; import { BaseDto } from '../../_core/dto/base.dto';
import { CreateFormDto } from '../../form/dto/create-form.dto';
import { CreateParticipantDto } from '../../participant/dto/create-participant.dto';
export class CreateRealmDto extends BaseDto { export class CreateRealmDto extends BaseDto {
@IsString() @IsString()
@ -8,16 +11,19 @@ export class CreateRealmDto extends BaseDto {
@IsString() @IsString()
description: string; description: string;
@IsOptional()
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @ValidateNested({ each: true })
formIds?: string[]; @Type(() => CreateFormDto)
@IsOptional() @IsOptional()
@IsArray() forms?: CreateFormDto[];
@IsUUID('4', { each: true }) // I'd say it's not needed, cause form has participants
participantIds?: string[];
@IsUUID('4') @IsArray()
ownerId: string; @ValidateNested({ each: true })
} @Type(() => CreateParticipantDto)
@IsOptional()
participants?: CreateParticipantDto[];
// @ValidateNested() // Not IsArray, as it's a single owner
// @Type(() => CreateParticipantDto)
// owner: CreateParticipantDto;
}

View File

@ -1,5 +1,7 @@
import { Entity, Column } from 'typeorm'; import { Entity, Column, OneToMany, OneToOne, JoinColumn} from 'typeorm';
import { BaseEntity } from '../../_core/entity/_base.entity'; import { BaseEntity } from '../../_core/entity/_base.entity';
import { Form } from '../../form/entity/form.entity';
import { Participant } from '../../participant/entity/participant.entity';
@Entity() @Entity()
export class Realm extends BaseEntity { export class Realm extends BaseEntity {
@ -9,12 +11,14 @@ export class Realm extends BaseEntity {
@Column() @Column()
description: string; description: string;
@Column('simple-array', { nullable: true }) @OneToMany(() => Form, form => form.realm, { cascade: true, eager: true })
formIds: string[]; forms: Form[];
@Column('simple-array', { nullable: true }) // I'd say it's not needed, cause form has participants @OneToMany(() => Participant, participant => participant.realm, { cascade: true, eager: true })
participantIds: string[]; participants: Participant[];
@Column({ type: 'uuid' }) @OneToOne(() => Participant, { cascade: true, eager: true })
ownerId: string; @JoinColumn({ name: 'ownerId', referencedColumnName: 'userId' }) // be explicit
} owner: Participant;
}

View File

@ -49,7 +49,7 @@ export class RealmService {
displayName: data.title + ' Owner', // Default displayName displayName: data.title + ' Owner', // Default displayName
role: 'admin', // Default role for owner role: 'admin', // Default role for owner
userId: decodedToken.sub, userId: decodedToken.sub,
metadata: data.metadata , metadata: data.metadata ? { entries: data.metadata.entries } : { entries: [] },
// status: 'active', // status: 'active',
}); });
participant = await this.participantRepo.save(participant); participant = await this.participantRepo.save(participant);
@ -58,7 +58,7 @@ export class RealmService {
// Create the realm with the participant as the owner // Create the realm with the participant as the owner
const realm = this.realmRepo.create({ const realm = this.realmRepo.create({
...data, ...data,
ownerId: participant.userId, // Set owner to the participant with userId = sub owner: participant, // Set owner to the participant with userId = sub
}); });
return await this.realmRepo.save(realm); return await this.realmRepo.save(realm);