Compare commits

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

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