Compare commits
24
Commits
195ace097c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace4342870 | ||
|
|
195eca1015 | ||
|
|
f3fe1f55e4 | ||
|
|
5c4e9df341 | ||
|
|
9842f4db31 | ||
|
|
13ba11f951 | ||
|
|
76f6c97a9e | ||
|
|
17b47ffd91 | ||
|
|
fbce16ef60 | ||
|
|
f660a6b7a7 | ||
|
|
7658ba805d | ||
|
|
bc333b8289 | ||
|
|
f3c86da09b | ||
|
|
f0bf186b6c | ||
|
|
f90b802419 | ||
|
|
287091724d | ||
|
|
0961b8f11d | ||
|
|
8fced148b7 | ||
|
|
d1324eb960 | ||
|
|
9ecc9647b5 | ||
|
|
dd9a31278e | ||
|
|
7dd9206ca6 | ||
|
|
f75e33a7dc | ||
|
|
73e8fc71bb |
@@ -28,6 +28,7 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
|
||||
Generated
+4956
-116
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -25,9 +25,14 @@
|
||||
"migration:run:dev": "npm run typeorm:dev -- migration:run",
|
||||
"migration:run:prod": "npm run typeorm:prod -- migration:run",
|
||||
"migration:revert:dev": "npm run typeorm:dev -- migration:revert",
|
||||
"migration:revert:prod": "npm run typeorm:prod -- migration:revert"
|
||||
"migration:revert:prod": "npm run typeorm:prod -- migration:revert",
|
||||
"seed": "cross-env NODE_ENV=development tsx src/database/seeds/seed.ts",
|
||||
"seed:prod": "cross-env NODE_ENV=production node dist/database/seeds/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adminjs/express": "^5.1.0",
|
||||
"@adminjs/nestjs": "^5.1.1",
|
||||
"@adminjs/typeorm": "^4.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -38,17 +43,24 @@
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@openrouter/sdk": "^1.3.33",
|
||||
"adminjs": "^6.8.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express-formidable": "^1.2.0",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.2.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"joi": "^18.0.2",
|
||||
"nestjs-i18n": "^10.6.0",
|
||||
"nestjs-pino": "^5.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.18.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28"
|
||||
@@ -61,6 +73,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
@@ -71,6 +84,7 @@
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
@@ -78,6 +92,7 @@
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
|
||||
+21
-1
@@ -23,6 +23,12 @@ import { ScoringModule } from './modules/scoring/scoring.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { AppThrottlerGuard } from './common/guards/app-throttler.guard';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggingModule } from './common/modules/logging/logging.module';
|
||||
import { ChatbotModule } from './modules/chatbot/chatbot.module';
|
||||
import { ConversationModule } from './modules/conversation/conversation.module';
|
||||
import { AdminPanelModule } from './modules/admin-panel/admin-panel.module';
|
||||
import openRouterConfig from './config/open-router.config';
|
||||
import adminConfig from './config/admin.config';
|
||||
|
||||
const ENV = process.env.NODE_ENV;
|
||||
|
||||
@@ -47,7 +53,14 @@ const ENV = process.env.NODE_ENV;
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: !ENV ? '.env' : `.env.${ENV}`,
|
||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
openRouterConfig,
|
||||
adminConfig,
|
||||
],
|
||||
validationSchema: environmentValidation,
|
||||
}),
|
||||
|
||||
@@ -79,6 +92,9 @@ const ENV = process.env.NODE_ENV;
|
||||
// ]),
|
||||
ThrottlerModule.forRoot([]),
|
||||
|
||||
// Logging
|
||||
LoggingModule,
|
||||
|
||||
/**
|
||||
* Local Modules
|
||||
*/
|
||||
@@ -92,6 +108,10 @@ const ENV = process.env.NODE_ENV;
|
||||
SubmissionsModule,
|
||||
ScoringModule,
|
||||
AdminModule,
|
||||
AdminPanelModule,
|
||||
LoggingModule,
|
||||
ChatbotModule,
|
||||
ConversationModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { applyDecorators, Type } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
import { AppResponse } from '../responses';
|
||||
|
||||
interface ApiAppResponseVariant<T = any> {
|
||||
model?: Type<T>;
|
||||
isArray?: boolean;
|
||||
summary?: string;
|
||||
example?: AppResponse<T>;
|
||||
messageExample?: string;
|
||||
}
|
||||
|
||||
interface ApiAppResponseStatus {
|
||||
status: number;
|
||||
description?: string;
|
||||
variants: ApiAppResponseVariant[];
|
||||
}
|
||||
|
||||
export function ApiAppResponse(
|
||||
options: ApiAppResponseStatus[],
|
||||
): MethodDecorator {
|
||||
const models = options.flatMap((status) =>
|
||||
status.variants
|
||||
.filter((variant) => variant.model)
|
||||
.map((variant) => variant.model!),
|
||||
);
|
||||
|
||||
return applyDecorators(
|
||||
ApiExtraModels(AppResponse, ...models),
|
||||
|
||||
...options.map((status) => {
|
||||
const variants = status.variants;
|
||||
|
||||
const schemas = variants.map((variant) => ({
|
||||
allOf: [
|
||||
{
|
||||
$ref: getSchemaPath(AppResponse),
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: variant.model
|
||||
? variant.isArray
|
||||
? {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: getSchemaPath(variant.model),
|
||||
},
|
||||
}
|
||||
: {
|
||||
$ref: getSchemaPath(variant.model),
|
||||
}
|
||||
: {
|
||||
nullable: true,
|
||||
},
|
||||
|
||||
message: {
|
||||
type: 'string',
|
||||
example:
|
||||
variant.messageExample ?? 'Operation completed successfully',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return ApiResponse({
|
||||
status: status.status,
|
||||
description: status.description,
|
||||
|
||||
content: {
|
||||
'application/json': {
|
||||
schema:
|
||||
variants.length > 1
|
||||
? {
|
||||
oneOf: schemas,
|
||||
}
|
||||
: schemas[0],
|
||||
|
||||
...(variants.some((variant) => variant.example)
|
||||
? {
|
||||
examples: Object.fromEntries(
|
||||
variants
|
||||
.filter((variant) => variant.example)
|
||||
.map((variant, index) => [
|
||||
`response${index + 1}`,
|
||||
{
|
||||
summary: variant.summary ?? `Response ${index + 1}`,
|
||||
value: variant.example,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
@@ -16,6 +17,11 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
* Inject I18n Service
|
||||
*/
|
||||
private readonly i18nService: I18nService,
|
||||
|
||||
/**
|
||||
* Inject Pino Logger
|
||||
*/
|
||||
private readonly logger: PinoLogger,
|
||||
) {}
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
@@ -40,6 +46,8 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
args: (exceptionResponse as any).params,
|
||||
});
|
||||
|
||||
this.logException(request, status, exception);
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
@@ -52,4 +60,28 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
logException(request: Request, status: number, exception?: any) {
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
{
|
||||
err: exception,
|
||||
stack: exception instanceof Error ? exception.stack : undefined,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: status,
|
||||
},
|
||||
'Unhandled server exception',
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: status,
|
||||
},
|
||||
'HTTP exception',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
pinoHttp: {
|
||||
level: configService.get<string>('app.logLevel') ?? 'info',
|
||||
genReqId: (req) => {
|
||||
const requestId = req.headers['x-request-id'];
|
||||
|
||||
if (typeof requestId === 'string' && requestId.length > 0) {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
return crypto.randomUUID();
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'req.headers["set-cookie"]',
|
||||
'password',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'otp',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
|
||||
// serializers: {
|
||||
// req: (req) => ({
|
||||
// method: req.method,
|
||||
// url: req.url,
|
||||
// remoteAddress: req.remoteAddress,
|
||||
// }),
|
||||
|
||||
// res: (res) => ({
|
||||
// statusCode: res.statusCode,
|
||||
// }),
|
||||
// },
|
||||
|
||||
customProps: (req) => ({
|
||||
requestId: req.id,
|
||||
}),
|
||||
|
||||
customLogLevel: (req, res, error) => {
|
||||
if (error || res.statusCode >= 500) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'info';
|
||||
},
|
||||
|
||||
transport:
|
||||
configService.get<string>('app.environment') !== 'production'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
singleLine: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [LoggerModule],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisService } from './providers/redis.service';
|
||||
import { REDIS } from './constants/redis.constants';
|
||||
@@ -15,6 +13,7 @@ import Redis from 'ioredis';
|
||||
return new Redis({
|
||||
host: configService.get<string>('redis.host'),
|
||||
port: configService.get<number>('redis.port'),
|
||||
password: configService.get<string>('redis.password'),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,3 +7,24 @@ export const normalizePhone = (phone: string): string => {
|
||||
|
||||
return phone;
|
||||
};
|
||||
|
||||
type GenerateCodeOptions = {
|
||||
prefix?: string;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
export const generateCode = (options?: GenerateCodeOptions) => {
|
||||
const prefix = options?.prefix || '';
|
||||
const length = options?.length || 5;
|
||||
|
||||
const characters =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
let random = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
random += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
|
||||
return `${prefix}${random}`;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
@@ -9,4 +9,5 @@ export default registerAs('app', () => ({
|
||||
swaggerServers: process.env.SWAGGER_SERVERS?.split(',').map((origin) =>
|
||||
origin.trim(),
|
||||
),
|
||||
logLevel: process.env.LOG_LEVEL,
|
||||
}));
|
||||
|
||||
@@ -18,7 +18,16 @@ export default Joi.object({
|
||||
JWT_AUDIENCE: Joi.string().required(),
|
||||
REDIS_HOST: Joi.string().required(),
|
||||
REDIS_PORT: Joi.number().port().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().required(),
|
||||
CORS_ORIGINS: Joi.string().required(),
|
||||
CORS_SUBDOMAIN: Joi.string().required(),
|
||||
SWAGGER_SERVERS: Joi.string().required(),
|
||||
LOG_LEVEL: Joi.string().default('info'),
|
||||
OPENROUTER_API_KEY: Joi.string().required(),
|
||||
OPENROUTER_MODEL: Joi.string().required(),
|
||||
ADMIN_PANEL_ENABLED: Joi.string().valid('true', 'false').default('true'),
|
||||
// Required at runtime when the panel is enabled (validated in AdminPanelSetup)
|
||||
ADMIN_COOKIE_PASSWORD: Joi.string().allow('').default(''),
|
||||
ADMIN_ROOT_PATH: Joi.string().default('/admin'),
|
||||
ADMIN_SESSION_TTL_MINUTES: Joi.number().integer().min(5).default(120),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('openRouter', () => ({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
model: process.env.OPENROUTER_MODEL,
|
||||
}));
|
||||
@@ -3,4 +3,5 @@ import { registerAs } from '@nestjs/config';
|
||||
export default registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
}));
|
||||
|
||||
@@ -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: 'امتیاز شما در محدوده خطر بسیار بالا قرار دارد.',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import dataSource from '../data-source';
|
||||
|
||||
import { Test } from '../../modules/tests/entities/test.entity';
|
||||
|
||||
import { seedSuperuser } from './superuser.seed';
|
||||
import { seedTests } from './tests.seed';
|
||||
import { seedQuestions } from './questions.seed';
|
||||
import { seedChoices } from './choices.seed';
|
||||
import { seedQuestionRangeRules } from './question-range-rules.seed';
|
||||
import { seedTransitions } from './transitions.seed';
|
||||
import { seedResultRange } from './result-range.seed';
|
||||
|
||||
async function prepareTests() {
|
||||
const result = await dataSource.transaction(async (manager) => {
|
||||
const seedTestResult = await seedTests(manager);
|
||||
|
||||
let savedTest: Test;
|
||||
|
||||
if (seedTestResult instanceof Test) {
|
||||
savedTest = seedTestResult;
|
||||
} else {
|
||||
return seedTestResult;
|
||||
}
|
||||
|
||||
const questions = await seedQuestions(manager, savedTest);
|
||||
|
||||
const choices = await seedChoices(manager, questions);
|
||||
|
||||
await seedQuestionRangeRules(manager, questions);
|
||||
|
||||
await seedTransitions(manager, savedTest, questions, choices);
|
||||
|
||||
await seedResultRange(manager, savedTest);
|
||||
|
||||
return {
|
||||
created: true,
|
||||
testId: savedTest.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.created) {
|
||||
console.log(`Test created successfully. ID: ${result.testId}`);
|
||||
} else {
|
||||
console.log(
|
||||
`Test already exists. ID: ${result.testId}. Nothing was created.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
|
||||
console.log('Database connection initialized.');
|
||||
|
||||
await clearDatabase();
|
||||
await seedSuperuser();
|
||||
await prepareTests();
|
||||
|
||||
console.log('Seed completed successfully.');
|
||||
} catch (error) {
|
||||
console.error('Seed failed:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void seed();
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"otp": "Otp"
|
||||
},
|
||||
"errors": {
|
||||
"otpAlreadySent": "OTP already sent. Please try again in 1 minute.",
|
||||
"wrongPhoneOrPass": "Entered phone number or password is wrong.",
|
||||
"unauthorized": "Unauthorized access",
|
||||
"tokenReuse": "Token reuse detected.",
|
||||
@@ -13,6 +14,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"question": "Question",
|
||||
"text": "Text",
|
||||
"value": "Value",
|
||||
"choice": "Choice",
|
||||
"score": "Score",
|
||||
"weight": "Weight",
|
||||
"rangeRule": "Range Rule",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"alreadyTookTest": "You already took this test.",
|
||||
"choiceNumericConstraint": "You must send either choiceId or numericValue.",
|
||||
"choiceNumericConstraint": "You must send either choiceId, choiceIds or numericValue.",
|
||||
"emptyNumericValue": "A numeric value should be provided as answer.",
|
||||
"numericValueRange": "Numeric value is out of specified range.",
|
||||
"emptyChoiceId": "Choice Id should be provided as answer.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"otp": "کد تایید"
|
||||
},
|
||||
"errors": {
|
||||
"otpAlreadySent": "کد تایید قبلا ارسال شده. لطفا دقایقی دیگر مجددا امتحان کنید.",
|
||||
"wrongPhoneOrPass": "شماره موبایل یا رمز عبور اشتباه است.",
|
||||
"unauthorized": "دسترسی غیر مجاز",
|
||||
"tokenReuse": "توکن قبلا استفاده شده.",
|
||||
@@ -13,6 +14,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"otpSent": "کد تایید به شماره {phone} ارسال شد.",
|
||||
"sendPass": "لطفا رمز عبور را ارسال کنید."
|
||||
"sendPass": "لطفا رمز عبور را ارسال کنید.",
|
||||
"loggedOut": "با موفقیت خارج شدید."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversationId": "شناسه مکالمه",
|
||||
"message": "پیام"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversation": "مکالمه"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
"metadata": "تنظیمات",
|
||||
"question": "سوال",
|
||||
"text": "متن",
|
||||
"value": "مقدار",
|
||||
"choice": "گزینه",
|
||||
"score": "نمره",
|
||||
"weight": "وزن",
|
||||
|
||||
+13
-1
@@ -5,12 +5,20 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import helmet from 'helmet';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AdminPanelSetup } from './modules/admin-panel/admin-panel.setup';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
app.setGlobalPrefix('v1');
|
||||
|
||||
// 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(
|
||||
new I18nValidationPipe({
|
||||
whitelist: true,
|
||||
@@ -91,6 +99,10 @@ async function bootstrap() {
|
||||
|
||||
SwaggerModule.setup('v1/docs', app, document);
|
||||
|
||||
await app.init();
|
||||
|
||||
AdminPanelSetup.setup(app);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PrepUserSubmissionHistory1788876455659 implements MigrationInterface {
|
||||
name = 'PrepUserSubmissionHistory1788876455659';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."unq_question_multi_choice"`);
|
||||
await queryRunner.query(`ALTER TABLE "choice" DROP COLUMN "value"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission_answer" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ADD "code" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ADD "completedAt" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" DROP COLUMN "completedAt"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "submission" DROP COLUMN "code"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission_answer" DROP COLUMN "createdAt"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "choice" ADD "value" integer NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "unq_question_multi_choice" ON "question_transition" ("type", "fromQuestionId") WHERE (type = 'multi-choice'::question_transition_type_enum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangeResultRangeFields1788950646073 implements MigrationInterface {
|
||||
name = 'ChangeResultRangeFields1788950646073';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "minScore"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "maxScore"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "minProbability" integer NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "maxProbability" integer NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "maxProbability"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "minProbability"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "maxScore" integer NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "minScore" integer NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CalculateByScore1789906979798 implements MigrationInterface {
|
||||
name = 'CalculateByScore1789906979798';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "minProbability"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "maxProbability"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "minScore" integer NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "maxScore" integer NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "maxScore"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" DROP COLUMN "minScore"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "maxProbability" integer NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "result_range" ADD "minProbability" integer NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Conversation1791194400675 implements MigrationInterface {
|
||||
name = 'Conversation1791194400675';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "conversation" ("id" SERIAL NOT NULL, "title" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, CONSTRAINT "PK_864528ec4274360a40f66c29845" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."message_role_enum" AS ENUM('user', 'assistant', 'doctor')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "message" ("id" SERIAL NOT NULL, "role" "public"."message_role_enum" NOT NULL, "content" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "conversationId" integer, CONSTRAINT "PK_ba01f0a3e0123651915008bc578" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "conversation" ADD CONSTRAINT "FK_c308b1cd542522bb66430fa860a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "message" ADD CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f" FOREIGN KEY ("conversationId") REFERENCES "conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "message" DROP CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "conversation" DROP CONSTRAINT "FK_c308b1cd542522bb66430fa860a"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "message"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."message_role_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "conversation"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SessionData } from 'express-session';
|
||||
import { Store } from 'express-session';
|
||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
||||
|
||||
/**
|
||||
* express-session store backed by the application Redis, so admin sessions
|
||||
* survive restarts and expire server-side together with the cookie.
|
||||
*/
|
||||
export class AdminPanelSessionStore extends Store {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Redis Service
|
||||
*/
|
||||
private readonly redisService: RedisService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private key(sessionId: string) {
|
||||
return `admin:panel:sess:${sessionId}`;
|
||||
}
|
||||
|
||||
private ttlSeconds(session: SessionData) {
|
||||
const maxAgeMs = session.cookie?.maxAge;
|
||||
|
||||
return typeof maxAgeMs === 'number' ? Math.floor(maxAgeMs / 1000) : undefined;
|
||||
}
|
||||
|
||||
override get(
|
||||
sessionId: string,
|
||||
callback: (err: unknown, session?: SessionData | null) => void,
|
||||
) {
|
||||
this.redisService
|
||||
.get<SessionData>(this.key(sessionId))
|
||||
.then((session) => callback(null, session ?? null))
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
override set(
|
||||
sessionId: string,
|
||||
session: SessionData,
|
||||
callback: (err?: unknown) => void,
|
||||
) {
|
||||
this.redisService
|
||||
.set(this.key(sessionId), session, this.ttlSeconds(session))
|
||||
.then(() => callback())
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
override destroy(sessionId: string, callback: (err?: unknown) => void) {
|
||||
this.redisService
|
||||
.del(this.key(sessionId))
|
||||
.then(() => callback())
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
override touch(
|
||||
sessionId: string,
|
||||
session: SessionData,
|
||||
callback: (err?: unknown) => void,
|
||||
) {
|
||||
// Rolling sessions: refresh the server-side TTL on every request
|
||||
this.set(sessionId, session, callback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { HashingModule } from '@/common/modules/hashing/hashing.module';
|
||||
import { RedisModule } from '@/common/modules/redis/redis.module';
|
||||
import { AdminPanelAuthenticator } from './providers/admin-panel-auth.provider';
|
||||
|
||||
/**
|
||||
* Wires the dependencies AdminPanelSetup resolves after bootstrap.
|
||||
* The panel itself is mounted on the express instance (see admin-panel.setup.ts
|
||||
* and main.ts), not through Nest controllers.
|
||||
*/
|
||||
@Module({
|
||||
imports: [UsersModule, HashingModule, RedisModule],
|
||||
providers: [AdminPanelAuthenticator],
|
||||
exports: [AdminPanelAuthenticator],
|
||||
})
|
||||
export class AdminPanelModule {}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { Express, Request, Response, NextFunction } from 'express';
|
||||
import AdminJS from 'adminjs';
|
||||
import AdminJSExpress from '@adminjs/express';
|
||||
import { Database, Resource } from '@adminjs/typeorm';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
||||
import { AdminPanelAuthenticator } from './providers/admin-panel-auth.provider';
|
||||
import { AdminPanelSessionStore } from './admin-panel-session.store';
|
||||
|
||||
/**
|
||||
* AdminJS panel bootstrap.
|
||||
*
|
||||
* The router must be registered on the express instance BEFORE NestJS body
|
||||
* parsers (they would otherwise consume request bodies and trip AdminJS's
|
||||
* OldBodyParserUsedError guard). @adminjs/nestjs reorders express layers for
|
||||
* this, but its reorder relies on app._router which no longer exists in
|
||||
* Express 5, so we mount a lazy placeholder early and swap in the real
|
||||
* AdminJS router once the DI container is initialized.
|
||||
*/
|
||||
export class AdminPanelSetup {
|
||||
private static handler: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => void;
|
||||
|
||||
static mount(app: INestApplication): void {
|
||||
if (process.env.ADMIN_PANEL_ENABLED === 'false') {
|
||||
return;
|
||||
}
|
||||
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Express;
|
||||
const rootPath = process.env.ADMIN_ROOT_PATH || '/admin';
|
||||
|
||||
// Placeholder stands in until setup() replaces it with the real router
|
||||
expressApp.use(rootPath, (req, res, next) => {
|
||||
if (AdminPanelSetup.handler) {
|
||||
return AdminPanelSetup.handler(req, res, next);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
static setup(app: INestApplication): void {
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
if (!configService.get<boolean>('admin.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cookiePassword = configService.get<string>('admin.cookiePassword');
|
||||
|
||||
if (!cookiePassword || cookiePassword.length < 32) {
|
||||
throw new Error(
|
||||
'ADMIN_COOKIE_PASSWORD must be set to a random string of at least 32 characters when the admin panel is enabled',
|
||||
);
|
||||
}
|
||||
|
||||
AdminJS.registerAdapter({ Database, Resource });
|
||||
|
||||
// Entities extend ActiveRecord's BaseEntity, which resolves its repository
|
||||
// through this static DataSource reference (the adapter relies on it)
|
||||
const dataSource = app.get(DataSource);
|
||||
|
||||
for (const entity of dataSource.entityMetadatas.map((m) => m.target)) {
|
||||
const baseEntity = entity as { useDataSource?: (ds: DataSource) => void };
|
||||
baseEntity.useDataSource?.(dataSource);
|
||||
}
|
||||
|
||||
// Register every real entity automatically (new entities show up without
|
||||
// any change here). Auto-generated ManyToMany junction metadatas have no
|
||||
// entity class behind them and are skipped — they are managed through the
|
||||
// owning relation instead.
|
||||
const autoResources = dataSource.entityMetadatas
|
||||
.map((m) => m.target)
|
||||
.filter(
|
||||
(target) =>
|
||||
typeof (target as { getRepository?: unknown }).getRepository ===
|
||||
'function',
|
||||
);
|
||||
|
||||
const adminJs = new AdminJS({
|
||||
rootPath: configService.get<string>('admin.rootPath'),
|
||||
branding: {
|
||||
companyName: 'Heala Admin',
|
||||
withMadeWithLove: false,
|
||||
},
|
||||
resources: [
|
||||
...autoResources,
|
||||
{
|
||||
resource: User,
|
||||
options: {
|
||||
properties: {
|
||||
// Never expose password hashes in tables and detail views
|
||||
password: {
|
||||
isVisible: {
|
||||
list: false,
|
||||
filter: false,
|
||||
show: false,
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const authenticator = app.get(AdminPanelAuthenticator);
|
||||
const redisService = app.get(RedisService);
|
||||
|
||||
AdminPanelSetup.handler = AdminJSExpress.buildAuthenticatedRouter(
|
||||
adminJs,
|
||||
{
|
||||
authenticate: (phone: string, password: string) =>
|
||||
authenticator.authenticate(phone, password),
|
||||
cookieName: configService.get<string>('admin.cookieName'),
|
||||
cookiePassword,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
// AdminJS overrides `secret` and `name` from the auth options; both
|
||||
// are repeated here only to satisfy the newer express-session typings
|
||||
secret: cookiePassword,
|
||||
store: new AdminPanelSessionStore(redisService),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: configService.get<boolean>('admin.secureCookies'),
|
||||
maxAge:
|
||||
(configService.get<number>('admin.sessionTtlMinutes') ?? 120) *
|
||||
60_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { CurrentAdmin } from 'adminjs';
|
||||
import { Role } from '@/common/enums/roles.enum';
|
||||
import { UsersService } from '@/modules/users/providers/users.service';
|
||||
import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider';
|
||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
||||
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const ATTEMPT_WINDOW_SECONDS = 15 * 60;
|
||||
|
||||
/**
|
||||
* Hash of a random throwaway string. Comparing against it when the phone
|
||||
* number is unknown keeps the response time identical to the success path,
|
||||
* so attackers cannot enumerate superuser phone numbers via timing.
|
||||
*/
|
||||
const DUMMY_BCRYPT_HASH =
|
||||
'$2b$10$ZFUmcSvMhrye2p.Dt1J7rOcfEdi.NcDLk3Bf55taT3ed57cAcpiNS';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPanelAuthenticator {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Users Service
|
||||
*/
|
||||
private readonly usersService: UsersService,
|
||||
|
||||
/**
|
||||
* Inject Hashing Provider
|
||||
*/
|
||||
private readonly hashingProvider: HashingProvider,
|
||||
|
||||
/**
|
||||
* Inject Redis Service
|
||||
*/
|
||||
private readonly redisService: RedisService,
|
||||
) {}
|
||||
|
||||
public async authenticate(
|
||||
phone: string,
|
||||
password: string,
|
||||
): Promise<CurrentAdmin | null> {
|
||||
const normalizedPhone = phone?.trim();
|
||||
|
||||
if (!normalizedPhone || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attemptsKey = `admin:panel:login:${normalizedPhone}`;
|
||||
const failedAttempts =
|
||||
(await this.redisService.get<number>(attemptsKey)) ?? 0;
|
||||
|
||||
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.usersService.findOneByPhone(normalizedPhone);
|
||||
const isSuperuser = !!user && user.role === Role.SUPERUSER;
|
||||
|
||||
const passwordMatches = await this.hashingProvider.compare(
|
||||
password,
|
||||
isSuperuser && user.password ? user.password : DUMMY_BCRYPT_HASH,
|
||||
);
|
||||
|
||||
if (!isSuperuser || !user.password || !passwordMatches) {
|
||||
await this.redisService.set(
|
||||
attemptsKey,
|
||||
failedAttempts + 1,
|
||||
ATTEMPT_WINDOW_SECONDS,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.redisService.del(attemptsKey);
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
phone: user.phone,
|
||||
role: user.role,
|
||||
// AdminJS requires an email on CurrentAdmin for the UI
|
||||
email: user.phone,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"question": 6,
|
||||
"text": "Option 2",
|
||||
"value": 1,
|
||||
"order": 2,
|
||||
"score": 8
|
||||
"question": 13,
|
||||
"text": "Option 4",
|
||||
"order": 4,
|
||||
"score": 10
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
DELETE {{baseUrl}}/admin/question-transitions/26
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
PATCH {{baseUrl}}/admin/question-transitions/26
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"toQuestion": 13
|
||||
}
|
||||
+6
-5
@@ -3,10 +3,11 @@ Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"test": 1,
|
||||
"test": 2,
|
||||
"type": "end",
|
||||
"fromQuestion": 7
|
||||
// "toQuestion": 7
|
||||
// "conditionValue": {"min": 51, "max": 100}
|
||||
// "conditionValue": {"choiceId": 5}
|
||||
"fromQuestion": 13
|
||||
// "toQuestion": 13
|
||||
// "conditionValue": {"min": 11, "max": 20}
|
||||
// "conditionValue": {"choiceId": 16 }
|
||||
// "conditionValue": {"choiceIds": [13, 14, 15], "operator": "all"}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"test": 1,
|
||||
"content": "سوال هفتم",
|
||||
"test": 2,
|
||||
"content": "Q6",
|
||||
"required": true,
|
||||
"type": "numeric"
|
||||
// "metadata": { "min": -10, "max": 10 }
|
||||
"type": "multi-choice"
|
||||
// "metadata": { "min": 0, "max": 100 }
|
||||
}
|
||||
|
||||
POST {{baseUrl}}/admin/questions/range-rules
|
||||
@@ -15,8 +15,8 @@ Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"question": 5,
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"score": 8
|
||||
"question": 11,
|
||||
"min": 51,
|
||||
"max": 100,
|
||||
"score": 5
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
GET {{baseUrl}}/admin/tests?title=test&isActive=true&accessType=paid&sortBy=title&order=DSC&page=2&limit=1
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
GET {{baseUrl}}/admin/tests/4
|
||||
GET {{baseUrl}}/admin/tests/1
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
PATCH {{baseUrl}}/admin/tests/4
|
||||
PATCH {{baseUrl}}/admin/tests/2
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"accessType": "login",
|
||||
"isActive": false
|
||||
"accessType": "free"
|
||||
// "isActive": false
|
||||
// "status": "published"
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ Content-Type: application/json
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"title": "Test One",
|
||||
"description": "Description One",
|
||||
"title": "Test Two",
|
||||
"description": "Description Two",
|
||||
"accessType": "login",
|
||||
"isActive": true
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import { VerifyOtpDTO } from './dtos/verify-otp.dto';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { RefreshTokenDTO } from './dtos/refresh-token.dto';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApiAppResponse } from '@/common/decorators/api-app-response.decorator';
|
||||
import { NewUserLoginResponseDTO } from './dtos/responses/new-user-login.dto';
|
||||
import { RequirePasswordDTO } from './dtos/responses/require-password.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')
|
||||
export class AuthController {
|
||||
@@ -19,6 +27,58 @@ export class AuthController {
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ login: {} })
|
||||
@ApiAppResponse([
|
||||
{
|
||||
status: HttpStatus.OK,
|
||||
variants: [
|
||||
{
|
||||
model: TokensResponseDTO,
|
||||
example: {
|
||||
data: {
|
||||
access: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
refresh: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: NewUserLoginResponseDTO,
|
||||
example: {
|
||||
data: {
|
||||
newUser: true,
|
||||
otpExpiresAt: '2026-09-01T09:24:34.894Z',
|
||||
},
|
||||
message: 'کد تایید به شماره 09121111111 فرستاده شد.',
|
||||
},
|
||||
},
|
||||
{
|
||||
model: RequirePasswordDTO,
|
||||
example: {
|
||||
data: {
|
||||
newUser: false,
|
||||
},
|
||||
message: 'لطفا رمز عبور را ارسال کنید.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
variants: [
|
||||
{
|
||||
messageExample: 'شماره موبایل یا رمز عبور اشتباه است.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: HttpStatus.TOO_MANY_REQUESTS,
|
||||
variants: [
|
||||
{
|
||||
messageExample:
|
||||
'تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از 1 دقیقه دوباره امتحان کنید.',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
public async logIn(@Body() loginDto: LoginDTO) {
|
||||
return await this.authService.logIn(loginDto);
|
||||
}
|
||||
@@ -27,6 +87,24 @@ export class AuthController {
|
||||
@Post('verify-otp')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ otp: {} })
|
||||
@ApiAppResponse([
|
||||
{
|
||||
status: HttpStatus.OK,
|
||||
variants: [
|
||||
{
|
||||
model: TokensResponseDTO,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
variants: [
|
||||
{
|
||||
messageExample: 'کد تایید اشتباه است یا منقضی شده.',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
public async verifyOTP(@Body() verifyOtpDto: VerifyOtpDTO) {
|
||||
return await this.authService.verifyOTP(verifyOtpDto);
|
||||
}
|
||||
@@ -35,7 +113,49 @@ export class AuthController {
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ login: {} })
|
||||
@ApiAppResponse([
|
||||
{
|
||||
status: HttpStatus.OK,
|
||||
variants: [
|
||||
{
|
||||
model: TokensResponseDTO,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
variants: [{ messageExample: 'توکن قبلا استفاده شده.' }],
|
||||
},
|
||||
])
|
||||
public async refreshToken(@Body() refreshTokenDto: 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 { RedisModule } from '@/common/modules/redis/redis.module';
|
||||
import { AuthRedisProvider } from './providers/auth-redis.provider';
|
||||
import { LogoutProvider } from './providers/logout.provider';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -43,6 +44,7 @@ import { AuthRedisProvider } from './providers/auth-redis.provider';
|
||||
JwtStrategy,
|
||||
RefreshTokensProvider,
|
||||
AuthRedisProvider,
|
||||
LogoutProvider,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
description: "User's Refresh Token",
|
||||
required: true,
|
||||
})
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class NewUserLoginResponseDTO {
|
||||
@ApiProperty({
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
})
|
||||
newUser!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
example: '2026-09-01T09:24:34.894Z',
|
||||
})
|
||||
otpExpiresAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RequirePasswordDTO {
|
||||
@ApiProperty({
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
})
|
||||
newUser!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TokensResponseDTO {
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
})
|
||||
access!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
})
|
||||
refresh!: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PhoneDTO } from './login.dto';
|
||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class VerifyOtpDTO extends PhoneDTO {
|
||||
@IsString()
|
||||
@@ -9,5 +10,11 @@ export class VerifyOtpDTO extends PhoneDTO {
|
||||
field: '$t(auth.fields.otp)',
|
||||
}),
|
||||
})
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
description: '5-digit OTP code',
|
||||
example: '12345',
|
||||
required: true,
|
||||
})
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
POST {{baseUrl}}/auth/logout/
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"refreshToken": "{{$global.refreshToken}}"
|
||||
}
|
||||
@@ -3,7 +3,7 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"phone": "09333026363",
|
||||
"otp": "55486"
|
||||
"otp": "23122"
|
||||
}
|
||||
|
||||
{{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface AccessTokenPayload {
|
||||
sub: number;
|
||||
phone: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
|
||||
@@ -21,4 +21,14 @@ export class AuthRedisProvider {
|
||||
async revokeRefreshToken(jti: string) {
|
||||
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 { RefreshTokenDTO } from '../dtos/refresh-token.dto';
|
||||
import { RefreshTokensProvider } from './refresh-tokens.provider';
|
||||
import { LogoutDTO } from '../dtos/logout.dto';
|
||||
import { LogoutProvider } from './logout.provider';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -23,6 +25,11 @@ export class AuthService {
|
||||
* Inject RefreshToken Provider
|
||||
*/
|
||||
private readonly refreshTokensProvider: RefreshTokensProvider,
|
||||
|
||||
/**
|
||||
* Inject Logout Provider
|
||||
*/
|
||||
private readonly logoutProvider: LogoutProvider,
|
||||
) {}
|
||||
|
||||
public async logIn(loginDto: LoginDTO) {
|
||||
@@ -36,4 +43,12 @@ export class AuthService {
|
||||
public async refreshToken(refreshTokenDto: 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 type { ConfigType } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
RefreshTokenPayload,
|
||||
@@ -51,13 +52,15 @@ export class GenerateTokenProvider {
|
||||
}
|
||||
|
||||
public async generateTokens(user: User, jti: string) {
|
||||
const accessJti = randomUUID();
|
||||
|
||||
const [access, refresh] = await Promise.all([
|
||||
// Sign Access Token
|
||||
this.signToken<Partial<AccessTokenPayload>>(
|
||||
user.id,
|
||||
this.jwtConfiguration.access.expiresIn,
|
||||
this.jwtConfiguration.access.secret,
|
||||
{ phone: user.phone },
|
||||
{ phone: user.phone, jti: accessJti },
|
||||
),
|
||||
|
||||
// Sign Refresh Token
|
||||
|
||||
@@ -50,8 +50,8 @@ export class LoginProvider {
|
||||
});
|
||||
|
||||
return {
|
||||
newUser: true,
|
||||
message,
|
||||
newUser: true,
|
||||
otpExpiresAt: otpCode.expiresAt,
|
||||
// Just for development
|
||||
code: otpCode.code,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import jwtConfig from '@/config/jwt.config';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
RefreshTokenPayload,
|
||||
} from '../interfaces/jwt.interface';
|
||||
import { AuthRedisProvider } from './auth-redis.provider';
|
||||
import { LogoutDTO } from '../dtos/logout.dto';
|
||||
import { AppResponse } from '@/common/responses';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class LogoutProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject JwtService
|
||||
*/
|
||||
private readonly jwtService: JwtService,
|
||||
|
||||
/**
|
||||
* Inject jwtConfig
|
||||
*/
|
||||
@Inject(jwtConfig.KEY)
|
||||
private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
|
||||
|
||||
/**
|
||||
* Inject AuthRedis Provider
|
||||
*/
|
||||
private readonly authRedisProvider: AuthRedisProvider,
|
||||
|
||||
/**
|
||||
* Inject I18n Service
|
||||
*/
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
public async logout(
|
||||
userId: number,
|
||||
logoutDto: LogoutDTO,
|
||||
accessToken: string,
|
||||
) {
|
||||
if (!accessToken) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
const refreshPayload =
|
||||
await this.jwtService.verifyAsync<RefreshTokenPayload>(
|
||||
logoutDto.refreshToken,
|
||||
{
|
||||
secret: this.jwtConfiguration.refresh.secret,
|
||||
issuer: this.jwtConfiguration.issuer,
|
||||
audience: this.jwtConfiguration.audience,
|
||||
},
|
||||
);
|
||||
|
||||
if (refreshPayload.sub !== userId || !refreshPayload.jti) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
// The guard already validated this token; verify again to be safe
|
||||
const accessPayload = await this.jwtService.verifyAsync<AccessTokenPayload>(
|
||||
accessToken,
|
||||
{
|
||||
secret: this.jwtConfiguration.access.secret,
|
||||
issuer: this.jwtConfiguration.issuer,
|
||||
audience: this.jwtConfiguration.audience,
|
||||
},
|
||||
);
|
||||
|
||||
if (accessPayload.sub !== userId || !accessPayload.jti) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
// Only blacklist until the access token would have expired anyway
|
||||
const ttl = accessPayload.exp - Math.floor(Date.now() / 1000);
|
||||
|
||||
await Promise.all([
|
||||
this.authRedisProvider.revokeRefreshToken(refreshPayload.jti),
|
||||
this.authRedisProvider.blacklistAccessToken(accessPayload.jti, ttl),
|
||||
]);
|
||||
|
||||
const message = this.i18nService.translate('auth.messages.loggedOut');
|
||||
|
||||
return new AppResponse(null, message);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AccessTokenPayload } from '../interfaces/jwt.interface';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { UsersService } from '@/modules/users/providers/users.service';
|
||||
import { AuthRedisProvider } from '../providers/auth-redis.provider';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -20,6 +21,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
* Inject Users Service
|
||||
*/
|
||||
private readonly usersService: UsersService,
|
||||
|
||||
/**
|
||||
* Inject AuthRedis Provider
|
||||
*/
|
||||
private readonly authRedisProvider: AuthRedisProvider,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -31,6 +37,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
@Index(['phone', 'used'])
|
||||
export class OTP {
|
||||
export class OTP extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -27,14 +27,6 @@ export class CreateChoiceDTO {
|
||||
})
|
||||
text!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty({
|
||||
message: t('validation.requiredField', {
|
||||
field: '$t(questions.fields.value)',
|
||||
}),
|
||||
})
|
||||
value!: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty({
|
||||
message: t('validation.requiredField', {
|
||||
|
||||
@@ -16,10 +16,6 @@ export class FindChoicesDTO extends PartialType(PaginationQueryDTO) {
|
||||
@IsOptional()
|
||||
text?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
value?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
choiceOrder?: number;
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { Question } from './question.entity';
|
||||
import { UNIQUE_QUESTION_ORDER_IDX } from '../constants';
|
||||
|
||||
@Entity()
|
||||
@Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true })
|
||||
export class Choice {
|
||||
export class Choice extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@@ -26,9 +27,6 @@ export class Choice {
|
||||
})
|
||||
text!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: false })
|
||||
value!: number;
|
||||
|
||||
@Column({ type: 'int', nullable: false })
|
||||
order!: number;
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Question } from './question.entity';
|
||||
|
||||
@Entity()
|
||||
export class QuestionRangeRule {
|
||||
export class QuestionRangeRule extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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 { QuestionTransitionType } from '../enums/question-transition-type.enum';
|
||||
import type { TransitionConditionType } from '../types/transition-condition.type';
|
||||
|
||||
@Entity()
|
||||
export class QuestionTransition {
|
||||
export class QuestionTransition extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { QuestionType } from '../enums/question-type.enum';
|
||||
import type { QuestionMetadata } from '../types/question-metadata.type';
|
||||
@@ -17,7 +18,7 @@ import { QuestionTransition } from './question-transition.entity';
|
||||
|
||||
@Entity()
|
||||
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
||||
export class Question {
|
||||
export class Question extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -3,6 +3,5 @@ export enum ChoiceSortField {
|
||||
QUESTION = 'question',
|
||||
TEXT = 'text',
|
||||
ORDER = 'order',
|
||||
VALUE = 'value',
|
||||
SCORE = 'score',
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ export const filterChoices = (query: FindChoicesDTO) => {
|
||||
where.order = query.choiceOrder;
|
||||
}
|
||||
|
||||
if (query.value !== undefined) {
|
||||
where.value = query.value;
|
||||
}
|
||||
|
||||
if (query.score !== undefined) {
|
||||
where.score = query.score;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Test } from '@/modules/tests/entities/test.entity';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class ResultRange {
|
||||
export class ResultRange extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { IsArray, IsInt, IsNumber, IsOptional } from 'class-validator';
|
||||
import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class AnswerSubmissionDTO {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
choiceId?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsInt({ each: true })
|
||||
@ApiPropertyOptional({ isArray: true, type: 'number' })
|
||||
choiceIds?: number[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
numericValue?: number;
|
||||
|
||||
@SubmissionAnswer()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
|
||||
import { GetSubmissionResultDTO } from './get-submission-result.dto';
|
||||
|
||||
export class GetAnswerSubmissionDTO {
|
||||
@ApiProperty({
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
})
|
||||
isCompleted!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetSubmissionQuestionDTO,
|
||||
nullable: true,
|
||||
})
|
||||
currentQuestion!: GetSubmissionQuestionDTO;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetSubmissionResultDTO,
|
||||
nullable: true,
|
||||
})
|
||||
result!: GetSubmissionResultDTO;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GetChoiceDTO {
|
||||
@ApiProperty()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
order!: number;
|
||||
|
||||
@ApiProperty()
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
|
||||
import { GetMessageAnswerDTO } from './get-message-answer.dto';
|
||||
|
||||
export class GetHistoryMessageDTO {
|
||||
@ApiProperty({
|
||||
type: GetSubmissionQuestionDTO,
|
||||
})
|
||||
question!: GetSubmissionQuestionDTO;
|
||||
|
||||
@ApiProperty({
|
||||
type: [GetMessageAnswerDTO],
|
||||
})
|
||||
answers!: Array<GetMessageAnswerDTO>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { GetChoiceDTO } from './get-choice.dto';
|
||||
|
||||
export class GetMessageAnswerDTO {
|
||||
@ApiPropertyOptional()
|
||||
choiceId!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: GetChoiceDTO })
|
||||
choice?: GetChoiceDTO;
|
||||
|
||||
@ApiPropertyOptional({ isArray: true, type: 'number' })
|
||||
choiceIds!: Array<number>;
|
||||
|
||||
@ApiPropertyOptional({ isArray: true, type: GetChoiceDTO })
|
||||
choices?: Array<GetChoiceDTO>;
|
||||
|
||||
@ApiPropertyOptional({ type: 'number' })
|
||||
numericValue?: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { SubmissionStatus } from '../../enums/submission-status.enum';
|
||||
import { GetSubmissionResultDTO } from './get-submission-result.dto';
|
||||
import { GetTestByIdDTO } from '@/modules/tests/dtos/responses/get-test-by-id.dto';
|
||||
|
||||
export class GetSubmissionDetailDTO {
|
||||
@ApiProperty()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: GetTestByIdDTO })
|
||||
test!: GetTestByIdDTO;
|
||||
|
||||
@ApiProperty({ enum: SubmissionStatus, enumName: 'status' })
|
||||
status!: SubmissionStatus;
|
||||
|
||||
@ApiPropertyOptional({ type: GetSubmissionResultDTO })
|
||||
result?: GetSubmissionResultDTO;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: Date;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
completedAt?: Date;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
|
||||
import { GetHistoryMessageDTO } from './get-history-message.dto';
|
||||
|
||||
export class GetSubmissionHistoryDTO {
|
||||
@ApiProperty({
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
})
|
||||
isCompleted!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
isArray: true,
|
||||
type: GetHistoryMessageDTO,
|
||||
})
|
||||
messages!: Array<GetHistoryMessageDTO>;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetSubmissionQuestionDTO,
|
||||
nullable: true,
|
||||
})
|
||||
currentQuestion!: GetSubmissionQuestionDTO;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { type QuestionMetadata } from '@/modules/questions/types/question-metadata.type';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { GetChoiceDTO } from './get-choice.dto';
|
||||
|
||||
export class GetSubmissionQuestionDTO {
|
||||
@ApiProperty({
|
||||
type: 'number',
|
||||
example: 1,
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({
|
||||
enum: QuestionType,
|
||||
enumName: 'questionType',
|
||||
})
|
||||
type!: QuestionType;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
})
|
||||
content!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'boolean',
|
||||
})
|
||||
required!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: [GetChoiceDTO],
|
||||
})
|
||||
choices!: Array<GetChoiceDTO>;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
min: {
|
||||
type: 'number',
|
||||
},
|
||||
max: {
|
||||
type: 'number',
|
||||
},
|
||||
step: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['min', 'max'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
allowOther: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['allowOther'],
|
||||
},
|
||||
],
|
||||
})
|
||||
metadata!: QuestionMetadata;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GetSubmissionResultDTO {
|
||||
@ApiProperty()
|
||||
score!: number;
|
||||
|
||||
@ApiProperty()
|
||||
probability!: number;
|
||||
|
||||
@ApiProperty()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
description!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
|
||||
|
||||
export class StartAuthenticatedDTO {
|
||||
@ApiProperty({ type: 'number', required: true })
|
||||
submissionId!: number;
|
||||
|
||||
@ApiProperty({ type: GetSubmissionQuestionDTO })
|
||||
question!: GetSubmissionQuestionDTO;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { GetSubmissionQuestionDTO } from './get-submission-question.dto';
|
||||
|
||||
export class StartGuestDTO {
|
||||
@ApiProperty({ type: 'string', required: true })
|
||||
guestSubmissionId!: string;
|
||||
|
||||
@ApiProperty({ type: GetSubmissionQuestionDTO })
|
||||
question!: GetSubmissionQuestionDTO;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber } from 'class-validator';
|
||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||
|
||||
@@ -8,5 +9,9 @@ export class StartSubmissionDTO {
|
||||
field: '$t(submissions.fields.testId)',
|
||||
}),
|
||||
})
|
||||
@ApiProperty({
|
||||
type: 'number',
|
||||
example: 1,
|
||||
})
|
||||
testId!: number;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user