Compare commits
10
Commits
7658ba805d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace4342870 | ||
|
|
195eca1015 | ||
|
|
f3fe1f55e4 | ||
|
|
5c4e9df341 | ||
|
|
9842f4db31 | ||
|
|
13ba11f951 | ||
|
|
76f6c97a9e | ||
|
|
17b47ffd91 | ||
|
|
fbce16ef60 | ||
|
|
f660a6b7a7 |
Generated
+4200
-116
File diff suppressed because it is too large
Load Diff
@@ -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,10 +43,14 @@
|
|||||||
"@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",
|
||||||
@@ -66,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",
|
||||||
|
|||||||
+16
-1
@@ -24,6 +24,11 @@ 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 { 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;
|
||||||
|
|
||||||
@@ -48,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,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -96,7 +108,10 @@ const ENV = process.env.NODE_ENV;
|
|||||||
SubmissionsModule,
|
SubmissionsModule,
|
||||||
ScoringModule,
|
ScoringModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
AdminPanelModule,
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
|
ChatbotModule,
|
||||||
|
ConversationModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}));
|
||||||
@@ -23,4 +23,11 @@ export default Joi.object({
|
|||||||
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'),
|
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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('openRouter', () => ({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY,
|
||||||
|
model: process.env.OPENROUTER_MODEL,
|
||||||
|
}));
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
-813
@@ -1,829 +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: 'بله',
|
|
||||||
order: 1,
|
|
||||||
score: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const q1No = choiceRepository.create({
|
|
||||||
question: q1,
|
|
||||||
text: 'خیر',
|
|
||||||
order: 2,
|
|
||||||
score: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await choiceRepository.save([q1Yes, q1No]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ---------------------------------------------------------
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await choiceRepository.save([q5Yes, q5No, q5NotSure]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ---------------------------------------------------------
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await choiceRepository.save([q6Yes, q6No, q6NotSure]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ---------------------------------------------------------
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await choiceRepository.save([q7Yes, q7No]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ---------------------------------------------------------
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await choiceRepository.save([q8Yes, q8No]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ---------------------------------------------------------
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await choiceRepository.save([q10Yes, q10No]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ---------------------------------------------------------
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -840,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) {
|
||||||
|
|||||||
@@ -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.`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversationId": "Conversation Id",
|
||||||
|
"message": "Message"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversation": "Conversation"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"resultRangeOverlappingConstraint": "The specified score range has some overlaps with other records.",
|
"resultRangeOverlappingConstraint": "The specified score range has some overlaps with other records.",
|
||||||
"resultRangeMinMaxConstraint": "minProbability value should be less than maxProbability."
|
"resultRangeMinMaxConstraint": "minScore value should be less than maxScore."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"otpSent": "کد تایید به شماره {phone} ارسال شد.",
|
"otpSent": "کد تایید به شماره {phone} ارسال شد.",
|
||||||
"sendPass": "لطفا رمز عبور را ارسال کنید."
|
"sendPass": "لطفا رمز عبور را ارسال کنید.",
|
||||||
|
"loggedOut": "با موفقیت خارج شدید."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversationId": "شناسه مکالمه",
|
||||||
|
"message": "پیام"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversation": "مکالمه"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"resultRangeOverlappingConstraint": "بازه امتیاز مشخص شده با بازه های دیگر تداخل دارد.",
|
"resultRangeOverlappingConstraint": "بازه امتیاز مشخص شده با بازه های دیگر تداخل دارد.",
|
||||||
"resultRangeMinMaxConstraint": "minProbability باید کمتر از maxProbability باشد."
|
"resultRangeMinMaxConstraint": "minScore باید کمتر از maxScore باشد."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { 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, { bufferLogs: true });
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
@@ -14,6 +15,10 @@ async function bootstrap() {
|
|||||||
|
|
||||||
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,
|
||||||
@@ -94,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();
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class ChangeResultRangeFields1788950646073 implements MigrationInterface {
|
export class ChangeResultRangeFields1788950646073 implements MigrationInterface {
|
||||||
name = 'ChangeResultRangeFields1788950646073'
|
name = 'ChangeResultRangeFields1788950646073';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" DROP COLUMN "minScore"`);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" DROP COLUMN "maxScore"`);
|
`ALTER TABLE "result_range" DROP COLUMN "minScore"`,
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" ADD "minProbability" integer NOT NULL`);
|
);
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" ADD "maxProbability" integer NOT NULL`);
|
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> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" DROP COLUMN "maxProbability"`);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" DROP COLUMN "minProbability"`);
|
`ALTER TABLE "result_range" DROP COLUMN "maxProbability"`,
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" ADD "maxScore" integer NOT NULL`);
|
);
|
||||||
await queryRunner.query(`ALTER TABLE "result_range" ADD "minScore" integer NOT NULL`);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,6 @@ Content-Type: application/json
|
|||||||
"test": 4,
|
"test": 4,
|
||||||
"title": "احتمال خیلی بالا",
|
"title": "احتمال خیلی بالا",
|
||||||
"description": "احتمال ابتلا خیلی بالا است.",
|
"description": "احتمال ابتلا خیلی بالا است.",
|
||||||
"minProbability": 21,
|
"minScore": 21,
|
||||||
"maxProbability": 40
|
"maxScore": 40
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -126,4 +130,32 @@ export class AuthController {
|
|||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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.
|
||||||
|
`;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ export class CreateResultRangeDTO {
|
|||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
minProbability!: number;
|
minScore!: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
maxProbability!: number;
|
maxScore!: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export class FindResultRangeDTO extends PartialType(PaginationQueryDTO) {
|
|||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
minProbability?: number;
|
minScore?: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
maxProbability?: number;
|
maxScore?: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -15,13 +21,13 @@ export class ResultRange {
|
|||||||
type: 'int',
|
type: 'int',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
minProbability!: number;
|
minScore!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'int',
|
type: 'int',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
maxProbability!: number;
|
maxScore!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export enum ResultRangeSortField {
|
export enum ResultRangeSortField {
|
||||||
ID = 'id',
|
ID = 'id',
|
||||||
TEST = 'test',
|
TEST = 'test',
|
||||||
MIN_PROBABILITY = 'minProbability',
|
MIN_SCORE = 'minScore',
|
||||||
MAX_PROBABILITY = 'maxProbability',
|
MAX_SCORE = 'maxScore',
|
||||||
TITLE = 'title',
|
TITLE = 'title',
|
||||||
DESCRIPTION = 'description',
|
DESCRIPTION = 'description',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ export const filterResultRanges = (query: FindResultRangeDTO) => {
|
|||||||
where.title = ILike(`%${query.title}%`);
|
where.title = ILike(`%${query.title}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.maxProbability !== undefined) {
|
if (query.maxScore !== undefined) {
|
||||||
where.maxProbability = query.maxProbability;
|
where.maxScore = query.maxScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.minProbability !== undefined) {
|
if (query.minScore !== undefined) {
|
||||||
where.minProbability = query.minProbability;
|
where.minScore = query.minScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
return where;
|
return where;
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ export class ResultRangeCommandProvider {
|
|||||||
|
|
||||||
private async checkRangeOverlap(
|
private async checkRangeOverlap(
|
||||||
test: number,
|
test: number,
|
||||||
minProbability: number,
|
minScore: number,
|
||||||
maxProbability: number,
|
maxScore: number,
|
||||||
excludeSelf: boolean = false,
|
excludeSelf: boolean = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const queryBuilder = this.resultRangeRepository
|
const queryBuilder = this.resultRangeRepository
|
||||||
.createQueryBuilder('resultRange')
|
.createQueryBuilder('resultRange')
|
||||||
.where('resultRange.testId = :test', { test })
|
.where('resultRange.testId = :test', { test })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
'(resultRange.minProbability BETWEEN :minProbability AND :maxProbability) OR (resultRange.maxProbability BETWEEN :minProbability AND :maxProbability) OR (:minProbability BETWEEN resultRange.minProbability AND resultRange.maxProbability) OR (:maxProbability BETWEEN resultRange.minProbability AND resultRange.maxProbability)',
|
'(resultRange.minScore BETWEEN :minScore AND :maxScore) OR (resultRange.maxScore BETWEEN :minScore AND :maxScore) OR (:minScore BETWEEN resultRange.minScore AND resultRange.maxScore) OR (:maxScore BETWEEN resultRange.minScore AND resultRange.maxScore)',
|
||||||
{ minProbability, maxProbability },
|
{ minScore, maxScore },
|
||||||
);
|
);
|
||||||
|
|
||||||
let overlappingRanges: Array<ResultRange>;
|
let overlappingRanges: Array<ResultRange>;
|
||||||
@@ -92,8 +92,8 @@ export class ResultRangeCommandProvider {
|
|||||||
|
|
||||||
await this.checkRangeOverlap(
|
await this.checkRangeOverlap(
|
||||||
test.id,
|
test.id,
|
||||||
createResultRangeDto.minProbability,
|
createResultRangeDto.minScore,
|
||||||
createResultRangeDto.maxProbability,
|
createResultRangeDto.maxScore,
|
||||||
);
|
);
|
||||||
|
|
||||||
const newResultRange = this.resultRangeRepository.create({
|
const newResultRange = this.resultRangeRepository.create({
|
||||||
@@ -129,23 +129,23 @@ export class ResultRangeCommandProvider {
|
|||||||
? updateResultRangeDto.test
|
? updateResultRangeDto.test
|
||||||
: resultRange.test.id;
|
: resultRange.test.id;
|
||||||
|
|
||||||
const minProbability =
|
const minScore =
|
||||||
updateResultRangeDto.minProbability !== undefined
|
updateResultRangeDto.minScore !== undefined
|
||||||
? updateResultRangeDto.minProbability
|
? updateResultRangeDto.minScore
|
||||||
: resultRange.minProbability;
|
: resultRange.minScore;
|
||||||
|
|
||||||
const maxProbability =
|
const maxScore =
|
||||||
updateResultRangeDto.maxProbability !== undefined
|
updateResultRangeDto.maxScore !== undefined
|
||||||
? updateResultRangeDto.maxProbability
|
? updateResultRangeDto.maxScore
|
||||||
: resultRange.maxProbability;
|
: resultRange.maxScore;
|
||||||
|
|
||||||
if (minProbability >= maxProbability) {
|
if (minScore >= maxScore) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'scoring.errors.resultRangeMinMaxConstraint',
|
'scoring.errors.resultRangeMinMaxConstraint',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.checkRangeOverlap(testId, minProbability, maxProbability, true);
|
await this.checkRangeOverlap(testId, minScore, maxScore, true);
|
||||||
|
|
||||||
Object.assign(resultRange, updateResultRangeDto);
|
Object.assign(resultRange, updateResultRangeDto);
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ export class ResultRangeValidator implements ValidatorConstraintInterface {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const obj = validationArguments?.object as any;
|
const obj = validationArguments?.object as any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const { minProbability, maxProbability } = obj;
|
const { minScore, maxScore } = obj;
|
||||||
return minProbability < maxProbability;
|
return minScore < maxScore;
|
||||||
}
|
}
|
||||||
defaultMessage?(): string {
|
defaultMessage?(): string {
|
||||||
const i18n = I18nContext.current();
|
const i18n = I18nContext.current();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
i18n?.t('scoring.errors.resultRangeMinMaxConstraint') ||
|
i18n?.t('scoring.errors.resultRangeMinMaxConstraint') ||
|
||||||
'minProbability value should be less than maxProbability.'
|
'minScore value should be less than maxScore.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
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';
|
||||||
@@ -17,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +16,7 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
GET {{baseUrl}}/submissions/6183e332-d28d-45ba-90a7-942f9d15100f/history
|
GET {{baseUrl}}/submissions/1/history
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
GET {{baseUrl}}/submissions
|
GET {{baseUrl}}/submissions
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ export class CalculateResultProvider {
|
|||||||
rangeRules: true,
|
rangeRules: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (probability) =>
|
async (score) =>
|
||||||
manager.find(ResultRange, {
|
manager.find(ResultRange, {
|
||||||
where: {
|
where: {
|
||||||
test: { id: submission.test.id },
|
test: { id: submission.test.id },
|
||||||
minProbability: LessThanOrEqual(probability),
|
minScore: LessThanOrEqual(score),
|
||||||
maxProbability: MoreThanOrEqual(probability),
|
maxScore: MoreThanOrEqual(score),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -119,12 +119,12 @@ export class CalculateResultProvider {
|
|||||||
rangeRules: true,
|
rangeRules: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (probability) =>
|
async (score) =>
|
||||||
this.resultRangeRepository.find({
|
this.resultRangeRepository.find({
|
||||||
where: {
|
where: {
|
||||||
test: { id: submission.testId },
|
test: { id: submission.testId },
|
||||||
minProbability: LessThanOrEqual(probability),
|
minScore: LessThanOrEqual(score),
|
||||||
maxProbability: MoreThanOrEqual(probability),
|
maxScore: MoreThanOrEqual(score),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -150,7 +150,7 @@ export class CalculateResultProvider {
|
|||||||
numericValue?: number;
|
numericValue?: number;
|
||||||
}>,
|
}>,
|
||||||
loadQuestions: (questionIds: number[]) => Promise<Question[]>,
|
loadQuestions: (questionIds: number[]) => Promise<Question[]>,
|
||||||
loadResultRanges: (probability: number) => Promise<ResultRange[]>,
|
loadResultRanges: (score: number) => Promise<ResultRange[]>,
|
||||||
) {
|
) {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ export class CalculateResultProvider {
|
|||||||
const probability =
|
const probability =
|
||||||
totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0;
|
totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0;
|
||||||
|
|
||||||
const resultRanges = await loadResultRanges(probability);
|
const resultRanges = await loadResultRanges(score);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score,
|
score,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
BaseEntity,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AccessType } from '../enums/access-types.enum';
|
import { AccessType } from '../enums/access-types.enum';
|
||||||
import { Question } from '@/modules/questions/entities/question.entity';
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
@@ -18,7 +19,7 @@ import { QuestionTransition } from '@/modules/questions/entities/question-transi
|
|||||||
OR
|
OR
|
||||||
("price" IS NOT NULL AND "accessType" = 'paid')
|
("price" IS NOT NULL AND "accessType" = 'paid')
|
||||||
`)
|
`)
|
||||||
export class Test {
|
export class Test extends BaseEntity {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
GET {{baseUrl}}/tests
|
GET {{baseUrl}}/tests
|
||||||
|
|
||||||
GET {{baseUrl}}/tests/4
|
GET {{baseUrl}}/tests/1
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
Index,
|
Index,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
|
BaseEntity,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User {
|
export class User extends BaseEntity {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user