Compare commits

..
17 Commits
110 changed files with 7409 additions and 1069 deletions
+4452 -116
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -30,6 +30,9 @@
"seed:prod": "cross-env NODE_ENV=production node dist/database/seeds/seed.js" "seed:prod": "cross-env NODE_ENV=production node dist/database/seeds/seed.js"
}, },
"dependencies": { "dependencies": {
"@adminjs/express": "^5.1.0",
"@adminjs/nestjs": "^5.1.1",
"@adminjs/typeorm": "^4.0.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -40,17 +43,24 @@
"@nestjs/swagger": "^11.2.6", "@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@openrouter/sdk": "^1.3.33",
"adminjs": "^6.8.7",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express-formidable": "^1.2.0",
"express-session": "^1.19.0",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"joi": "^18.0.2", "joi": "^18.0.2",
"nestjs-i18n": "^10.6.0", "nestjs-i18n": "^10.6.0",
"nestjs-pino": "^5.1.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.18.0", "pg": "^8.18.0",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.28" "typeorm": "^0.3.28"
@@ -63,6 +73,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-session": "^1.19.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@@ -73,6 +84,7 @@
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
+21 -1
View File
@@ -23,6 +23,12 @@ import { ScoringModule } from './modules/scoring/scoring.module';
import { AdminModule } from './modules/admin/admin.module'; import { AdminModule } from './modules/admin/admin.module';
import { AppThrottlerGuard } from './common/guards/app-throttler.guard'; import { AppThrottlerGuard } from './common/guards/app-throttler.guard';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { LoggingModule } from './common/modules/logging/logging.module';
import { ChatbotModule } from './modules/chatbot/chatbot.module';
import { ConversationModule } from './modules/conversation/conversation.module';
import { AdminPanelModule } from './modules/admin-panel/admin-panel.module';
import openRouterConfig from './config/open-router.config';
import adminConfig from './config/admin.config';
const ENV = process.env.NODE_ENV; const ENV = process.env.NODE_ENV;
@@ -47,7 +53,14 @@ const ENV = process.env.NODE_ENV;
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
envFilePath: !ENV ? '.env' : `.env.${ENV}`, envFilePath: !ENV ? '.env' : `.env.${ENV}`,
load: [appConfig, databaseConfig, jwtConfig, redisConfig], load: [
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
openRouterConfig,
adminConfig,
],
validationSchema: environmentValidation, validationSchema: environmentValidation,
}), }),
@@ -79,6 +92,9 @@ const ENV = process.env.NODE_ENV;
// ]), // ]),
ThrottlerModule.forRoot([]), ThrottlerModule.forRoot([]),
// Logging
LoggingModule,
/** /**
* Local Modules * Local Modules
*/ */
@@ -92,6 +108,10 @@ const ENV = process.env.NODE_ENV;
SubmissionsModule, SubmissionsModule,
ScoringModule, ScoringModule,
AdminModule, AdminModule,
AdminPanelModule,
LoggingModule,
ChatbotModule,
ConversationModule,
], ],
providers: [ providers: [
{ {
+36 -4
View File
@@ -8,6 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { I18nService } from 'nestjs-i18n'; import { I18nService } from 'nestjs-i18n';
import { PinoLogger } from 'nestjs-pino';
@Catch() @Catch()
export class GlobalExceptionFilter implements ExceptionFilter { export class GlobalExceptionFilter implements ExceptionFilter {
@@ -16,6 +17,11 @@ export class GlobalExceptionFilter implements ExceptionFilter {
* Inject I18n Service * Inject I18n Service
*/ */
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
/**
* Inject Pino Logger
*/
private readonly logger: PinoLogger,
) {} ) {}
catch(exception: any, host: ArgumentsHost) { catch(exception: any, host: ArgumentsHost) {
@@ -40,16 +46,42 @@ export class GlobalExceptionFilter implements ExceptionFilter {
args: (exceptionResponse as any).params, args: (exceptionResponse as any).params,
}); });
this.logException(request, status, exception);
response.status(status).json({ response.status(status).json({
success: false, success: false,
statusCode: status, statusCode: status,
message, message,
path: request.url, path: request.url,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
// ...(process.env.NODE_ENV !== 'production' && { ...(process.env.NODE_ENV !== 'production' && {
raw: exceptionResponse, raw: exceptionResponse,
stack: exception instanceof Error ? exception.stack : undefined, stack: exception instanceof Error ? exception.stack : undefined,
// }), }),
}); });
} }
logException(request: Request, status: number, exception?: any) {
if (status >= 500) {
this.logger.error(
{
err: exception,
stack: exception instanceof Error ? exception.stack : undefined,
method: request.method,
url: request.url,
statusCode: status,
},
'Unhandled server exception',
);
} else {
this.logger.warn(
{
method: request.method,
url: request.url,
statusCode: status,
},
'HTTP exception',
);
}
}
} }
@@ -0,0 +1,81 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
@Module({
imports: [
LoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
pinoHttp: {
level: configService.get<string>('app.logLevel') ?? 'info',
genReqId: (req) => {
const requestId = req.headers['x-request-id'];
if (typeof requestId === 'string' && requestId.length > 0) {
return requestId;
}
return crypto.randomUUID();
},
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["set-cookie"]',
'password',
'accessToken',
'refreshToken',
'otp',
],
censor: '[REDACTED]',
},
// serializers: {
// req: (req) => ({
// method: req.method,
// url: req.url,
// remoteAddress: req.remoteAddress,
// }),
// res: (res) => ({
// statusCode: res.statusCode,
// }),
// },
customProps: (req) => ({
requestId: req.id,
}),
customLogLevel: (req, res, error) => {
if (error || res.statusCode >= 500) {
return 'error';
}
if (res.statusCode >= 400) {
return 'warn';
}
return 'info';
},
transport:
configService.get<string>('app.environment') !== 'production'
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
},
}),
}),
],
exports: [LoggerModule],
})
export class LoggingModule {}
+1 -2
View File
@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RedisService } from './providers/redis.service'; import { RedisService } from './providers/redis.service';
import { REDIS } from './constants/redis.constants'; import { REDIS } from './constants/redis.constants';
@@ -15,6 +13,7 @@ import Redis from 'ioredis';
return new Redis({ return new Redis({
host: configService.get<string>('redis.host'), host: configService.get<string>('redis.host'),
port: configService.get<number>('redis.port'), port: configService.get<number>('redis.port'),
password: configService.get<string>('redis.password'),
}); });
}, },
}, },
+21
View File
@@ -7,3 +7,24 @@ export const normalizePhone = (phone: string): string => {
return phone; return phone;
}; };
type GenerateCodeOptions = {
prefix?: string;
length?: number;
};
export const generateCode = (options?: GenerateCodeOptions) => {
const prefix = options?.prefix || '';
const length = options?.length || 5;
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let random = '';
for (let i = 0; i < length; i++) {
random += characters.charAt(Math.floor(Math.random() * characters.length));
}
return `${prefix}${random}`;
};
+10
View File
@@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('admin', () => ({
enabled: process.env.ADMIN_PANEL_ENABLED !== 'false',
rootPath: process.env.ADMIN_ROOT_PATH || '/admin',
cookiePassword: process.env.ADMIN_COOKIE_PASSWORD,
cookieName: 'heala.admin.sid',
sessionTtlMinutes: parseInt(process.env.ADMIN_SESSION_TTL_MINUTES || '120'),
secureCookies: process.env.NODE_ENV === 'production',
}));
+1
View File
@@ -9,4 +9,5 @@ export default registerAs('app', () => ({
swaggerServers: process.env.SWAGGER_SERVERS?.split(',').map((origin) => swaggerServers: process.env.SWAGGER_SERVERS?.split(',').map((origin) =>
origin.trim(), origin.trim(),
), ),
logLevel: process.env.LOG_LEVEL,
})); }));
+9
View File
@@ -18,7 +18,16 @@ export default Joi.object({
JWT_AUDIENCE: Joi.string().required(), JWT_AUDIENCE: Joi.string().required(),
REDIS_HOST: Joi.string().required(), REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().port().default(6379), REDIS_PORT: Joi.number().port().default(6379),
REDIS_PASSWORD: Joi.string().required(),
CORS_ORIGINS: Joi.string().required(), CORS_ORIGINS: Joi.string().required(),
CORS_SUBDOMAIN: Joi.string().required(), CORS_SUBDOMAIN: Joi.string().required(),
SWAGGER_SERVERS: Joi.string().required(), SWAGGER_SERVERS: Joi.string().required(),
LOG_LEVEL: Joi.string().default('info'),
OPENROUTER_API_KEY: Joi.string().required(),
OPENROUTER_MODEL: Joi.string().required(),
ADMIN_PANEL_ENABLED: Joi.string().valid('true', 'false').default('true'),
// Required at runtime when the panel is enabled (validated in AdminPanelSetup)
ADMIN_COOKIE_PASSWORD: Joi.string().allow('').default(''),
ADMIN_ROOT_PATH: Joi.string().default('/admin'),
ADMIN_SESSION_TTL_MINUTES: Joi.number().integer().min(5).default(120),
}); });
+6
View File
@@ -0,0 +1,6 @@
import { registerAs } from '@nestjs/config';
export default registerAs('openRouter', () => ({
apiKey: process.env.OPENROUTER_API_KEY,
model: process.env.OPENROUTER_MODEL,
}));
+1
View File
@@ -3,4 +3,5 @@ import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({ export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10), port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
})); }));
+306
View File
@@ -0,0 +1,306 @@
import { Choice } from '@/modules/questions/entities/choice.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { EntityManager } from 'typeorm';
export async function seedChoices(
manager: EntityManager,
questions: Record<string, Question>,
) {
const choiceRepository = manager.getRepository(Choice);
const { q1, q2, q3, q4, q5, q6, q7, q8, q10, q12 } = questions;
/*
* ---------------------------------------------------------
* QUESTION 1 CHOICES
* ---------------------------------------------------------
*/
const q1Yes = choiceRepository.create({
question: q1,
text: 'بله',
order: 1,
score: 1,
});
const q1No = choiceRepository.create({
question: q1,
text: 'خیر',
order: 2,
score: 0,
});
const q1Choices = [q1Yes, q1No];
await choiceRepository.save(q1Choices);
/*
* ---------------------------------------------------------
* QUESTION 2 CHOICES
* ---------------------------------------------------------
*/
const q2Yes = choiceRepository.create({
question: q2,
text: 'بله',
order: 1,
score: 1,
});
const q2No = choiceRepository.create({
question: q2,
text: 'خیر',
order: 2,
score: 0,
});
const q2NotSure = choiceRepository.create({
question: q2,
text: 'مطمئن نیستم',
order: 3,
score: 1,
});
const q2Choices = [q2Yes, q2No, q2NotSure];
await choiceRepository.save(q2Choices);
/*
* ---------------------------------------------------------
* QUESTION 3 CHOICES
* ---------------------------------------------------------
*/
const q3Texts = [
'اچ‌آی‌وی (HIV)',
'سیفلیس',
'سوزاک',
'عفونت غیراختصاصی مجرای ادرار',
'هپاتیت B',
'هپاتیت C',
'کلامیدیا',
'زگیل تناسلی',
// 'سایر',
// 'مطمئن نیستم',
// 'هیچ کدام',
];
const q3Choices = q3Texts.map((text, index) =>
choiceRepository.create({
question: q3,
text,
order: index + 1,
score: 2,
}),
);
await choiceRepository.save(q3Choices);
/*
* ---------------------------------------------------------
* QUESTION 4 CHOICES
* ---------------------------------------------------------
*/
const q4Texts = [
'سیفلیس',
'سوزاک',
'عفونت غیراختصاصی مجرای ادرار',
'هپاتیت B',
'هپاتیت C',
'کلامیدیا',
'زگیل تناسلی',
// 'سایر',
// 'مطمئن نیستم',
// 'هیچ کدام',
];
const q4Choices = q4Texts.map((text, index) =>
choiceRepository.create({
question: q4,
text,
order: index + 1,
score: 1,
}),
);
await choiceRepository.save(q4Choices);
/*
* ---------------------------------------------------------
* QUESTION 5 CHOICES
* ---------------------------------------------------------
*/
const q5Yes = choiceRepository.create({
question: q5,
text: 'بله',
order: 1,
score: 1,
});
const q5No = choiceRepository.create({
question: q5,
text: 'خیر',
order: 2,
score: 0,
});
const q5NotSure = choiceRepository.create({
question: q5,
text: 'مطمئن نیستم',
order: 3,
score: 1,
});
const q5Choices = [q5Yes, q5No, q5NotSure];
await choiceRepository.save(q5Choices);
/*
* ---------------------------------------------------------
* QUESTION 6 CHOICES
* ---------------------------------------------------------
*/
const q6Yes = choiceRepository.create({
question: q6,
text: 'بله',
order: 1,
score: 1,
});
const q6No = choiceRepository.create({
question: q6,
text: 'خیر',
order: 2,
score: 0,
});
const q6NotSure = choiceRepository.create({
question: q6,
text: 'مطمئن نیستم',
order: 3,
score: 1,
});
const q6Choices = [q6Yes, q6No, q6NotSure];
await choiceRepository.save(q6Choices);
/*
* ---------------------------------------------------------
* QUESTION 7 CHOICES
* ---------------------------------------------------------
*/
const q7Yes = choiceRepository.create({
question: q7,
text: 'بله',
order: 1,
score: 2,
});
const q7No = choiceRepository.create({
question: q7,
text: 'خیر',
order: 2,
score: 0,
});
const q7Choices = [q7Yes, q7No];
await choiceRepository.save(q7Choices);
/*
* ---------------------------------------------------------
* QUESTION 8 CHOICES
* ---------------------------------------------------------
*/
const q8Yes = choiceRepository.create({
question: q8,
text: 'بله',
order: 1,
score: 2,
});
const q8No = choiceRepository.create({
question: q8,
text: 'خیر',
order: 2,
score: 0,
});
const q8Choices = [q8Yes, q8No];
await choiceRepository.save(q8Choices);
/*
* ---------------------------------------------------------
* QUESTION 10 CHOICES
* ---------------------------------------------------------
*/
const q10Yes = choiceRepository.create({
question: q10,
text: 'بله',
order: 1,
score: 1,
});
const q10No = choiceRepository.create({
question: q10,
text: 'خیر',
order: 2,
score: 0,
});
const q10Choices = [q10Yes, q10No];
await choiceRepository.save(q10Choices);
/*
* ---------------------------------------------------------
* QUESTION 12 CHOICES
* ---------------------------------------------------------
*/
const q12Always = choiceRepository.create({
question: q12,
text: 'همیشه با همهٔ شرکای جنسی‌ام از کاندوم استفاده کرده‌ام.',
order: 1,
score: 0,
});
const q12Sometimes = choiceRepository.create({
question: q12,
text: 'گاهی با شرکای جنسی‌ام از کاندوم استفاده کرده‌ام.',
order: 2,
score: 2,
});
const q12Never = choiceRepository.create({
question: q12,
text: 'هیچ‌وقت با هیچ‌کدام از شرکای جنسی‌ام از کاندوم استفاده نکرده‌ام.',
order: 3,
score: 5,
});
const q12NotApplicable = choiceRepository.create({
question: q12,
text: 'سؤال در مورد من صحت ندارد؛ چون که رابطهٔ واژنی یا مقعدی نداشته‌ام.',
order: 4,
score: 1,
});
const q12NotSure = choiceRepository.create({
question: q12,
text: 'مطمئن نیستم.',
order: 5,
score: 2,
});
const q12Choices = [
q12Always,
q12Sometimes,
q12Never,
q12NotApplicable,
q12NotSure,
];
await choiceRepository.save(q12Choices);
return { q8Yes, q10Yes };
}
@@ -0,0 +1,77 @@
import { QuestionRangeRule } from '@/modules/questions/entities/question-range-rule.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { EntityManager } from 'typeorm';
export async function seedQuestionRangeRules(
manager: EntityManager,
questions: Record<string, Question>,
) {
const rangeRuleRepository = manager.getRepository(QuestionRangeRule);
const { q9, q11 } = questions;
/*
* ---------------------------------------------------------
* QUESTION 9 RANGE RULES
* ---------------------------------------------------------
*
* 1 person => 2
* 2 people => 3
* 3 or more => 5
*/
await rangeRuleRepository.save([
rangeRuleRepository.create({
question: q9,
min: 1,
max: 1,
score: 2,
}),
rangeRuleRepository.create({
question: q9,
min: 2,
max: 2,
score: 3,
}),
rangeRuleRepository.create({
question: q9,
min: 3,
max: 2147483647,
score: 5,
}),
]);
/*
* ---------------------------------------------------------
* QUESTION 11 RANGE RULES
* ---------------------------------------------------------
*
* 1 person => 1
* 2 people => 2
* 3 or more => 3
*/
await rangeRuleRepository.save([
rangeRuleRepository.create({
question: q11,
min: 1,
max: 1,
score: 1,
}),
rangeRuleRepository.create({
question: q11,
min: 2,
max: 2,
score: 2,
}),
rangeRuleRepository.create({
question: q11,
min: 3,
max: 2147483647,
score: 3,
}),
]);
}
+161
View File
@@ -0,0 +1,161 @@
import { Question } from '@/modules/questions/entities/question.entity';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { Test } from '@/modules/tests/entities/test.entity';
import { EntityManager } from 'typeorm';
export async function seedQuestions(manager: EntityManager, savedTest: Test) {
const questionRepository = manager.getRepository(Question);
/*
* ---------------------------------------------------------
* QUESTIONS
* ---------------------------------------------------------
*/
const q1 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 1,
weight: 1,
required: true,
content:
'آیا اخیراً علائمی داشته‌اید که فکر می‌کنید ممکن است ناشی از عفونت‌های منتقله از راه جنسی باشند؟',
metadata: {},
});
const q2 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 2,
weight: 1,
required: true,
content:
'آیا تا به حال پزشک یا یکی از کادر درمان به شما گفته است که به یک یا چند بیماری مقاربتی مبتلا شده‌اید؟',
metadata: {},
});
const q3 = questionRepository.create({
test: savedTest,
type: QuestionType.MULTI_CHOICE,
order: 3,
weight: 1,
required: true,
content:
'تاکنون به کدام‌یک از بیماری‌های مقاربتی مبتلا بوده‌اید؟ (می‌توانید بیش از یک گزینه را انتخاب کنید.)',
metadata: {},
});
const q4 = questionRepository.create({
test: savedTest,
type: QuestionType.MULTI_CHOICE,
order: 4,
weight: 1,
required: true,
content:
'آیا اخیراً با فردی که به یکی از بیماری‌های زیر مبتلا بوده، رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q5 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 5,
weight: 1,
required: true,
content: 'آیا تاکنون ماده‌ای غیرقانونی را به صورت تزریقی مصرف کرده‌اید؟',
metadata: {},
});
const q6 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 6,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا با فردی با تابعیت غیر ایرانی رابطهٔ جنسی داشته‌اید؟ (مثلاً هنگام سفر به خارج از کشور رابطهٔ جنسی داشته‌اید، یا در ایران با فردی از کشور دیگری رابطه داشته‌اید؟)',
metadata: {},
});
const q7 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 7,
weight: 1,
required: true,
content: 'آیا در حال حاضر به عنوان کارگر جنسی فعالیت می‌کنید؟',
metadata: {},
});
const q8 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 8,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا با یک فرد همجنس رابطهٔ جنسی داشته‌اید؟ (مثلاً مرد با مرد)',
metadata: {},
});
const q9 = questionRepository.create({
test: savedTest,
type: QuestionType.NUMERIC,
order: 9,
weight: 1,
required: true,
content: 'در ۱۲ ماه گذشته، با چند فرد از همجنس خود رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q10 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 10,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا با یک فرد از جنس مخالف رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q11 = questionRepository.create({
test: savedTest,
type: QuestionType.NUMERIC,
order: 11,
weight: 1,
required: true,
content: 'در ۱۲ ماه گذشته، با چند نفر از جنس مخالف رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q12 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 12,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا هنگام رابطهٔ واژنی یا مقعدی از کاندوم استفاده کرده‌اید؟',
metadata: {},
});
const questions = {
q1,
q2,
q3,
q4,
q5,
q6,
q7,
q8,
q9,
q10,
q11,
q12,
};
await questionRepository.save(Object.values(questions));
return questions;
}
+41
View File
@@ -0,0 +1,41 @@
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
import { Test } from '@/modules/tests/entities/test.entity';
import { EntityManager } from 'typeorm';
export async function seedResultRange(manager: EntityManager, savedTest: Test) {
const resultRangeRepository = manager.getRepository(ResultRange);
await resultRangeRepository.save([
resultRangeRepository.create({
test: savedTest,
minScore: 0,
maxScore: 1,
title: 'خطر پایین',
description: 'امتیاز شما در محدوده خطر پایین قرار دارد.',
}),
resultRangeRepository.create({
test: savedTest,
minScore: 2,
maxScore: 4,
title: 'خطر متوسط',
description: 'امتیاز شما در محدوده خطر متوسط قرار دارد.',
}),
resultRangeRepository.create({
test: savedTest,
minScore: 5,
maxScore: 7,
title: 'خطر بالا',
description: 'امتیاز شما در محدوده خطر بالا قرار دارد.',
}),
resultRangeRepository.create({
test: savedTest,
minScore: 8,
maxScore: 60,
title: 'خطر بسیار بالا',
description: 'امتیاز شما در محدوده خطر بسیار بالا قرار دارد.',
}),
]);
}
+39 -837
View File
@@ -1,853 +1,36 @@
import * as bcrypt from 'bcrypt';
import dataSource from '../data-source'; import dataSource from '../data-source';
import { User } from '../../modules/users/entities/user.entity';
import { Test } from '../../modules/tests/entities/test.entity'; import { Test } from '../../modules/tests/entities/test.entity';
import { Question } from '../../modules/questions/entities/question.entity';
import { Choice } from '../../modules/questions/entities/choice.entity';
import { QuestionRangeRule } from '../../modules/questions/entities/question-range-rule.entity';
import { QuestionTransition } from '../../modules/questions/entities/question-transition.entity';
import { Role } from '@/common/enums/roles.enum'; import { seedSuperuser } from './superuser.seed';
import { QuestionType } from '../../modules/questions/enums/question-type.enum'; import { seedTests } from './tests.seed';
import { AccessType } from '@/modules/tests/enums/access-types.enum'; import { seedQuestions } from './questions.seed';
import { TestStatus } from '../../modules/tests/enums/test-status.enum'; import { seedChoices } from './choices.seed';
import { QuestionTransitionType } from '../../modules/questions/enums/question-transition-type.enum'; import { seedQuestionRangeRules } from './question-range-rules.seed';
import { seedTransitions } from './transitions.seed';
import { seedResultRange } from './result-range.seed';
const TEST_TITLE = 'تست ارزیابی خطر ابتلا به عفونت‌های منتقله از راه جنسی'; async function prepareTests() {
const SUPERUSER_PHONE = process.env.SUPERUSER_PHONE;
const SUPERUSER_PASSWORD = process.env.SUPERUSER_PASSWORD;
async function seedSuperuser() {
if (!SUPERUSER_PHONE || !SUPERUSER_PASSWORD) {
console.log(
'SUPERUSER_PHONE or SUPERUSER_PASSWORD is not defined. Skipping superuser seed.',
);
return;
}
const userRepository = dataSource.getRepository(User);
const existingUser = await userRepository.findOne({
where: {
phone: SUPERUSER_PHONE,
},
});
if (existingUser) {
if (existingUser.role !== Role.SUPERUSER) {
existingUser.role = Role.SUPERUSER;
await userRepository.save(existingUser);
console.log(`User ${SUPERUSER_PHONE} promoted to SUPERUSER.`);
} else {
console.log(`Superuser ${SUPERUSER_PHONE} already exists.`);
}
return;
}
const passwordHash = await bcrypt.hash(SUPERUSER_PASSWORD, 12);
const superuser = userRepository.create({
phone: SUPERUSER_PHONE,
password: passwordHash,
role: Role.SUPERUSER,
});
await userRepository.save(superuser);
console.log(`Superuser ${SUPERUSER_PHONE} created successfully.`);
}
async function seedTest() {
const result = await dataSource.transaction(async (manager) => { const result = await dataSource.transaction(async (manager) => {
const testRepository = manager.getRepository(Test); const seedTestResult = await seedTests(manager);
const questionRepository = manager.getRepository(Question);
const choiceRepository = manager.getRepository(Choice);
const rangeRuleRepository = manager.getRepository(QuestionRangeRule);
const transitionRepository = manager.getRepository(QuestionTransition);
const existingTest = await testRepository.findOne({ let savedTest: Test;
where: {
title: TEST_TITLE,
},
});
if (existingTest) { if (seedTestResult instanceof Test) {
return { savedTest = seedTestResult;
created: false, } else {
testId: existingTest.id, return seedTestResult;
};
} }
/* const questions = await seedQuestions(manager, savedTest);
* ---------------------------------------------------------
* TEST
* ---------------------------------------------------------
*/
const test = testRepository.create({ const choices = await seedChoices(manager, questions);
title: TEST_TITLE,
description:
'این تست برای ارزیابی عوامل مرتبط با خطر ابتلا به عفونت‌های منتقله از راه جنسی طراحی شده است.',
status: TestStatus.PUBLISHED,
accessType: AccessType.FREE,
isActive: true,
questionsCount: 12,
});
const savedTest = await testRepository.save(test); await seedQuestionRangeRules(manager, questions);
/* await seedTransitions(manager, savedTest, questions, choices);
* ---------------------------------------------------------
* QUESTIONS
* ---------------------------------------------------------
*/
const q1 = questionRepository.create({ await seedResultRange(manager, savedTest);
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 1,
weight: 1,
required: true,
content:
'آیا اخیراً علائمی داشته‌اید که فکر می‌کنید ممکن است ناشی از عفونت‌های منتقله از راه جنسی باشند؟',
metadata: {},
});
const q2 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 2,
weight: 1,
required: true,
content:
'آیا تا به حال پزشک یا یکی از کادر درمان به شما گفته است که به یک یا چند بیماری مقاربتی مبتلا شده‌اید؟',
metadata: {},
});
const q3 = questionRepository.create({
test: savedTest,
type: QuestionType.MULTI_CHOICE,
order: 3,
weight: 1,
required: true,
content:
'تاکنون به کدام‌یک از بیماری‌های مقاربتی مبتلا بوده‌اید؟ (می‌توانید بیش از یک گزینه را انتخاب کنید.)',
metadata: {},
});
const q4 = questionRepository.create({
test: savedTest,
type: QuestionType.MULTI_CHOICE,
order: 4,
weight: 1,
required: true,
content:
'آیا اخیراً با فردی که به یکی از بیماری‌های زیر مبتلا بوده، رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q5 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 5,
weight: 1,
required: true,
content: 'آیا تاکنون ماده‌ای غیرقانونی را به صورت تزریقی مصرف کرده‌اید؟',
metadata: {},
});
const q6 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 6,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا با فردی با تابعیت غیر ایرانی رابطهٔ جنسی داشته‌اید؟ (مثلاً هنگام سفر به خارج از کشور رابطهٔ جنسی داشته‌اید، یا در ایران با فردی از کشور دیگری رابطه داشته‌اید؟)',
metadata: {},
});
const q7 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 7,
weight: 1,
required: true,
content: 'آیا در حال حاضر به عنوان کارگر جنسی فعالیت می‌کنید؟',
metadata: {},
});
const q8 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 8,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا با یک فرد همجنس رابطهٔ جنسی داشته‌اید؟ (مثلاً مرد با مرد)',
metadata: {},
});
const q9 = questionRepository.create({
test: savedTest,
type: QuestionType.NUMERIC,
order: 9,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، با چند فرد از همجنس خود رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q10 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 10,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا با یک فرد از جنس مخالف رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q11 = questionRepository.create({
test: savedTest,
type: QuestionType.NUMERIC,
order: 11,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، با چند نفر از جنس مخالف رابطهٔ جنسی داشته‌اید؟',
metadata: {},
});
const q12 = questionRepository.create({
test: savedTest,
type: QuestionType.SINGLE_CHOICE,
order: 12,
weight: 1,
required: true,
content:
'در ۱۲ ماه گذشته، آیا هنگام رابطهٔ واژنی یا مقعدی از کاندوم استفاده کرده‌اید؟',
metadata: {},
});
await questionRepository.save([
q1,
q2,
q3,
q4,
q5,
q6,
q7,
q8,
q9,
q10,
q11,
q12,
]);
/*
* ---------------------------------------------------------
* QUESTION 1 CHOICES
* ---------------------------------------------------------
*/
const q1Yes = choiceRepository.create({
question: q1,
text: 'بله',
value: 1,
order: 1,
score: 1,
});
const q1No = choiceRepository.create({
question: q1,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
await choiceRepository.save([q1Yes, q1No]);
/*
* ---------------------------------------------------------
* QUESTION 2 CHOICES
* ---------------------------------------------------------
*/
const q2Yes = choiceRepository.create({
question: q2,
text: 'بله',
value: 1,
order: 1,
score: 1,
});
const q2No = choiceRepository.create({
question: q2,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
const q2NotSure = choiceRepository.create({
question: q2,
text: 'مطمئن نیستم',
value: 2,
order: 3,
score: 1,
});
await choiceRepository.save([q2Yes, q2No, q2NotSure]);
/*
* ---------------------------------------------------------
* QUESTION 3 CHOICES
* ---------------------------------------------------------
*/
const q3Texts = [
'اچ‌آی‌وی (HIV)',
'سیفلیس',
'سوزاک',
'عفونت غیراختصاصی مجرای ادرار',
'هپاتیت B',
'هپاتیت C',
'کلامیدیا',
'زگیل تناسلی',
// 'سایر',
// 'مطمئن نیستم',
// 'هیچ کدام',
];
const q3Choices = q3Texts.map((text, index) =>
choiceRepository.create({
question: q3,
text,
value: index + 1,
order: index + 1,
score: 2,
}),
);
await choiceRepository.save(q3Choices);
/*
* ---------------------------------------------------------
* QUESTION 4 CHOICES
* ---------------------------------------------------------
*/
const q4Texts = [
'سیفلیس',
'سوزاک',
'عفونت غیراختصاصی مجرای ادرار',
'هپاتیت B',
'هپاتیت C',
'کلامیدیا',
'زگیل تناسلی',
// 'سایر',
// 'مطمئن نیستم',
// 'هیچ کدام',
];
const q4Choices = q4Texts.map((text, index) =>
choiceRepository.create({
question: q4,
text,
value: index + 1,
order: index + 1,
score: 1,
}),
);
await choiceRepository.save(q4Choices);
/*
* ---------------------------------------------------------
* QUESTION 5 CHOICES
* ---------------------------------------------------------
*/
const q5Yes = choiceRepository.create({
question: q5,
text: 'بله',
value: 1,
order: 1,
score: 1,
});
const q5No = choiceRepository.create({
question: q5,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
const q5NotSure = choiceRepository.create({
question: q5,
text: 'مطمئن نیستم',
value: 2,
order: 3,
score: 1,
});
await choiceRepository.save([q5Yes, q5No, q5NotSure]);
/*
* ---------------------------------------------------------
* QUESTION 6 CHOICES
* ---------------------------------------------------------
*/
const q6Yes = choiceRepository.create({
question: q6,
text: 'بله',
value: 1,
order: 1,
score: 1,
});
const q6No = choiceRepository.create({
question: q6,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
const q6NotSure = choiceRepository.create({
question: q6,
text: 'مطمئن نیستم',
value: 2,
order: 3,
score: 1,
});
await choiceRepository.save([q6Yes, q6No, q6NotSure]);
/*
* ---------------------------------------------------------
* QUESTION 7 CHOICES
* ---------------------------------------------------------
*/
const q7Yes = choiceRepository.create({
question: q7,
text: 'بله',
value: 1,
order: 1,
score: 2,
});
const q7No = choiceRepository.create({
question: q7,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
await choiceRepository.save([q7Yes, q7No]);
/*
* ---------------------------------------------------------
* QUESTION 8 CHOICES
* ---------------------------------------------------------
*/
const q8Yes = choiceRepository.create({
question: q8,
text: 'بله',
value: 1,
order: 1,
score: 2,
});
const q8No = choiceRepository.create({
question: q8,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
await choiceRepository.save([q8Yes, q8No]);
/*
* ---------------------------------------------------------
* QUESTION 10 CHOICES
* ---------------------------------------------------------
*/
const q10Yes = choiceRepository.create({
question: q10,
text: 'بله',
value: 1,
order: 1,
score: 1,
});
const q10No = choiceRepository.create({
question: q10,
text: 'خیر',
value: 0,
order: 2,
score: 0,
});
await choiceRepository.save([q10Yes, q10No]);
/*
* ---------------------------------------------------------
* QUESTION 12 CHOICES
* ---------------------------------------------------------
*/
const q12Always = choiceRepository.create({
question: q12,
text: 'همیشه با همهٔ شرکای جنسی‌ام از کاندوم استفاده کرده‌ام.',
value: 1,
order: 1,
score: 0,
});
const q12Sometimes = choiceRepository.create({
question: q12,
text: 'گاهی با شرکای جنسی‌ام از کاندوم استفاده کرده‌ام.',
value: 2,
order: 2,
score: 2,
});
const q12Never = choiceRepository.create({
question: q12,
text: 'هیچ‌وقت با هیچ‌کدام از شرکای جنسی‌ام از کاندوم استفاده نکرده‌ام.',
value: 3,
order: 3,
score: 5,
});
const q12NotApplicable = choiceRepository.create({
question: q12,
text: 'سؤال در مورد من صحت ندارد؛ چون که رابطهٔ واژنی یا مقعدی نداشته‌ام.',
value: 4,
order: 4,
score: 1,
});
const q12NotSure = choiceRepository.create({
question: q12,
text: 'مطمئن نیستم.',
value: 5,
order: 5,
score: 2,
});
await choiceRepository.save([
q12Always,
q12Sometimes,
q12Never,
q12NotApplicable,
q12NotSure,
]);
/*
* ---------------------------------------------------------
* QUESTION 9 RANGE RULES
* ---------------------------------------------------------
*
* 1 person => 2
* 2 people => 3
* 3 or more => 5
*/
await rangeRuleRepository.save([
rangeRuleRepository.create({
question: q9,
min: 1,
max: 1,
score: 2,
}),
rangeRuleRepository.create({
question: q9,
min: 2,
max: 2,
score: 3,
}),
rangeRuleRepository.create({
question: q9,
min: 3,
max: 2147483647,
score: 5,
}),
]);
/*
* ---------------------------------------------------------
* QUESTION 11 RANGE RULES
* ---------------------------------------------------------
*
* 1 person => 1
* 2 people => 2
* 3 or more => 3
*/
await rangeRuleRepository.save([
rangeRuleRepository.create({
question: q11,
min: 1,
max: 1,
score: 1,
}),
rangeRuleRepository.create({
question: q11,
min: 2,
max: 2,
score: 2,
}),
rangeRuleRepository.create({
question: q11,
min: 3,
max: 2147483647,
score: 3,
}),
]);
/*
* ---------------------------------------------------------
* QUESTION TRANSITIONS
* ---------------------------------------------------------
*/
/*
* START -> Q1
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: null,
toQuestion: q1,
type: QuestionTransitionType.START,
condition: null,
}),
);
/*
* Q1 -> Q2
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q1,
toQuestion: q2,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q2 -> Q3
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q2,
toQuestion: q3,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q3 -> Q4
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q3,
toQuestion: q4,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q4 -> Q5
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q4,
toQuestion: q5,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q5 -> Q6
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q5,
toQuestion: q6,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q6 -> Q7
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q6,
toQuestion: q7,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q7 -> Q8
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q7,
toQuestion: q8,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* ---------------------------------------------------------
* Q8 BRANCH
* ---------------------------------------------------------
*
* YES -> Q9
* NO -> Q10
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q8,
toQuestion: q9,
type: QuestionTransitionType.CHOICE,
condition: {
choiceId: q8Yes.id,
},
}),
);
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q8,
toQuestion: q10,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q9 -> Q10
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q9,
toQuestion: q10,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* ---------------------------------------------------------
* Q10 BRANCH
* ---------------------------------------------------------
*
* YES -> Q11
* NO -> Q12
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q10,
toQuestion: q11,
type: QuestionTransitionType.CHOICE,
condition: {
choiceId: q10Yes.id,
},
}),
);
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q10,
toQuestion: q12,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q11 -> Q12
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q11,
toQuestion: q12,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q12 -> END
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q12,
toQuestion: null,
type: QuestionTransitionType.END,
condition: null,
}),
);
return { return {
created: true, created: true,
@@ -864,14 +47,33 @@ async function seedTest() {
} }
} }
export async function clearDatabase() {
await dataSource.query(`
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
)
LOOP
EXECUTE 'TRUNCATE TABLE "' || r.tablename || '" RESTART IDENTITY CASCADE';
END LOOP;
END $$;
`);
}
async function seed() { async function seed() {
try { try {
await dataSource.initialize(); await dataSource.initialize();
console.log('Database connection initialized.'); console.log('Database connection initialized.');
await clearDatabase();
await seedSuperuser(); await seedSuperuser();
await seedTest(); await prepareTests();
console.log('Seed completed successfully.'); console.log('Seed completed successfully.');
} catch (error) { } catch (error) {
+50
View File
@@ -0,0 +1,50 @@
import * as bcrypt from 'bcrypt';
import { User } from '../../modules/users/entities/user.entity';
import { Role } from '@/common/enums/roles.enum';
import dataSource from '../data-source';
const SUPERUSER_PHONE = process.env.SUPERUSER_PHONE;
const SUPERUSER_PASSWORD = process.env.SUPERUSER_PASSWORD;
export async function seedSuperuser() {
if (!SUPERUSER_PHONE || !SUPERUSER_PASSWORD) {
console.log(
'SUPERUSER_PHONE or SUPERUSER_PASSWORD is not defined. Skipping superuser seed.',
);
return;
}
const userRepository = dataSource.getRepository(User);
const existingUser = await userRepository.findOne({
where: {
phone: SUPERUSER_PHONE,
},
});
if (existingUser) {
if (existingUser.role !== Role.SUPERUSER) {
existingUser.role = Role.SUPERUSER;
await userRepository.save(existingUser);
console.log(`User ${SUPERUSER_PHONE} promoted to SUPERUSER.`);
} else {
console.log(`Superuser ${SUPERUSER_PHONE} already exists.`);
}
return;
}
const passwordHash = await bcrypt.hash(SUPERUSER_PASSWORD, 12);
const superuser = userRepository.create({
phone: SUPERUSER_PHONE,
password: passwordHash,
role: Role.SUPERUSER,
});
await userRepository.save(superuser);
console.log(`Superuser ${SUPERUSER_PHONE} created successfully.`);
}
+43
View File
@@ -0,0 +1,43 @@
import { Test } from '@/modules/tests/entities/test.entity';
import { AccessType } from '@/modules/tests/enums/access-types.enum';
import { TestStatus } from '@/modules/tests/enums/test-status.enum';
import { EntityManager } from 'typeorm';
const TEST_TITLE = 'تست ارزیابی خطر ابتلا به عفونت‌های منتقله از راه جنسی';
export async function seedTests(manager: EntityManager) {
const testRepository = manager.getRepository(Test);
const existingTest = await testRepository.findOne({
where: {
title: TEST_TITLE,
},
});
if (existingTest) {
return {
created: false,
testId: existingTest.id,
};
}
/*
* ---------------------------------------------------------
* TEST
* ---------------------------------------------------------
*/
const test = testRepository.create({
title: TEST_TITLE,
description:
'این تست برای ارزیابی عوامل مرتبط با خطر ابتلا به عفونت‌های منتقله از راه جنسی طراحی شده است.',
status: TestStatus.PUBLISHED,
accessType: AccessType.FREE,
isActive: true,
questionsCount: 12,
});
const savedTest = await testRepository.save(test);
return savedTest;
}
+239
View File
@@ -0,0 +1,239 @@
import { Choice } from '@/modules/questions/entities/choice.entity';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { Test } from '@/modules/tests/entities/test.entity';
import { EntityManager } from 'typeorm';
export async function seedTransitions(
manager: EntityManager,
savedTest: Test,
questions: Record<string, Question>,
choices: Record<string, Choice>,
) {
const transitionRepository = manager.getRepository(QuestionTransition);
const { q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11, q12 } = questions;
const { q8Yes, q10Yes } = choices;
/*
* ---------------------------------------------------------
* QUESTION TRANSITIONS
* ---------------------------------------------------------
*/
/*
* START -> Q1
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: null,
toQuestion: q1,
type: QuestionTransitionType.START,
condition: null,
}),
);
/*
* Q1 -> Q2
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q1,
toQuestion: q2,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q2 -> Q3
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q2,
toQuestion: q3,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q3 -> Q4
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q3,
toQuestion: q4,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q4 -> Q5
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q4,
toQuestion: q5,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q5 -> Q6
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q5,
toQuestion: q6,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q6 -> Q7
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q6,
toQuestion: q7,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q7 -> Q8
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q7,
toQuestion: q8,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* ---------------------------------------------------------
* Q8 BRANCH
* ---------------------------------------------------------
*
* YES -> Q9
* NO -> Q10
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q8,
toQuestion: q9,
type: QuestionTransitionType.CHOICE,
condition: {
choiceId: q8Yes.id,
},
}),
);
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q8,
toQuestion: q10,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q9 -> Q10
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q9,
toQuestion: q10,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* ---------------------------------------------------------
* Q10 BRANCH
* ---------------------------------------------------------
*
* YES -> Q11
* NO -> Q12
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q10,
toQuestion: q11,
type: QuestionTransitionType.CHOICE,
condition: {
choiceId: q10Yes.id,
},
}),
);
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q10,
toQuestion: q12,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q11 -> Q12
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q11,
toQuestion: q12,
type: QuestionTransitionType.DEFAULT,
condition: null,
}),
);
/*
* Q12 -> END
*/
await transitionRepository.save(
transitionRepository.create({
test: savedTest,
fromQuestion: q12,
toQuestion: null,
type: QuestionTransitionType.END,
condition: null,
}),
);
}
+3 -1
View File
@@ -5,6 +5,7 @@
"otp": "Otp" "otp": "Otp"
}, },
"errors": { "errors": {
"otpAlreadySent": "OTP already sent. Please try again in 1 minute.",
"wrongPhoneOrPass": "Entered phone number or password is wrong.", "wrongPhoneOrPass": "Entered phone number or password is wrong.",
"unauthorized": "Unauthorized access", "unauthorized": "Unauthorized access",
"tokenReuse": "Token reuse detected.", "tokenReuse": "Token reuse detected.",
@@ -13,6 +14,7 @@
}, },
"messages": { "messages": {
"otpSent": "OTP sent to {phone}.", "otpSent": "OTP sent to {phone}.",
"sendPass": "Please send password." "sendPass": "Please send password.",
"loggedOut": "Logged out successfully."
} }
} }
+6
View File
@@ -0,0 +1,6 @@
{
"fields": {
"conversationId": "Conversation Id",
"message": "Message"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"fields": {
"conversation": "Conversation"
}
}
-1
View File
@@ -9,7 +9,6 @@
"question": "Question", "question": "Question",
"text": "Text", "text": "Text",
"value": "Value", "value": "Value",
"choice": "Choice",
"score": "Score", "score": "Score",
"weight": "Weight", "weight": "Weight",
"rangeRule": "Range Rule", "rangeRule": "Range Rule",
+3 -1
View File
@@ -5,6 +5,7 @@
"otp": "کد تایید" "otp": "کد تایید"
}, },
"errors": { "errors": {
"otpAlreadySent": "کد تایید قبلا ارسال شده. لطفا دقایقی دیگر مجددا امتحان کنید.",
"wrongPhoneOrPass": "شماره موبایل یا رمز عبور اشتباه است.", "wrongPhoneOrPass": "شماره موبایل یا رمز عبور اشتباه است.",
"unauthorized": "دسترسی غیر مجاز", "unauthorized": "دسترسی غیر مجاز",
"tokenReuse": "توکن قبلا استفاده شده.", "tokenReuse": "توکن قبلا استفاده شده.",
@@ -13,6 +14,7 @@
}, },
"messages": { "messages": {
"otpSent": "کد تایید به شماره {phone} ارسال شد.", "otpSent": "کد تایید به شماره {phone} ارسال شد.",
"sendPass": "لطفا رمز عبور را ارسال کنید." "sendPass": "لطفا رمز عبور را ارسال کنید.",
"loggedOut": "با موفقیت خارج شدید."
} }
} }
+6
View File
@@ -0,0 +1,6 @@
{
"fields": {
"conversationId": "شناسه مکالمه",
"message": "پیام"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"fields": {
"conversation": "مکالمه"
}
}
-1
View File
@@ -8,7 +8,6 @@
"metadata": "تنظیمات", "metadata": "تنظیمات",
"question": "سوال", "question": "سوال",
"text": "متن", "text": "متن",
"value": "مقدار",
"choice": "گزینه", "choice": "گزینه",
"score": "نمره", "score": "نمره",
"weight": "وزن", "weight": "وزن",
+13 -1
View File
@@ -5,12 +5,20 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import helmet from 'helmet'; import helmet from 'helmet';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Logger } from 'nestjs-pino';
import { AdminPanelSetup } from './modules/admin-panel/admin-panel.setup';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
app.setGlobalPrefix('v1'); app.setGlobalPrefix('v1');
// Mount the admin panel before Nest registers its body parsers; the setup
// itself is deferred until the DI container is ready (after app.init)
AdminPanelSetup.mount(app);
app.useGlobalPipes( app.useGlobalPipes(
new I18nValidationPipe({ new I18nValidationPipe({
whitelist: true, whitelist: true,
@@ -91,6 +99,10 @@ async function bootstrap() {
SwaggerModule.setup('v1/docs', app, document); SwaggerModule.setup('v1/docs', app, document);
await app.init();
AdminPanelSetup.setup(app);
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PrepUserSubmissionHistory1788876455659 implements MigrationInterface {
name = 'PrepUserSubmissionHistory1788876455659';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."unq_question_multi_choice"`);
await queryRunner.query(`ALTER TABLE "choice" DROP COLUMN "value"`);
await queryRunner.query(
`ALTER TABLE "submission_answer" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "submission" ADD "code" character varying`,
);
await queryRunner.query(
`ALTER TABLE "submission" ADD "completedAt" TIMESTAMP WITH TIME ZONE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "submission" DROP COLUMN "completedAt"`,
);
await queryRunner.query(`ALTER TABLE "submission" DROP COLUMN "code"`);
await queryRunner.query(
`ALTER TABLE "submission_answer" DROP COLUMN "createdAt"`,
);
await queryRunner.query(
`ALTER TABLE "choice" ADD "value" integer NOT NULL`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "unq_question_multi_choice" ON "question_transition" ("type", "fromQuestionId") WHERE (type = 'multi-choice'::question_transition_type_enum)`,
);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ChangeResultRangeFields1788950646073 implements MigrationInterface {
name = 'ChangeResultRangeFields1788950646073';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "minScore"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "maxScore"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "minProbability" integer NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "maxProbability" integer NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "maxProbability"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "minProbability"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "maxScore" integer NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "minScore" integer NOT NULL`,
);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CalculateByScore1789906979798 implements MigrationInterface {
name = 'CalculateByScore1789906979798';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "minProbability"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "maxProbability"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "minScore" integer NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "maxScore" integer NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "maxScore"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" DROP COLUMN "minScore"`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "maxProbability" integer NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "result_range" ADD "minProbability" integer NOT NULL`,
);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Conversation1791194400675 implements MigrationInterface {
name = 'Conversation1791194400675';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "conversation" ("id" SERIAL NOT NULL, "title" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, CONSTRAINT "PK_864528ec4274360a40f66c29845" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "public"."message_role_enum" AS ENUM('user', 'assistant', 'doctor')`,
);
await queryRunner.query(
`CREATE TABLE "message" ("id" SERIAL NOT NULL, "role" "public"."message_role_enum" NOT NULL, "content" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "conversationId" integer, CONSTRAINT "PK_ba01f0a3e0123651915008bc578" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "conversation" ADD CONSTRAINT "FK_c308b1cd542522bb66430fa860a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "message" ADD CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f" FOREIGN KEY ("conversationId") REFERENCES "conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "message" DROP CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f"`,
);
await queryRunner.query(
`ALTER TABLE "conversation" DROP CONSTRAINT "FK_c308b1cd542522bb66430fa860a"`,
);
await queryRunner.query(`DROP TABLE "message"`);
await queryRunner.query(`DROP TYPE "public"."message_role_enum"`);
await queryRunner.query(`DROP TABLE "conversation"`);
}
}
@@ -0,0 +1,65 @@
import type { SessionData } from 'express-session';
import { Store } from 'express-session';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
/**
* express-session store backed by the application Redis, so admin sessions
* survive restarts and expire server-side together with the cookie.
*/
export class AdminPanelSessionStore extends Store {
constructor(
/**
* Inject Redis Service
*/
private readonly redisService: RedisService,
) {
super();
}
private key(sessionId: string) {
return `admin:panel:sess:${sessionId}`;
}
private ttlSeconds(session: SessionData) {
const maxAgeMs = session.cookie?.maxAge;
return typeof maxAgeMs === 'number' ? Math.floor(maxAgeMs / 1000) : undefined;
}
override get(
sessionId: string,
callback: (err: unknown, session?: SessionData | null) => void,
) {
this.redisService
.get<SessionData>(this.key(sessionId))
.then((session) => callback(null, session ?? null))
.catch(callback);
}
override set(
sessionId: string,
session: SessionData,
callback: (err?: unknown) => void,
) {
this.redisService
.set(this.key(sessionId), session, this.ttlSeconds(session))
.then(() => callback())
.catch(callback);
}
override destroy(sessionId: string, callback: (err?: unknown) => void) {
this.redisService
.del(this.key(sessionId))
.then(() => callback())
.catch(callback);
}
override touch(
sessionId: string,
session: SessionData,
callback: (err?: unknown) => void,
) {
// Rolling sessions: refresh the server-side TTL on every request
this.set(sessionId, session, callback);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { HashingModule } from '@/common/modules/hashing/hashing.module';
import { RedisModule } from '@/common/modules/redis/redis.module';
import { AdminPanelAuthenticator } from './providers/admin-panel-auth.provider';
/**
* Wires the dependencies AdminPanelSetup resolves after bootstrap.
* The panel itself is mounted on the express instance (see admin-panel.setup.ts
* and main.ts), not through Nest controllers.
*/
@Module({
imports: [UsersModule, HashingModule, RedisModule],
providers: [AdminPanelAuthenticator],
exports: [AdminPanelAuthenticator],
})
export class AdminPanelModule {}
@@ -0,0 +1,144 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import type { Express, Request, Response, NextFunction } from 'express';
import AdminJS from 'adminjs';
import AdminJSExpress from '@adminjs/express';
import { Database, Resource } from '@adminjs/typeorm';
import { User } from '@/modules/users/entities/user.entity';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
import { AdminPanelAuthenticator } from './providers/admin-panel-auth.provider';
import { AdminPanelSessionStore } from './admin-panel-session.store';
/**
* AdminJS panel bootstrap.
*
* The router must be registered on the express instance BEFORE NestJS body
* parsers (they would otherwise consume request bodies and trip AdminJS's
* OldBodyParserUsedError guard). @adminjs/nestjs reorders express layers for
* this, but its reorder relies on app._router which no longer exists in
* Express 5, so we mount a lazy placeholder early and swap in the real
* AdminJS router once the DI container is initialized.
*/
export class AdminPanelSetup {
private static handler: (
req: Request,
res: Response,
next: NextFunction,
) => void;
static mount(app: INestApplication): void {
if (process.env.ADMIN_PANEL_ENABLED === 'false') {
return;
}
const expressApp = app.getHttpAdapter().getInstance() as Express;
const rootPath = process.env.ADMIN_ROOT_PATH || '/admin';
// Placeholder stands in until setup() replaces it with the real router
expressApp.use(rootPath, (req, res, next) => {
if (AdminPanelSetup.handler) {
return AdminPanelSetup.handler(req, res, next);
}
next();
});
}
static setup(app: INestApplication): void {
const configService = app.get(ConfigService);
if (!configService.get<boolean>('admin.enabled')) {
return;
}
const cookiePassword = configService.get<string>('admin.cookiePassword');
if (!cookiePassword || cookiePassword.length < 32) {
throw new Error(
'ADMIN_COOKIE_PASSWORD must be set to a random string of at least 32 characters when the admin panel is enabled',
);
}
AdminJS.registerAdapter({ Database, Resource });
// Entities extend ActiveRecord's BaseEntity, which resolves its repository
// through this static DataSource reference (the adapter relies on it)
const dataSource = app.get(DataSource);
for (const entity of dataSource.entityMetadatas.map((m) => m.target)) {
const baseEntity = entity as { useDataSource?: (ds: DataSource) => void };
baseEntity.useDataSource?.(dataSource);
}
// Register every real entity automatically (new entities show up without
// any change here). Auto-generated ManyToMany junction metadatas have no
// entity class behind them and are skipped — they are managed through the
// owning relation instead.
const autoResources = dataSource.entityMetadatas
.map((m) => m.target)
.filter(
(target) =>
typeof (target as { getRepository?: unknown }).getRepository ===
'function',
);
const adminJs = new AdminJS({
rootPath: configService.get<string>('admin.rootPath'),
branding: {
companyName: 'Heala Admin',
withMadeWithLove: false,
},
resources: [
...autoResources,
{
resource: User,
options: {
properties: {
// Never expose password hashes in tables and detail views
password: {
isVisible: {
list: false,
filter: false,
show: false,
edit: true,
},
},
},
},
},
],
});
const authenticator = app.get(AdminPanelAuthenticator);
const redisService = app.get(RedisService);
AdminPanelSetup.handler = AdminJSExpress.buildAuthenticatedRouter(
adminJs,
{
authenticate: (phone: string, password: string) =>
authenticator.authenticate(phone, password),
cookieName: configService.get<string>('admin.cookieName'),
cookiePassword,
},
undefined,
{
// AdminJS overrides `secret` and `name` from the auth options; both
// are repeated here only to satisfy the newer express-session typings
secret: cookiePassword,
store: new AdminPanelSessionStore(redisService),
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: configService.get<boolean>('admin.secureCookies'),
maxAge:
(configService.get<number>('admin.sessionTtlMinutes') ?? 120) *
60_000,
},
},
);
}
}
@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import type { CurrentAdmin } from 'adminjs';
import { Role } from '@/common/enums/roles.enum';
import { UsersService } from '@/modules/users/providers/users.service';
import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_WINDOW_SECONDS = 15 * 60;
/**
* Hash of a random throwaway string. Comparing against it when the phone
* number is unknown keeps the response time identical to the success path,
* so attackers cannot enumerate superuser phone numbers via timing.
*/
const DUMMY_BCRYPT_HASH =
'$2b$10$ZFUmcSvMhrye2p.Dt1J7rOcfEdi.NcDLk3Bf55taT3ed57cAcpiNS';
@Injectable()
export class AdminPanelAuthenticator {
constructor(
/**
* Inject Users Service
*/
private readonly usersService: UsersService,
/**
* Inject Hashing Provider
*/
private readonly hashingProvider: HashingProvider,
/**
* Inject Redis Service
*/
private readonly redisService: RedisService,
) {}
public async authenticate(
phone: string,
password: string,
): Promise<CurrentAdmin | null> {
const normalizedPhone = phone?.trim();
if (!normalizedPhone || !password) {
return null;
}
const attemptsKey = `admin:panel:login:${normalizedPhone}`;
const failedAttempts =
(await this.redisService.get<number>(attemptsKey)) ?? 0;
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
return null;
}
const user = await this.usersService.findOneByPhone(normalizedPhone);
const isSuperuser = !!user && user.role === Role.SUPERUSER;
const passwordMatches = await this.hashingProvider.compare(
password,
isSuperuser && user.password ? user.password : DUMMY_BCRYPT_HASH,
);
if (!isSuperuser || !user.password || !passwordMatches) {
await this.redisService.set(
attemptsKey,
failedAttempts + 1,
ATTEMPT_WINDOW_SECONDS,
);
return null;
}
await this.redisService.del(attemptsKey);
return {
id: user.id.toString(),
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
role: user.role,
// AdminJS requires an email on CurrentAdmin for the UI
email: user.phone,
};
}
}
@@ -5,7 +5,6 @@ Content-Type: application/json
{ {
"question": 13, "question": 13,
"text": "Option 4", "text": "Option 4",
"value": 1,
"order": 4, "order": 4,
"score": 10 "score": 10
} }
+67 -8
View File
@@ -9,6 +9,10 @@ import { ApiAppResponse } from '@/common/decorators/api-app-response.decorator';
import { NewUserLoginResponseDTO } from './dtos/responses/new-user-login.dto'; import { NewUserLoginResponseDTO } from './dtos/responses/new-user-login.dto';
import { RequirePasswordDTO } from './dtos/responses/require-password.dto'; import { RequirePasswordDTO } from './dtos/responses/require-password.dto';
import { TokensResponseDTO } from './dtos/responses/tokens.dto'; import { TokensResponseDTO } from './dtos/responses/tokens.dto';
import { LogoutDTO } from './dtos/logout.dto';
import { ActiveUser } from '@/common/decorators/active-user.decorator';
import { AccessToken } from './decorators/access-token.decorator';
import { User } from '@/modules/users/entities/user.entity';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -26,16 +30,13 @@ export class AuthController {
@ApiAppResponse([ @ApiAppResponse([
{ {
status: HttpStatus.OK, status: HttpStatus.OK,
description: 'Successfull responses',
variants: [ variants: [
{ {
model: TokensResponseDTO, model: TokensResponseDTO,
example: { example: {
data: { data: {
access: access: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciIsImlhdCI6MTcyNDAwMDAwMCwiZXhwIjoxNzI0MDAzNjAwfQ.dGVzdC1zaWduYXR1cmU', refresh: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refresh:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciIsImlhdCI6MTcyNDAwMDAwMCwiZXhwIjoxNzI0MDAzNjAwfQ.dGVzdC1zaWduYXR1cmU',
}, },
}, },
}, },
@@ -61,8 +62,7 @@ export class AuthController {
], ],
}, },
{ {
status: HttpStatus.BAD_REQUEST, status: HttpStatus.UNAUTHORIZED,
description: 'BadRequest responses',
variants: [ variants: [
{ {
messageExample: 'شماره موبایل یا رمز عبور اشتباه است.', messageExample: 'شماره موبایل یا رمز عبور اشتباه است.',
@@ -71,7 +71,6 @@ export class AuthController {
}, },
{ {
status: HttpStatus.TOO_MANY_REQUESTS, status: HttpStatus.TOO_MANY_REQUESTS,
description: 'TooManyRequests responses',
variants: [ variants: [
{ {
messageExample: messageExample:
@@ -88,6 +87,24 @@ export class AuthController {
@Post('verify-otp') @Post('verify-otp')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Throttle({ otp: {} }) @Throttle({ otp: {} })
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
model: TokensResponseDTO,
},
],
},
{
status: HttpStatus.UNAUTHORIZED,
variants: [
{
messageExample: 'کد تایید اشتباه است یا منقضی شده.',
},
],
},
])
public async verifyOTP(@Body() verifyOtpDto: VerifyOtpDTO) { public async verifyOTP(@Body() verifyOtpDto: VerifyOtpDTO) {
return await this.authService.verifyOTP(verifyOtpDto); return await this.authService.verifyOTP(verifyOtpDto);
} }
@@ -96,7 +113,49 @@ export class AuthController {
@Post('refresh') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Throttle({ login: {} }) @Throttle({ login: {} })
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
model: TokensResponseDTO,
},
],
},
{
status: HttpStatus.UNAUTHORIZED,
variants: [{ messageExample: 'توکن قبلا استفاده شده.' }],
},
])
public async refreshToken(@Body() refreshTokenDto: RefreshTokenDTO) { public async refreshToken(@Body() refreshTokenDto: RefreshTokenDTO) {
return await this.authService.refreshToken(refreshTokenDto); return await this.authService.refreshToken(refreshTokenDto);
} }
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
messageExample: 'با موفقیت خارج شدید.',
},
],
},
{
status: HttpStatus.UNAUTHORIZED,
variants: [
{
messageExample: 'دسترسی غیر مجاز',
},
],
},
])
public async logout(
@ActiveUser() user: User,
@Body() logoutDto: LogoutDTO,
@AccessToken() accessToken: string,
) {
return await this.authService.logout(user.id, logoutDto, accessToken);
}
} }
+2
View File
@@ -15,6 +15,7 @@ import { GlobalAuthGuard } from './guards/global-auth.guard';
import { RefreshTokensProvider } from './providers/refresh-tokens.provider'; import { RefreshTokensProvider } from './providers/refresh-tokens.provider';
import { RedisModule } from '@/common/modules/redis/redis.module'; import { RedisModule } from '@/common/modules/redis/redis.module';
import { AuthRedisProvider } from './providers/auth-redis.provider'; import { AuthRedisProvider } from './providers/auth-redis.provider';
import { LogoutProvider } from './providers/logout.provider';
@Module({ @Module({
imports: [ imports: [
@@ -43,6 +44,7 @@ import { AuthRedisProvider } from './providers/auth-redis.provider';
JwtStrategy, JwtStrategy,
RefreshTokensProvider, RefreshTokensProvider,
AuthRedisProvider, AuthRedisProvider,
LogoutProvider,
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: GlobalAuthGuard, useClass: GlobalAuthGuard,
@@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { ExtractJwt } from 'passport-jwt';
import type { Request } from 'express';
export const AccessToken = createParamDecorator(
(_: unknown, ctx: ExecutionContext): string | undefined => {
const request: Request = ctx.switchToHttp().getRequest();
return ExtractJwt.fromAuthHeaderAsBearerToken()(request) ?? undefined;
},
);
+13
View File
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class LogoutDTO {
@IsNotEmpty()
@IsString()
@ApiProperty({
type: 'string',
description: "User's Refresh Token",
required: true,
})
refreshToken!: string;
}
@@ -3,11 +3,13 @@ import { ApiProperty } from '@nestjs/swagger';
export class TokensResponseDTO { export class TokensResponseDTO {
@ApiProperty({ @ApiProperty({
type: 'string', type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
}) })
access!: string; access!: string;
@ApiProperty({ @ApiProperty({
type: 'string', type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
}) })
refresh!: string; refresh!: string;
} }
@@ -3,8 +3,8 @@ Content-Type: application/json
lang: fa lang: fa
{ {
"phone": "09121212123" "phone": "09333026363",
// "password": "Password1@" "password": "Password1@"
} }
{{ {{
@@ -0,0 +1,7 @@
POST {{baseUrl}}/auth/logout/
Content-Type: application/json
Authorization: Bearer {{$global.accessToken}}
{
"refreshToken": "{{$global.refreshToken}}"
}
@@ -2,8 +2,8 @@ POST {{baseUrl}}/auth/verify-otp
Content-Type: application/json Content-Type: application/json
{ {
"phone": "09121212123", "phone": "09333026363",
"otp": "26989" "otp": "23122"
} }
{{ {{
@@ -1,6 +1,8 @@
export interface AccessTokenPayload { export interface AccessTokenPayload {
sub: number; sub: number;
phone: string; phone: string;
jti: string;
exp: number;
} }
export interface RefreshTokenPayload { export interface RefreshTokenPayload {
@@ -21,4 +21,14 @@ export class AuthRedisProvider {
async revokeRefreshToken(jti: string) { async revokeRefreshToken(jti: string) {
await this.redisService.del(`rt:${jti}`); await this.redisService.del(`rt:${jti}`);
} }
async blacklistAccessToken(jti: string, ttl: number) {
if (ttl <= 0) return;
await this.redisService.set(`at:bl:${jti}`, '1', ttl);
}
async isAccessTokenBlacklisted(jti: string) {
return !!(await this.redisService.get(`at:bl:${jti}`));
}
} }
@@ -5,6 +5,8 @@ import { VerifyOtpDTO } from '../dtos/verify-otp.dto';
import { VerifyOTPProvider } from './verify-otp.provider'; import { VerifyOTPProvider } from './verify-otp.provider';
import { RefreshTokenDTO } from '../dtos/refresh-token.dto'; import { RefreshTokenDTO } from '../dtos/refresh-token.dto';
import { RefreshTokensProvider } from './refresh-tokens.provider'; import { RefreshTokensProvider } from './refresh-tokens.provider';
import { LogoutDTO } from '../dtos/logout.dto';
import { LogoutProvider } from './logout.provider';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -23,6 +25,11 @@ export class AuthService {
* Inject RefreshToken Provider * Inject RefreshToken Provider
*/ */
private readonly refreshTokensProvider: RefreshTokensProvider, private readonly refreshTokensProvider: RefreshTokensProvider,
/**
* Inject Logout Provider
*/
private readonly logoutProvider: LogoutProvider,
) {} ) {}
public async logIn(loginDto: LoginDTO) { public async logIn(loginDto: LoginDTO) {
@@ -36,4 +43,12 @@ export class AuthService {
public async refreshToken(refreshTokenDto: RefreshTokenDTO) { public async refreshToken(refreshTokenDto: RefreshTokenDTO) {
return await this.refreshTokensProvider.refreshTokens(refreshTokenDto); return await this.refreshTokensProvider.refreshTokens(refreshTokenDto);
} }
public async logout(
userId: number,
logoutDto: LogoutDTO,
accessToken: string,
) {
return await this.logoutProvider.logout(userId, logoutDto, accessToken);
}
} }
@@ -3,6 +3,7 @@ import { User } from '@/modules/users/entities/user.entity';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import { import {
AccessTokenPayload, AccessTokenPayload,
RefreshTokenPayload, RefreshTokenPayload,
@@ -51,13 +52,15 @@ export class GenerateTokenProvider {
} }
public async generateTokens(user: User, jti: string) { public async generateTokens(user: User, jti: string) {
const accessJti = randomUUID();
const [access, refresh] = await Promise.all([ const [access, refresh] = await Promise.all([
// Sign Access Token // Sign Access Token
this.signToken<Partial<AccessTokenPayload>>( this.signToken<Partial<AccessTokenPayload>>(
user.id, user.id,
this.jwtConfiguration.access.expiresIn, this.jwtConfiguration.access.expiresIn,
this.jwtConfiguration.access.secret, this.jwtConfiguration.access.secret,
{ phone: user.phone }, { phone: user.phone, jti: accessJti },
), ),
// Sign Refresh Token // Sign Refresh Token
+4 -6
View File
@@ -51,12 +51,10 @@ export class LoginProvider {
return { return {
message, message,
data: { newUser: true,
newUser: true, otpExpiresAt: otpCode.expiresAt,
otpExpiresAt: otpCode.expiresAt, // Just for development
// Just for development code: otpCode.code,
code: otpCode.code,
},
}; };
} }
@@ -0,0 +1,88 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { ConfigType } from '@nestjs/config';
import jwtConfig from '@/config/jwt.config';
import {
AccessTokenPayload,
RefreshTokenPayload,
} from '../interfaces/jwt.interface';
import { AuthRedisProvider } from './auth-redis.provider';
import { LogoutDTO } from '../dtos/logout.dto';
import { AppResponse } from '@/common/responses';
import { I18nService } from 'nestjs-i18n';
@Injectable()
export class LogoutProvider {
constructor(
/**
* Inject JwtService
*/
private readonly jwtService: JwtService,
/**
* Inject jwtConfig
*/
@Inject(jwtConfig.KEY)
private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
/**
* Inject AuthRedis Provider
*/
private readonly authRedisProvider: AuthRedisProvider,
/**
* Inject I18n Service
*/
private readonly i18nService: I18nService,
) {}
public async logout(
userId: number,
logoutDto: LogoutDTO,
accessToken: string,
) {
if (!accessToken) {
throw new UnauthorizedException('auth.errors.unauthorized');
}
const refreshPayload =
await this.jwtService.verifyAsync<RefreshTokenPayload>(
logoutDto.refreshToken,
{
secret: this.jwtConfiguration.refresh.secret,
issuer: this.jwtConfiguration.issuer,
audience: this.jwtConfiguration.audience,
},
);
if (refreshPayload.sub !== userId || !refreshPayload.jti) {
throw new UnauthorizedException('auth.errors.unauthorized');
}
// The guard already validated this token; verify again to be safe
const accessPayload = await this.jwtService.verifyAsync<AccessTokenPayload>(
accessToken,
{
secret: this.jwtConfiguration.access.secret,
issuer: this.jwtConfiguration.issuer,
audience: this.jwtConfiguration.audience,
},
);
if (accessPayload.sub !== userId || !accessPayload.jti) {
throw new UnauthorizedException('auth.errors.unauthorized');
}
// Only blacklist until the access token would have expired anyway
const ttl = accessPayload.exp - Math.floor(Date.now() / 1000);
await Promise.all([
this.authRedisProvider.revokeRefreshToken(refreshPayload.jti),
this.authRedisProvider.blacklistAccessToken(accessPayload.jti, ttl),
]);
const message = this.i18nService.translate('auth.messages.loggedOut');
return new AppResponse(null, message);
}
}
@@ -6,6 +6,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { AccessTokenPayload } from '../interfaces/jwt.interface'; import { AccessTokenPayload } from '../interfaces/jwt.interface';
import { User } from '@/modules/users/entities/user.entity'; import { User } from '@/modules/users/entities/user.entity';
import { UsersService } from '@/modules/users/providers/users.service'; import { UsersService } from '@/modules/users/providers/users.service';
import { AuthRedisProvider } from '../providers/auth-redis.provider';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -20,6 +21,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
* Inject Users Service * Inject Users Service
*/ */
private readonly usersService: UsersService, private readonly usersService: UsersService,
/**
* Inject AuthRedis Provider
*/
private readonly authRedisProvider: AuthRedisProvider,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -31,6 +37,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
async validate(payload: AccessTokenPayload): Promise<User> { async validate(payload: AccessTokenPayload): Promise<User> {
if (
!payload.jti ||
(await this.authRedisProvider.isAccessTokenBlacklisted(payload.jti))
) {
throw new UnauthorizedException('auth.errors.unauthorized');
}
const user = await this.usersService.findOneByPhone(payload.phone); const user = await this.usersService.findOneByPhone(payload.phone);
if (!user) { if (!user) {
+59
View File
@@ -0,0 +1,59 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Res,
} from '@nestjs/common';
import { ChatbotService } from './providers/chatbot.service';
import type { Response } from 'express';
import { ChatDTO } from './dtos/chat.dto';
import { ActiveUser } from '@/common/decorators/active-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('chatbot')
export class ChatbotController {
constructor(
/**
* Inject Chatbot Service
*/
private readonly chatbotService: ChatbotService,
) {}
@Post('chat')
@HttpCode(HttpStatus.OK)
public async chat(@Body() chatDto: ChatDTO, @ActiveUser() user: User) {
return this.chatbotService.chat(
user,
chatDto.message,
chatDto.conversationId,
);
}
@Post('stream')
@HttpCode(HttpStatus.OK)
public async streamChat(
@Body() chatDto: ChatDTO,
@ActiveUser() user: User,
@Res() res: Response,
) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
for await (const content of this.chatbotService.streamChat(
user,
chatDto.message,
chatDto.conversationId,
)) {
res.write(
`data: ${JSON.stringify({
message: content,
})}\n\n`,
);
}
res.end();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ChatbotController } from './chatbot.controller';
import { ChatbotService } from './providers/chatbot.service';
import { ConversationModule } from '../conversation/conversation.module';
@Module({
imports: [ConversationModule],
controllers: [ChatbotController],
providers: [ChatbotService],
})
export class ChatbotModule {}
+14
View File
@@ -0,0 +1,14 @@
export const CHATBOT_SYSTEM_PROMPT = `
You are a medical AI assistant named Heala (Or in persian: هیلا).
Your role is to provide general medical information
and health education.
- Do not provide definitive diagnoses.
- Do not prescribe medications or dosages.
- Do not fabricate medical information.
- If symptoms may indicate an emergency, recommend
seeking urgent medical care.
- Clearly communicate uncertainty when appropriate.
- Answer in Persian when the user asks in Persian.
`;
+24
View File
@@ -0,0 +1,24 @@
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as t } from 'nestjs-i18n';
export class ChatDTO {
@IsInt({
message: t('validation.wrongFieldFormat', {
field: '$t(chatbot.fields.conversationId)',
}),
})
@IsOptional()
conversationId?: number;
@IsString({
message: t('validation.wrongFieldFormat', {
field: '$t(chatbot.fields.message)',
}),
})
@IsNotEmpty({
message: t('validation.requiredField', {
field: '$t(chatbot.fields.message)',
}),
})
message!: string;
}
@@ -0,0 +1,10 @@
POST {{baseUrl}}/chatbot/chat
Content-Type: application/json
Authorization: Bearer {{$global.accessToken}}
{
"message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟"
}
# POST {{baseUrl}}/chatbot/stream/
# Content-Type: application/json
@@ -0,0 +1,158 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CHATBOT_SYSTEM_PROMPT } from '../constants';
import { User } from '@/modules/users/entities/user.entity';
import { Conversation } from '@/modules/conversation/entities/conversation.entity';
import { ConversationService } from '@/modules/conversation/providers/conversation.service';
import { MessageRole } from '@/modules/conversation/enums/message-role.enum';
@Injectable()
export class ChatbotService {
constructor(
/**
* Inject Config Service
*/
private readonly configService: ConfigService,
/**
* Inject Conversation Service
*/
private readonly conversationService: ConversationService,
) {}
private async getOpenRouter() {
const { OpenRouter } = await import('@openrouter/sdk');
return new OpenRouter({
apiKey: this.configService.getOrThrow<string>('openRouter.apiKey'),
});
}
private async getConversationMessages(
user: User,
message: string,
conversationId?: number,
) {
let conversation: Conversation;
if (conversationId) {
conversation = await this.conversationService.findConversationById(
conversationId,
user,
);
} else {
conversation = await this.conversationService.createConversation(user);
}
await this.conversationService.addMessage(
conversation,
MessageRole.USER,
message,
);
const messages = await this.conversationService.getMessages(conversation);
return {
conversation,
messages: [
{
role: 'system' as const,
content: CHATBOT_SYSTEM_PROMPT.trim(),
},
...messages.map((message) => ({
role:
message.role === MessageRole.USER
? ('user' as const)
: ('assistant' as const),
content: message.content,
})),
],
};
}
public async chat(user: User, message: string, conversationId?: number) {
const { conversation, messages } = await this.getConversationMessages(
user,
message,
conversationId,
);
const openRouter = await this.getOpenRouter();
const response = await openRouter.chat.send({
chatRequest: {
model: this.configService.getOrThrow<string>('openRouter.model'),
messages,
},
});
if (response instanceof ReadableStream) {
throw new Error('Expected non-streaming response');
}
const content = response.choices[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('OpenRouter returned an unexpected response');
}
await this.conversationService.addMessage(
conversation,
MessageRole.ASSISTANT,
content,
);
return {
conversationId: conversation.id,
message: content,
};
}
public async *streamChat(
user: User,
message: string,
conversationId?: number,
) {
const { conversation, messages } = await this.getConversationMessages(
user,
message,
conversationId,
);
const openRouter = await this.getOpenRouter();
const response = await openRouter.chat.send({
chatRequest: {
model: this.configService.getOrThrow<string>('openRouter.model'),
messages,
stream: true,
},
});
if ('choices' in response) {
throw new Error('Expected streaming response');
}
let assistantMessage = '';
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content;
if (!content) {
continue;
}
assistantMessage += content;
yield { conversationId: conversation.id, content };
}
if (assistantMessage) {
await this.conversationService.addMessage(
conversation,
MessageRole.ASSISTANT,
assistantMessage,
);
}
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';
import { ConversationService } from './providers/conversation.service';
@Module({
imports: [TypeOrmModule.forFeature([Conversation, Message])],
providers: [ConversationService],
exports: [ConversationService],
})
export class ConversationModule {}
@@ -0,0 +1,33 @@
import { User } from '@/modules/users/entities/user.entity';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
BaseEntity,
} from 'typeorm';
import { Message } from './message.entity';
@Entity()
export class Conversation extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar', nullable: true })
title!: string | null;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user!: User;
@OneToMany(() => Message, (message) => message.conversation)
messages!: Message[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm';
import { Conversation } from './conversation.entity';
import { MessageRole } from '../enums/message-role.enum';
@Entity()
export class Message extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@ManyToOne(() => Conversation, (conversation) => conversation.messages, {
onDelete: 'CASCADE',
})
conversation!: Conversation;
@Column({
type: 'enum',
enum: MessageRole,
nullable: false,
})
role!: MessageRole;
@Column({ type: 'text' })
content!: string;
@CreateDateColumn()
createdAt!: Date;
}
@@ -0,0 +1,4 @@
export enum ConversationType {
AI = 'ai',
DOCTOR = 'doctor',
}
@@ -0,0 +1,5 @@
export enum MessageRole {
USER = 'user',
ASSISTANT = 'assistant',
DOCTOR = 'doctor',
}
@@ -0,0 +1,111 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Conversation } from '../entities/conversation.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Message } from '../entities/message.entity';
import { User } from '@/modules/users/entities/user.entity';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppException } from '@/common/exceptions/app.exception';
import { MessageRole } from '../enums/message-role.enum';
@Injectable()
export class ConversationService {
constructor(
/**
* Inject Conversation Repository
*/
@InjectRepository(Conversation)
private readonly conversationRepository: Repository<Conversation>,
/**
* Inject Message Repository
*/
@InjectRepository(Message)
private readonly messageRepository: Repository<Message>,
) {}
public async createConversation(
user: User,
title?: string,
): Promise<Conversation> {
const conversation = this.conversationRepository.create({
user,
title: title ?? null,
});
try {
return await this.conversationRepository.save(conversation);
} catch (err) {
throw new AppTimeoutException(err);
}
}
public async findConversationById(
id: number,
user: User,
): Promise<Conversation> {
let conversation: Conversation | null = null;
try {
conversation = await this.conversationRepository.findOne({
where: {
id,
user: {
id: user.id,
},
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (!conversation) {
throw new AppException(
'common.errors.entityNotFound',
HttpStatus.NOT_FOUND,
{ entity: '$t(conversations.fields.conversation)' },
);
}
return conversation;
}
public async addMessage(
conversation: Conversation,
role: MessageRole,
content: string,
): Promise<Message> {
const message = this.messageRepository.create({
conversation,
role,
content,
});
try {
return await this.messageRepository.save(message);
} catch (err) {
throw new AppTimeoutException(err);
}
}
public async getMessages(conversation: Conversation): Promise<Message[]> {
let messages: Array<Message> = [];
try {
messages = await this.messageRepository.find({
where: {
conversation: {
id: conversation.id,
},
},
order: {
createdAt: 'ASC',
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
return messages;
}
}
+2 -1
View File
@@ -5,11 +5,12 @@ import {
Entity, Entity,
Index, Index,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
@Entity() @Entity()
@Index(['phone', 'used']) @Index(['phone', 'used'])
export class OTP { export class OTP extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -27,14 +27,6 @@ export class CreateChoiceDTO {
}) })
text!: string; text!: string;
@IsNumber()
@IsNotEmpty({
message: t('validation.requiredField', {
field: '$t(questions.fields.value)',
}),
})
value!: number;
@IsNumber() @IsNumber()
@IsNotEmpty({ @IsNotEmpty({
message: t('validation.requiredField', { message: t('validation.requiredField', {
@@ -16,10 +16,6 @@ export class FindChoicesDTO extends PartialType(PaginationQueryDTO) {
@IsOptional() @IsOptional()
text?: string; text?: string;
@IsNumber()
@IsOptional()
value?: number;
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
choiceOrder?: number; choiceOrder?: number;
@@ -4,13 +4,14 @@ import {
Index, Index,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Question } from './question.entity'; import { Question } from './question.entity';
import { UNIQUE_QUESTION_ORDER_IDX } from '../constants'; import { UNIQUE_QUESTION_ORDER_IDX } from '../constants';
@Entity() @Entity()
@Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true }) @Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true })
export class Choice { export class Choice extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -26,9 +27,6 @@ export class Choice {
}) })
text!: string; text!: string;
@Column({ type: 'int', nullable: false })
value!: number;
@Column({ type: 'int', nullable: false }) @Column({ type: 'int', nullable: false })
order!: number; order!: number;
@@ -1,8 +1,14 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Question } from './question.entity'; import { Question } from './question.entity';
@Entity() @Entity()
export class QuestionRangeRule { export class QuestionRangeRule extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -1,11 +1,17 @@
import { Test } from '@/modules/tests/entities/test.entity'; import { Test } from '@/modules/tests/entities/test.entity';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Question } from './question.entity'; import { Question } from './question.entity';
import { QuestionTransitionType } from '../enums/question-transition-type.enum'; import { QuestionTransitionType } from '../enums/question-transition-type.enum';
import type { TransitionConditionType } from '../types/transition-condition.type'; import type { TransitionConditionType } from '../types/transition-condition.type';
@Entity() @Entity()
export class QuestionTransition { export class QuestionTransition extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -7,6 +7,7 @@ import {
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { QuestionType } from '../enums/question-type.enum'; import { QuestionType } from '../enums/question-type.enum';
import type { QuestionMetadata } from '../types/question-metadata.type'; import type { QuestionMetadata } from '../types/question-metadata.type';
@@ -17,7 +18,7 @@ import { QuestionTransition } from './question-transition.entity';
@Entity() @Entity()
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true }) @Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
export class Question { export class Question extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -3,6 +3,5 @@ export enum ChoiceSortField {
QUESTION = 'question', QUESTION = 'question',
TEXT = 'text', TEXT = 'text',
ORDER = 'order', ORDER = 'order',
VALUE = 'value',
SCORE = 'score', SCORE = 'score',
} }
@@ -17,10 +17,6 @@ export const filterChoices = (query: FindChoicesDTO) => {
where.order = query.choiceOrder; where.order = query.choiceOrder;
} }
if (query.value !== undefined) {
where.value = query.value;
}
if (query.score !== undefined) { if (query.score !== undefined) {
where.score = query.score; where.score = query.score;
} }
@@ -1,8 +1,14 @@
import { Test } from '@/modules/tests/entities/test.entity'; import { Test } from '@/modules/tests/entities/test.entity';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity() @Entity()
export class ResultRange { export class ResultRange extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -1,18 +1,22 @@
import { IsArray, IsInt, IsNumber, IsOptional } from 'class-validator'; import { IsArray, IsInt, IsNumber, IsOptional } from 'class-validator';
import { SubmissionAnswer } from '../decorators/submission-answer.decorator'; import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class AnswerSubmissionDTO { export class AnswerSubmissionDTO {
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
@ApiPropertyOptional()
choiceId?: number; choiceId?: number;
@IsArray() @IsArray()
@IsOptional() @IsOptional()
@IsInt({ each: true }) @IsInt({ each: true })
@ApiPropertyOptional({ isArray: true, type: 'number' })
choiceIds?: number[]; choiceIds?: number[];
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
@ApiPropertyOptional()
numericValue?: number; numericValue?: number;
@SubmissionAnswer() @SubmissionAnswer()
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
import { GetSubmissionResultDTO } from './get-submission-result.dto';
export class GetAnswerSubmissionDTO {
@ApiProperty({
type: 'boolean',
example: false,
})
isCompleted!: boolean;
@ApiProperty({
type: GetSubmissionQuestionDTO,
nullable: true,
})
currentQuestion!: GetSubmissionQuestionDTO;
@ApiProperty({
type: GetSubmissionResultDTO,
nullable: true,
})
result!: GetSubmissionResultDTO;
}
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class GetChoiceDTO {
@ApiProperty()
id!: number;
@ApiProperty()
order!: number;
@ApiProperty()
text!: string;
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
import { GetMessageAnswerDTO } from './get-message-answer.dto';
export class GetHistoryMessageDTO {
@ApiProperty({
type: GetSubmissionQuestionDTO,
})
question!: GetSubmissionQuestionDTO;
@ApiProperty({
type: [GetMessageAnswerDTO],
})
answers!: Array<GetMessageAnswerDTO>;
}
@@ -0,0 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { GetChoiceDTO } from './get-choice.dto';
export class GetMessageAnswerDTO {
@ApiPropertyOptional()
choiceId!: boolean;
@ApiPropertyOptional({ type: GetChoiceDTO })
choice?: GetChoiceDTO;
@ApiPropertyOptional({ isArray: true, type: 'number' })
choiceIds!: Array<number>;
@ApiPropertyOptional({ isArray: true, type: GetChoiceDTO })
choices?: Array<GetChoiceDTO>;
@ApiPropertyOptional({ type: 'number' })
numericValue?: number;
}
@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { SubmissionStatus } from '../../enums/submission-status.enum';
import { GetSubmissionResultDTO } from './get-submission-result.dto';
import { GetTestByIdDTO } from '@/modules/tests/dtos/responses/get-test-by-id.dto';
export class GetSubmissionDetailDTO {
@ApiProperty()
id!: number;
@ApiProperty()
code!: string;
@ApiProperty({ type: GetTestByIdDTO })
test!: GetTestByIdDTO;
@ApiProperty({ enum: SubmissionStatus, enumName: 'status' })
status!: SubmissionStatus;
@ApiPropertyOptional({ type: GetSubmissionResultDTO })
result?: GetSubmissionResultDTO;
@ApiProperty()
createdAt!: Date;
@ApiPropertyOptional()
completedAt?: Date;
}
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
import { GetHistoryMessageDTO } from './get-history-message.dto';
export class GetSubmissionHistoryDTO {
@ApiProperty({
type: 'boolean',
example: false,
})
isCompleted!: boolean;
@ApiProperty({
isArray: true,
type: GetHistoryMessageDTO,
})
messages!: Array<GetHistoryMessageDTO>;
@ApiProperty({
type: GetSubmissionQuestionDTO,
nullable: true,
})
currentQuestion!: GetSubmissionQuestionDTO;
}
@@ -0,0 +1,63 @@
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { type QuestionMetadata } from '@/modules/questions/types/question-metadata.type';
import { ApiProperty } from '@nestjs/swagger';
import { GetChoiceDTO } from './get-choice.dto';
export class GetSubmissionQuestionDTO {
@ApiProperty({
type: 'number',
example: 1,
})
id!: number;
@ApiProperty({
enum: QuestionType,
enumName: 'questionType',
})
type!: QuestionType;
@ApiProperty({
type: 'string',
})
content!: string;
@ApiProperty({
type: 'boolean',
})
required!: boolean;
@ApiProperty({
type: [GetChoiceDTO],
})
choices!: Array<GetChoiceDTO>;
@ApiProperty({
oneOf: [
{
type: 'object',
properties: {
min: {
type: 'number',
},
max: {
type: 'number',
},
step: {
type: 'number',
},
},
required: ['min', 'max'],
},
{
type: 'object',
properties: {
allowOther: {
type: 'boolean',
},
},
required: ['allowOther'],
},
],
})
metadata!: QuestionMetadata;
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class GetSubmissionResultDTO {
@ApiProperty()
score!: number;
@ApiProperty()
probability!: number;
@ApiProperty()
title!: string;
@ApiProperty()
description!: string;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
export class StartAuthenticatedDTO {
@ApiProperty({ type: 'number', required: true })
submissionId!: number;
@ApiProperty({ type: GetSubmissionQuestionDTO })
question!: GetSubmissionQuestionDTO;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
export class StartGuestDTO {
@ApiProperty({ type: 'string', required: true })
guestSubmissionId!: string;
@ApiProperty({ type: GetSubmissionQuestionDTO })
question!: GetSubmissionQuestionDTO;
}
@@ -1,3 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber } from 'class-validator'; import { IsNotEmpty, IsNumber } from 'class-validator';
import { i18nValidationMessage as t } from 'nestjs-i18n'; import { i18nValidationMessage as t } from 'nestjs-i18n';
@@ -8,5 +9,9 @@ export class StartSubmissionDTO {
field: '$t(submissions.fields.testId)', field: '$t(submissions.fields.testId)',
}), }),
}) })
@ApiProperty({
type: 'number',
example: 1,
})
testId!: number; testId!: number;
} }
@@ -1,11 +1,13 @@
import { import {
Column, Column,
CreateDateColumn,
Entity, Entity,
Index, Index,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Submission } from './submission.entity'; import { Submission } from './submission.entity';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
@@ -16,7 +18,7 @@ import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants';
@Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], { @Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], {
unique: true, unique: true,
}) })
export class SubmissionAnswer { export class SubmissionAnswer extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -44,4 +46,7 @@ export class SubmissionAnswer {
nullable: true, nullable: true,
}) })
textValue?: string; textValue?: string;
@CreateDateColumn()
createdAt!: Date;
} }
@@ -7,13 +7,14 @@ import {
ManyToOne, ManyToOne,
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Submission } from './submission.entity'; import { Submission } from './submission.entity';
import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
@Entity() @Entity()
@Check(`"probability" >= 0 AND "probability" <= 100`) @Check(`"probability" >= 0 AND "probability" <= 100`)
export class SubmissionResult { export class SubmissionResult extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -8,6 +8,7 @@ import {
OneToMany, OneToMany,
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { SubmissionStatus } from '../enums/submission-status.enum'; import { SubmissionStatus } from '../enums/submission-status.enum';
import { SubmissionAnswer } from './submission-answer.entity'; import { SubmissionAnswer } from './submission-answer.entity';
@@ -15,10 +16,13 @@ import { SubmissionResult } from './submission-result.entity';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
@Entity() @Entity()
export class Submission { export class Submission extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column({ type: 'varchar', nullable: true })
code!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' }) @ManyToOne(() => User, { onDelete: 'CASCADE' })
user!: User; user!: User;
@@ -43,4 +47,7 @@ export class Submission {
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
completedAt?: Date;
} }
@@ -1,2 +1,5 @@
GET {{baseUrl}}/submissions/6183e332-d28d-45ba-90a7-942f9d15100f/history GET {{baseUrl}}/submissions/1/history
Authorization: Bearer {{$global.accessToken}}
GET {{baseUrl}}/submissions
Authorization: Bearer {{$global.accessToken}} Authorization: Bearer {{$global.accessToken}}
@@ -1,17 +1,17 @@
POST {{baseUrl}}/submissions/start POST {{baseUrl}}/submissions/start
Content-Type: application/json Content-Type: application/json
# Authorization: Bearer {{$global.accessToken}} Authorization: Bearer {{$global.accessToken}}
{ {
"testId": 3 "testId": 1
} }
POST {{baseUrl}}/submissions/6183e332-d28d-45ba-90a7-942f9d15100f/answer POST {{baseUrl}}/submissions/2/answer
Content-Type: application/json Content-Type: application/json
# Authorization: Bearer {{$global.accessToken}} Authorization: Bearer {{$global.accessToken}}
{ {
"choiceId": 57 "choiceId": 33
// "numericValue": 10 // "numericValue": 1
// "choiceIds": [45] // "choiceIds": []
} }
@@ -183,7 +183,11 @@ export class AnswerAuthenticatedProvider {
await manager.update( await manager.update(
Submission, Submission,
{ id: submission.id }, { id: submission.id },
{ currentQuestion: null, status: SubmissionStatus.COMPLETED }, {
currentQuestion: null,
status: SubmissionStatus.COMPLETED,
completedAt: new Date(),
},
); );
const result = const result =
@@ -239,11 +239,14 @@ export class CalculateResultProvider {
} }
}); });
const probability =
totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0;
const resultRanges = await loadResultRanges(score); const resultRanges = await loadResultRanges(score);
return { return {
score, score,
probability: totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0, probability,
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined, resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
}; };
} }
@@ -96,6 +96,7 @@ export class HistoryAuthenticatedProvider {
type: answer.question.type, type: answer.question.type,
content: answer.question.content, content: answer.question.content,
required: answer.question.required, required: answer.question.required,
choices: answer.choices,
metadata: answer.question.metadata, metadata: answer.question.metadata,
}, },
answer: { answer: {
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Submission } from '../../entities/submission.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '@/modules/users/entities/user.entity';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppResponse } from '@/common/responses';
@Injectable()
export class UserSubmissionListingProvider {
constructor(
/**
* Inject Submission Repository
*/
@InjectRepository(Submission)
private readonly submissionsRepository: Repository<Submission>,
) {}
public async getSubmissionsList(user: User) {
let userSubmissions: Array<Submission>;
try {
userSubmissions = await this.submissionsRepository.find({
where: {
user: { id: user.id },
},
relations: {
result: true,
test: true,
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
return new AppResponse(userSubmissions);
}
}
@@ -2,7 +2,6 @@ import { AccessType } from '@/modules/tests/enums/access-types.enum';
import { TestsService } from '@/modules/tests/providers/tests.service'; import { TestsService } from '@/modules/tests/providers/tests.service';
import { User } from '@/modules/users/entities/user.entity'; import { User } from '@/modules/users/entities/user.entity';
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -12,7 +11,6 @@ import { Submission } from '../../entities/submission.entity';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppResponse } from '@/common/responses'; import { AppResponse } from '@/common/responses';
import { SubmissionStatus } from '../../enums/submission-status.enum';
import { Test } from '@/modules/tests/entities/test.entity'; import { Test } from '@/modules/tests/entities/test.entity';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { RedisService } from '@/common/modules/redis/providers/redis.service'; import { RedisService } from '@/common/modules/redis/providers/redis.service';
@@ -20,6 +18,7 @@ import { GuestSubmissionSession } from '../../interfaces/guest-submission-sessio
import { TestStatus } from '@/modules/tests/enums/test-status.enum'; import { TestStatus } from '@/modules/tests/enums/test-status.enum';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity'; import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import Utils from '@/common/utils';
@Injectable() @Injectable()
export class StartSubmissionProvider { export class StartSubmissionProvider {
@@ -113,41 +112,19 @@ export class StartSubmissionProvider {
} }
private async startAuthenticated(test: Test, user: User) { private async startAuthenticated(test: Test, user: User) {
let submission: Submission | null = null;
try {
submission = await this.submissionsRepository.findOne({
where: {
test: {
id: test.id,
},
user: {
id: user.id,
},
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (submission && submission.status === SubmissionStatus.COMPLETED) {
throw new BadRequestException('submissions.errors.alreadyTookTest');
}
const firstQuestion = await this.getFirstQuestion(test.id); const firstQuestion = await this.getFirstQuestion(test.id);
if (!submission) { const submission = this.submissionsRepository.create({
submission = this.submissionsRepository.create({ test,
test, user: user || undefined,
user: user || undefined, currentQuestion: firstQuestion,
currentQuestion: firstQuestion, code: Utils.StringUtils.generateCode({ prefix: 's-' }),
}); });
try { try {
await this.submissionsRepository.save(submission); await this.submissionsRepository.save(submission);
} catch (err) { } catch (err) {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
}
} }
return new AppResponse({ return new AppResponse({
@@ -4,6 +4,7 @@ import { User } from '@/modules/users/entities/user.entity';
import { AnswerSubmissionProvider } from './answer/answer-submission.provider'; import { AnswerSubmissionProvider } from './answer/answer-submission.provider';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
import { SubmissionHistoryProvider } from './history/submission-history.provider'; import { SubmissionHistoryProvider } from './history/submission-history.provider';
import { UserSubmissionListingProvider } from './listing/user-submission-listing.provider';
@Injectable() @Injectable()
export class SubmissionsService { export class SubmissionsService {
@@ -22,6 +23,11 @@ export class SubmissionsService {
* Inject Submission History Provider * Inject Submission History Provider
*/ */
private readonly submissionHistoryProvider: SubmissionHistoryProvider, private readonly submissionHistoryProvider: SubmissionHistoryProvider,
/**
* Inject User Submission Listing Provider
*/
private readonly userSubmissionListingProvider: UserSubmissionListingProvider,
) {} ) {}
public async startSubmission(testId: number, user?: User) { public async startSubmission(testId: number, user?: User) {
@@ -43,4 +49,8 @@ export class SubmissionsService {
public async getSubmissionHistory(id: string, user?: User) { public async getSubmissionHistory(id: string, user?: User) {
return await this.submissionHistoryProvider.getHistory(id, user); return await this.submissionHistoryProvider.getHistory(id, user);
} }
public async getUserSubmissions(user: User) {
return await this.userSubmissionListingProvider.getSubmissionsList(user);
}
} }
@@ -15,6 +15,12 @@ import { ActiveUser } from '@/common/decorators/active-user.decorator';
import { SubmissionsService } from './providers/submissions.service'; import { SubmissionsService } from './providers/submissions.service';
import { StartSubmissionDTO } from './dtos/start-submission.dto'; import { StartSubmissionDTO } from './dtos/start-submission.dto';
import { AnswerSubmissionDTO } from './dtos/answer-submission.dto'; import { AnswerSubmissionDTO } from './dtos/answer-submission.dto';
import { ApiAppResponse } from '@/common/decorators/api-app-response.decorator';
import { StartAuthenticatedDTO } from './dtos/responses/start-authenticated.dto';
import { StartGuestDTO } from './dtos/responses/start-guest.dto';
import { GetAnswerSubmissionDTO } from './dtos/responses/get-answer-submission.dto';
import { GetSubmissionHistoryDTO } from './dtos/responses/get-submission-history.dto';
import { GetSubmissionDetailDTO } from './dtos/responses/get-submission-detail.dto';
@Controller('submissions') @Controller('submissions')
export class SubmissionsController { export class SubmissionsController {
@@ -29,6 +35,63 @@ export class SubmissionsController {
@Post('start') @Post('start')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(OptionalJwtGuard) @UseGuards(OptionalJwtGuard)
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
model: StartAuthenticatedDTO,
example: {
data: {
submissionId: 1,
question: {
id: 1,
type: 'numeric',
content: 'string',
required: true,
choices: [],
metadata: {
min: 0,
max: 10,
step: 1,
},
},
},
},
},
{
model: StartGuestDTO,
example: {
data: {
guestSubmissionId: 'sahfdih123ieg1231fds23ljhakshf',
question: {
id: 2,
type: 'single-choice',
content: 'string',
required: true,
choices: [
{
id: 1,
order: 1,
text: 'string',
},
],
metadata: null,
},
},
},
},
],
},
{
status: HttpStatus.NOT_FOUND,
variants: [{ messageExample: 'تست مورد نظر یافت نشد.' }],
},
{
status: HttpStatus.FORBIDDEN,
variants: [{ messageExample: 'تست مورد نظر فعال نیست.' }],
},
])
public async startTest( public async startTest(
@Body() startSubmissionDto: StartSubmissionDTO, @Body() startSubmissionDto: StartSubmissionDTO,
@ActiveUser() user?: User, @ActiveUser() user?: User,
@@ -43,6 +106,16 @@ export class SubmissionsController {
@Post(':id/answer') @Post(':id/answer')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(OptionalJwtGuard) @UseGuards(OptionalJwtGuard)
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
model: GetAnswerSubmissionDTO,
},
],
},
])
public async answerSubmission( public async answerSubmission(
@Param('id') id: string, @Param('id') id: string,
@Body() answerSubmissionDto: AnswerSubmissionDTO, @Body() answerSubmissionDto: AnswerSubmissionDTO,
@@ -58,7 +131,33 @@ export class SubmissionsController {
@Public() @Public()
@Get(':id/history') @Get(':id/history')
@UseGuards(OptionalJwtGuard) @UseGuards(OptionalJwtGuard)
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
model: GetSubmissionHistoryDTO,
},
],
},
])
public async getHistory(@Param('id') id: string, @ActiveUser() user?: User) { public async getHistory(@Param('id') id: string, @ActiveUser() user?: User) {
return await this.submissionsService.getSubmissionHistory(id, user); return await this.submissionsService.getSubmissionHistory(id, user);
} }
@Get()
@ApiAppResponse([
{
status: HttpStatus.OK,
variants: [
{
model: GetSubmissionDetailDTO,
isArray: true,
},
],
},
])
public async getUserSubmissions(@ActiveUser() user: User) {
return await this.submissionsService.getUserSubmissions(user);
}
} }
@@ -17,6 +17,7 @@ import { AnswerAuthenticatedProvider } from './providers/answer/answer-authentic
import { HistoryAuthenticatedProvider } from './providers/history/history-authenticated.provider'; import { HistoryAuthenticatedProvider } from './providers/history/history-authenticated.provider';
import { HistoryGuestProvider } from './providers/history/history-guest.provider'; import { HistoryGuestProvider } from './providers/history/history-guest.provider';
import { SubmissionHistoryProvider } from './providers/history/submission-history.provider'; import { SubmissionHistoryProvider } from './providers/history/submission-history.provider';
import { UserSubmissionListingProvider } from './providers/listing/user-submission-listing.provider';
@Module({ @Module({
imports: [ imports: [
@@ -36,6 +37,7 @@ import { SubmissionHistoryProvider } from './providers/history/submission-histor
HistoryAuthenticatedProvider, HistoryAuthenticatedProvider,
HistoryGuestProvider, HistoryGuestProvider,
SubmissionHistoryProvider, SubmissionHistoryProvider,
UserSubmissionListingProvider,
SubmissionsService, SubmissionsService,
], ],
}) })

Some files were not shown because too many files have changed in this diff Show More