Compare commits
35
Commits
deaa479c80
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace4342870 | ||
|
|
195eca1015 | ||
|
|
f3fe1f55e4 | ||
|
|
5c4e9df341 | ||
|
|
9842f4db31 | ||
|
|
13ba11f951 | ||
|
|
76f6c97a9e | ||
|
|
17b47ffd91 | ||
|
|
fbce16ef60 | ||
|
|
f660a6b7a7 | ||
|
|
7658ba805d | ||
|
|
bc333b8289 | ||
|
|
f3c86da09b | ||
|
|
f0bf186b6c | ||
|
|
f90b802419 | ||
|
|
287091724d | ||
|
|
0961b8f11d | ||
|
|
8fced148b7 | ||
|
|
d1324eb960 | ||
|
|
9ecc9647b5 | ||
|
|
dd9a31278e | ||
|
|
7dd9206ca6 | ||
|
|
f75e33a7dc | ||
|
|
73e8fc71bb | ||
|
|
195ace097c | ||
|
|
55ddc7f7fe | ||
|
|
2e146e52b5 | ||
|
|
ca88e6994e | ||
|
|
ca8d02c531 | ||
|
|
f6a57454f4 | ||
|
|
c368948c4b | ||
|
|
0c82d3426b | ||
|
|
b4343bdb53 | ||
|
|
9742616f28 | ||
|
|
0f9543aaf2 |
@@ -28,6 +28,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:8-alpine
|
image: redis:8-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
|||||||
Generated
+6135
-1007
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -25,9 +25,14 @@
|
|||||||
"migration:run:dev": "npm run typeorm:dev -- migration:run",
|
"migration:run: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
@@ -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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +46,8 @@ 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,
|
||||||
@@ -52,4 +60,28 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logException(request: Request, status: number, exception?: any) {
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
{
|
||||||
|
err: exception,
|
||||||
|
stack: exception instanceof Error ? exception.stack : undefined,
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
statusCode: status,
|
||||||
|
},
|
||||||
|
'Unhandled server exception',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
statusCode: status,
|
||||||
|
},
|
||||||
|
'HTTP exception',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
LoggerModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
pinoHttp: {
|
||||||
|
level: configService.get<string>('app.logLevel') ?? 'info',
|
||||||
|
genReqId: (req) => {
|
||||||
|
const requestId = req.headers['x-request-id'];
|
||||||
|
|
||||||
|
if (typeof requestId === 'string' && requestId.length > 0) {
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.randomUUID();
|
||||||
|
},
|
||||||
|
redact: {
|
||||||
|
paths: [
|
||||||
|
'req.headers.authorization',
|
||||||
|
'req.headers.cookie',
|
||||||
|
'req.headers["set-cookie"]',
|
||||||
|
'password',
|
||||||
|
'accessToken',
|
||||||
|
'refreshToken',
|
||||||
|
'otp',
|
||||||
|
],
|
||||||
|
censor: '[REDACTED]',
|
||||||
|
},
|
||||||
|
|
||||||
|
// serializers: {
|
||||||
|
// req: (req) => ({
|
||||||
|
// method: req.method,
|
||||||
|
// url: req.url,
|
||||||
|
// remoteAddress: req.remoteAddress,
|
||||||
|
// }),
|
||||||
|
|
||||||
|
// res: (res) => ({
|
||||||
|
// statusCode: res.statusCode,
|
||||||
|
// }),
|
||||||
|
// },
|
||||||
|
|
||||||
|
customProps: (req) => ({
|
||||||
|
requestId: req.id,
|
||||||
|
}),
|
||||||
|
|
||||||
|
customLogLevel: (req, res, error) => {
|
||||||
|
if (error || res.statusCode >= 500) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
|
||||||
|
transport:
|
||||||
|
configService.get<string>('app.environment') !== 'production'
|
||||||
|
? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
singleLine: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
exports: [LoggerModule],
|
||||||
|
})
|
||||||
|
export class LoggingModule {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REDIS } from '../constants/redis.constants';
|
import { REDIS } from '../constants/redis.constants';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisService {
|
export class RedisService {
|
||||||
@@ -38,6 +39,22 @@ export class RedisService {
|
|||||||
return await this.redis.del(key);
|
return await this.redis.del(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async acquireLock(key: string, ttlSeconds = 5): Promise<string | null> {
|
||||||
|
const token = randomUUID();
|
||||||
|
|
||||||
|
const result = await this.redis.set(key, token, 'EX', ttlSeconds, 'NX');
|
||||||
|
|
||||||
|
return result === 'OK' ? token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseLock(key: string, token: string): Promise<void> {
|
||||||
|
const value = await this.redis.get(key);
|
||||||
|
|
||||||
|
if (value === token) {
|
||||||
|
await this.redis.del(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
await this.redis.quit();
|
await this.redis.quit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('admin', () => ({
|
||||||
|
enabled: process.env.ADMIN_PANEL_ENABLED !== 'false',
|
||||||
|
rootPath: process.env.ADMIN_ROOT_PATH || '/admin',
|
||||||
|
cookiePassword: process.env.ADMIN_COOKIE_PASSWORD,
|
||||||
|
cookieName: 'heala.admin.sid',
|
||||||
|
sessionTtlMinutes: parseInt(process.env.ADMIN_SESSION_TTL_MINUTES || '120'),
|
||||||
|
secureCookies: process.env.NODE_ENV === 'production',
|
||||||
|
}));
|
||||||
@@ -9,4 +9,5 @@ export default registerAs('app', () => ({
|
|||||||
swaggerServers: process.env.SWAGGER_SERVERS?.split(',').map((origin) =>
|
swaggerServers: process.env.SWAGGER_SERVERS?.split(',').map((origin) =>
|
||||||
origin.trim(),
|
origin.trim(),
|
||||||
),
|
),
|
||||||
|
logLevel: process.env.LOG_LEVEL,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('openRouter', () => ({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY,
|
||||||
|
model: process.env.OPENROUTER_MODEL,
|
||||||
|
}));
|
||||||
@@ -3,4 +3,5 @@ import { registerAs } from '@nestjs/config';
|
|||||||
export default registerAs('redis', () => ({
|
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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { Choice } from '@/modules/questions/entities/choice.entity';
|
||||||
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export async function seedChoices(
|
||||||
|
manager: EntityManager,
|
||||||
|
questions: Record<string, Question>,
|
||||||
|
) {
|
||||||
|
const choiceRepository = manager.getRepository(Choice);
|
||||||
|
const { q1, q2, q3, q4, q5, q6, q7, q8, q10, q12 } = questions;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 1 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q1Yes = choiceRepository.create({
|
||||||
|
question: q1,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q1No = choiceRepository.create({
|
||||||
|
question: q1,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q1Choices = [q1Yes, q1No];
|
||||||
|
await choiceRepository.save(q1Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 2 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q2Yes = choiceRepository.create({
|
||||||
|
question: q2,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q2No = choiceRepository.create({
|
||||||
|
question: q2,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q2NotSure = choiceRepository.create({
|
||||||
|
question: q2,
|
||||||
|
text: 'مطمئن نیستم',
|
||||||
|
order: 3,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q2Choices = [q2Yes, q2No, q2NotSure];
|
||||||
|
await choiceRepository.save(q2Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 3 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q3Texts = [
|
||||||
|
'اچآیوی (HIV)',
|
||||||
|
'سیفلیس',
|
||||||
|
'سوزاک',
|
||||||
|
'عفونت غیراختصاصی مجرای ادرار',
|
||||||
|
'هپاتیت B',
|
||||||
|
'هپاتیت C',
|
||||||
|
'کلامیدیا',
|
||||||
|
'زگیل تناسلی',
|
||||||
|
// 'سایر',
|
||||||
|
// 'مطمئن نیستم',
|
||||||
|
// 'هیچ کدام',
|
||||||
|
];
|
||||||
|
|
||||||
|
const q3Choices = q3Texts.map((text, index) =>
|
||||||
|
choiceRepository.create({
|
||||||
|
question: q3,
|
||||||
|
text,
|
||||||
|
order: index + 1,
|
||||||
|
score: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await choiceRepository.save(q3Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 4 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q4Texts = [
|
||||||
|
'سیفلیس',
|
||||||
|
'سوزاک',
|
||||||
|
'عفونت غیراختصاصی مجرای ادرار',
|
||||||
|
'هپاتیت B',
|
||||||
|
'هپاتیت C',
|
||||||
|
'کلامیدیا',
|
||||||
|
'زگیل تناسلی',
|
||||||
|
// 'سایر',
|
||||||
|
// 'مطمئن نیستم',
|
||||||
|
// 'هیچ کدام',
|
||||||
|
];
|
||||||
|
|
||||||
|
const q4Choices = q4Texts.map((text, index) =>
|
||||||
|
choiceRepository.create({
|
||||||
|
question: q4,
|
||||||
|
text,
|
||||||
|
order: index + 1,
|
||||||
|
score: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await choiceRepository.save(q4Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 5 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q5Yes = choiceRepository.create({
|
||||||
|
question: q5,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q5No = choiceRepository.create({
|
||||||
|
question: q5,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q5NotSure = choiceRepository.create({
|
||||||
|
question: q5,
|
||||||
|
text: 'مطمئن نیستم',
|
||||||
|
order: 3,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q5Choices = [q5Yes, q5No, q5NotSure];
|
||||||
|
await choiceRepository.save(q5Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 6 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q6Yes = choiceRepository.create({
|
||||||
|
question: q6,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q6No = choiceRepository.create({
|
||||||
|
question: q6,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q6NotSure = choiceRepository.create({
|
||||||
|
question: q6,
|
||||||
|
text: 'مطمئن نیستم',
|
||||||
|
order: 3,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q6Choices = [q6Yes, q6No, q6NotSure];
|
||||||
|
await choiceRepository.save(q6Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 7 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q7Yes = choiceRepository.create({
|
||||||
|
question: q7,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q7No = choiceRepository.create({
|
||||||
|
question: q7,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q7Choices = [q7Yes, q7No];
|
||||||
|
await choiceRepository.save(q7Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 8 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q8Yes = choiceRepository.create({
|
||||||
|
question: q8,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q8No = choiceRepository.create({
|
||||||
|
question: q8,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q8Choices = [q8Yes, q8No];
|
||||||
|
await choiceRepository.save(q8Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 10 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q10Yes = choiceRepository.create({
|
||||||
|
question: q10,
|
||||||
|
text: 'بله',
|
||||||
|
order: 1,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q10No = choiceRepository.create({
|
||||||
|
question: q10,
|
||||||
|
text: 'خیر',
|
||||||
|
order: 2,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q10Choices = [q10Yes, q10No];
|
||||||
|
await choiceRepository.save(q10Choices);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 12 CHOICES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q12Always = choiceRepository.create({
|
||||||
|
question: q12,
|
||||||
|
text: 'همیشه با همهٔ شرکای جنسیام از کاندوم استفاده کردهام.',
|
||||||
|
order: 1,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q12Sometimes = choiceRepository.create({
|
||||||
|
question: q12,
|
||||||
|
text: 'گاهی با شرکای جنسیام از کاندوم استفاده کردهام.',
|
||||||
|
order: 2,
|
||||||
|
score: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q12Never = choiceRepository.create({
|
||||||
|
question: q12,
|
||||||
|
text: 'هیچوقت با هیچکدام از شرکای جنسیام از کاندوم استفاده نکردهام.',
|
||||||
|
order: 3,
|
||||||
|
score: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q12NotApplicable = choiceRepository.create({
|
||||||
|
question: q12,
|
||||||
|
text: 'سؤال در مورد من صحت ندارد؛ چون که رابطهٔ واژنی یا مقعدی نداشتهام.',
|
||||||
|
order: 4,
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q12NotSure = choiceRepository.create({
|
||||||
|
question: q12,
|
||||||
|
text: 'مطمئن نیستم.',
|
||||||
|
order: 5,
|
||||||
|
score: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const q12Choices = [
|
||||||
|
q12Always,
|
||||||
|
q12Sometimes,
|
||||||
|
q12Never,
|
||||||
|
q12NotApplicable,
|
||||||
|
q12NotSure,
|
||||||
|
];
|
||||||
|
await choiceRepository.save(q12Choices);
|
||||||
|
|
||||||
|
return { q8Yes, q10Yes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { QuestionRangeRule } from '@/modules/questions/entities/question-range-rule.entity';
|
||||||
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export async function seedQuestionRangeRules(
|
||||||
|
manager: EntityManager,
|
||||||
|
questions: Record<string, Question>,
|
||||||
|
) {
|
||||||
|
const rangeRuleRepository = manager.getRepository(QuestionRangeRule);
|
||||||
|
const { q9, q11 } = questions;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 9 RANGE RULES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*
|
||||||
|
* 1 person => 2
|
||||||
|
* 2 people => 3
|
||||||
|
* 3 or more => 5
|
||||||
|
*/
|
||||||
|
|
||||||
|
await rangeRuleRepository.save([
|
||||||
|
rangeRuleRepository.create({
|
||||||
|
question: q9,
|
||||||
|
min: 1,
|
||||||
|
max: 1,
|
||||||
|
score: 2,
|
||||||
|
}),
|
||||||
|
|
||||||
|
rangeRuleRepository.create({
|
||||||
|
question: q9,
|
||||||
|
min: 2,
|
||||||
|
max: 2,
|
||||||
|
score: 3,
|
||||||
|
}),
|
||||||
|
|
||||||
|
rangeRuleRepository.create({
|
||||||
|
question: q9,
|
||||||
|
min: 3,
|
||||||
|
max: 2147483647,
|
||||||
|
score: 5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION 11 RANGE RULES
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*
|
||||||
|
* 1 person => 1
|
||||||
|
* 2 people => 2
|
||||||
|
* 3 or more => 3
|
||||||
|
*/
|
||||||
|
|
||||||
|
await rangeRuleRepository.save([
|
||||||
|
rangeRuleRepository.create({
|
||||||
|
question: q11,
|
||||||
|
min: 1,
|
||||||
|
max: 1,
|
||||||
|
score: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
rangeRuleRepository.create({
|
||||||
|
question: q11,
|
||||||
|
min: 2,
|
||||||
|
max: 2,
|
||||||
|
score: 2,
|
||||||
|
}),
|
||||||
|
|
||||||
|
rangeRuleRepository.create({
|
||||||
|
question: q11,
|
||||||
|
min: 3,
|
||||||
|
max: 2147483647,
|
||||||
|
score: 3,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
|
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||||
|
import { Test } from '@/modules/tests/entities/test.entity';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export async function seedQuestions(manager: EntityManager, savedTest: Test) {
|
||||||
|
const questionRepository = manager.getRepository(Question);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTIONS
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const q1 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 1,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'آیا اخیراً علائمی داشتهاید که فکر میکنید ممکن است ناشی از عفونتهای منتقله از راه جنسی باشند؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q2 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 2,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'آیا تا به حال پزشک یا یکی از کادر درمان به شما گفته است که به یک یا چند بیماری مقاربتی مبتلا شدهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q3 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.MULTI_CHOICE,
|
||||||
|
order: 3,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'تاکنون به کدامیک از بیماریهای مقاربتی مبتلا بودهاید؟ (میتوانید بیش از یک گزینه را انتخاب کنید.)',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q4 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.MULTI_CHOICE,
|
||||||
|
order: 4,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'آیا اخیراً با فردی که به یکی از بیماریهای زیر مبتلا بوده، رابطهٔ جنسی داشتهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q5 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 5,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content: 'آیا تاکنون مادهای غیرقانونی را به صورت تزریقی مصرف کردهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q6 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 6,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'در ۱۲ ماه گذشته، آیا با فردی با تابعیت غیر ایرانی رابطهٔ جنسی داشتهاید؟ (مثلاً هنگام سفر به خارج از کشور رابطهٔ جنسی داشتهاید، یا در ایران با فردی از کشور دیگری رابطه داشتهاید؟)',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q7 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 7,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content: 'آیا در حال حاضر به عنوان کارگر جنسی فعالیت میکنید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q8 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 8,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'در ۱۲ ماه گذشته، آیا با یک فرد همجنس رابطهٔ جنسی داشتهاید؟ (مثلاً مرد با مرد)',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q9 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.NUMERIC,
|
||||||
|
order: 9,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content: 'در ۱۲ ماه گذشته، با چند فرد از همجنس خود رابطهٔ جنسی داشتهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q10 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 10,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'در ۱۲ ماه گذشته، آیا با یک فرد از جنس مخالف رابطهٔ جنسی داشتهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q11 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.NUMERIC,
|
||||||
|
order: 11,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content: 'در ۱۲ ماه گذشته، با چند نفر از جنس مخالف رابطهٔ جنسی داشتهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q12 = questionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
type: QuestionType.SINGLE_CHOICE,
|
||||||
|
order: 12,
|
||||||
|
weight: 1,
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
'در ۱۲ ماه گذشته، آیا هنگام رابطهٔ واژنی یا مقعدی از کاندوم استفاده کردهاید؟',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const questions = {
|
||||||
|
q1,
|
||||||
|
q2,
|
||||||
|
q3,
|
||||||
|
q4,
|
||||||
|
q5,
|
||||||
|
q6,
|
||||||
|
q7,
|
||||||
|
q8,
|
||||||
|
q9,
|
||||||
|
q10,
|
||||||
|
q11,
|
||||||
|
q12,
|
||||||
|
};
|
||||||
|
|
||||||
|
await questionRepository.save(Object.values(questions));
|
||||||
|
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||||
|
import { Test } from '@/modules/tests/entities/test.entity';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export async function seedResultRange(manager: EntityManager, savedTest: Test) {
|
||||||
|
const resultRangeRepository = manager.getRepository(ResultRange);
|
||||||
|
|
||||||
|
await resultRangeRepository.save([
|
||||||
|
resultRangeRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
minScore: 0,
|
||||||
|
maxScore: 1,
|
||||||
|
title: 'خطر پایین',
|
||||||
|
description: 'امتیاز شما در محدوده خطر پایین قرار دارد.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
resultRangeRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
minScore: 2,
|
||||||
|
maxScore: 4,
|
||||||
|
title: 'خطر متوسط',
|
||||||
|
description: 'امتیاز شما در محدوده خطر متوسط قرار دارد.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
resultRangeRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
minScore: 5,
|
||||||
|
maxScore: 7,
|
||||||
|
title: 'خطر بالا',
|
||||||
|
description: 'امتیاز شما در محدوده خطر بالا قرار دارد.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
resultRangeRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
minScore: 8,
|
||||||
|
maxScore: 60,
|
||||||
|
title: 'خطر بسیار بالا',
|
||||||
|
description: 'امتیاز شما در محدوده خطر بسیار بالا قرار دارد.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import dataSource from '../data-source';
|
||||||
|
|
||||||
|
import { Test } from '../../modules/tests/entities/test.entity';
|
||||||
|
|
||||||
|
import { seedSuperuser } from './superuser.seed';
|
||||||
|
import { seedTests } from './tests.seed';
|
||||||
|
import { seedQuestions } from './questions.seed';
|
||||||
|
import { seedChoices } from './choices.seed';
|
||||||
|
import { seedQuestionRangeRules } from './question-range-rules.seed';
|
||||||
|
import { seedTransitions } from './transitions.seed';
|
||||||
|
import { seedResultRange } from './result-range.seed';
|
||||||
|
|
||||||
|
async function prepareTests() {
|
||||||
|
const result = await dataSource.transaction(async (manager) => {
|
||||||
|
const seedTestResult = await seedTests(manager);
|
||||||
|
|
||||||
|
let savedTest: Test;
|
||||||
|
|
||||||
|
if (seedTestResult instanceof Test) {
|
||||||
|
savedTest = seedTestResult;
|
||||||
|
} else {
|
||||||
|
return seedTestResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = await seedQuestions(manager, savedTest);
|
||||||
|
|
||||||
|
const choices = await seedChoices(manager, questions);
|
||||||
|
|
||||||
|
await seedQuestionRangeRules(manager, questions);
|
||||||
|
|
||||||
|
await seedTransitions(manager, savedTest, questions, choices);
|
||||||
|
|
||||||
|
await seedResultRange(manager, savedTest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: true,
|
||||||
|
testId: savedTest.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.created) {
|
||||||
|
console.log(`Test created successfully. ID: ${result.testId}`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`Test already exists. ID: ${result.testId}. Nothing was created.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDatabase() {
|
||||||
|
await dataSource.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN (
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
)
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'TRUNCATE TABLE "' || r.tablename || '" RESTART IDENTITY CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
try {
|
||||||
|
await dataSource.initialize();
|
||||||
|
|
||||||
|
console.log('Database connection initialized.');
|
||||||
|
|
||||||
|
await clearDatabase();
|
||||||
|
await seedSuperuser();
|
||||||
|
await prepareTests();
|
||||||
|
|
||||||
|
console.log('Seed completed successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Seed failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (dataSource.isInitialized) {
|
||||||
|
await dataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void seed();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User } from '../../modules/users/entities/user.entity';
|
||||||
|
import { Role } from '@/common/enums/roles.enum';
|
||||||
|
import dataSource from '../data-source';
|
||||||
|
|
||||||
|
const SUPERUSER_PHONE = process.env.SUPERUSER_PHONE;
|
||||||
|
const SUPERUSER_PASSWORD = process.env.SUPERUSER_PASSWORD;
|
||||||
|
|
||||||
|
export async function seedSuperuser() {
|
||||||
|
if (!SUPERUSER_PHONE || !SUPERUSER_PASSWORD) {
|
||||||
|
console.log(
|
||||||
|
'SUPERUSER_PHONE or SUPERUSER_PASSWORD is not defined. Skipping superuser seed.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRepository = dataSource.getRepository(User);
|
||||||
|
|
||||||
|
const existingUser = await userRepository.findOne({
|
||||||
|
where: {
|
||||||
|
phone: SUPERUSER_PHONE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
if (existingUser.role !== Role.SUPERUSER) {
|
||||||
|
existingUser.role = Role.SUPERUSER;
|
||||||
|
await userRepository.save(existingUser);
|
||||||
|
|
||||||
|
console.log(`User ${SUPERUSER_PHONE} promoted to SUPERUSER.`);
|
||||||
|
} else {
|
||||||
|
console.log(`Superuser ${SUPERUSER_PHONE} already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(SUPERUSER_PASSWORD, 12);
|
||||||
|
|
||||||
|
const superuser = userRepository.create({
|
||||||
|
phone: SUPERUSER_PHONE,
|
||||||
|
password: passwordHash,
|
||||||
|
role: Role.SUPERUSER,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userRepository.save(superuser);
|
||||||
|
|
||||||
|
console.log(`Superuser ${SUPERUSER_PHONE} created successfully.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Test } from '@/modules/tests/entities/test.entity';
|
||||||
|
import { AccessType } from '@/modules/tests/enums/access-types.enum';
|
||||||
|
import { TestStatus } from '@/modules/tests/enums/test-status.enum';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
const TEST_TITLE = 'تست ارزیابی خطر ابتلا به عفونتهای منتقله از راه جنسی';
|
||||||
|
|
||||||
|
export async function seedTests(manager: EntityManager) {
|
||||||
|
const testRepository = manager.getRepository(Test);
|
||||||
|
|
||||||
|
const existingTest = await testRepository.findOne({
|
||||||
|
where: {
|
||||||
|
title: TEST_TITLE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTest) {
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
testId: existingTest.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* TEST
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = testRepository.create({
|
||||||
|
title: TEST_TITLE,
|
||||||
|
description:
|
||||||
|
'این تست برای ارزیابی عوامل مرتبط با خطر ابتلا به عفونتهای منتقله از راه جنسی طراحی شده است.',
|
||||||
|
status: TestStatus.PUBLISHED,
|
||||||
|
accessType: AccessType.FREE,
|
||||||
|
isActive: true,
|
||||||
|
questionsCount: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedTest = await testRepository.save(test);
|
||||||
|
|
||||||
|
return savedTest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { Choice } from '@/modules/questions/entities/choice.entity';
|
||||||
|
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||||
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
|
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||||
|
import { Test } from '@/modules/tests/entities/test.entity';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export async function seedTransitions(
|
||||||
|
manager: EntityManager,
|
||||||
|
savedTest: Test,
|
||||||
|
questions: Record<string, Question>,
|
||||||
|
choices: Record<string, Choice>,
|
||||||
|
) {
|
||||||
|
const transitionRepository = manager.getRepository(QuestionTransition);
|
||||||
|
const { q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11, q12 } = questions;
|
||||||
|
const { q8Yes, q10Yes } = choices;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* QUESTION TRANSITIONS
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* START -> Q1
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: null,
|
||||||
|
toQuestion: q1,
|
||||||
|
type: QuestionTransitionType.START,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q1 -> Q2
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q1,
|
||||||
|
toQuestion: q2,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q2 -> Q3
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q2,
|
||||||
|
toQuestion: q3,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q3 -> Q4
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q3,
|
||||||
|
toQuestion: q4,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q4 -> Q5
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q4,
|
||||||
|
toQuestion: q5,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q5 -> Q6
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q5,
|
||||||
|
toQuestion: q6,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q6 -> Q7
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q6,
|
||||||
|
toQuestion: q7,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q7 -> Q8
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q7,
|
||||||
|
toQuestion: q8,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* Q8 BRANCH
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*
|
||||||
|
* YES -> Q9
|
||||||
|
* NO -> Q10
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q8,
|
||||||
|
toQuestion: q9,
|
||||||
|
type: QuestionTransitionType.CHOICE,
|
||||||
|
condition: {
|
||||||
|
choiceId: q8Yes.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q8,
|
||||||
|
toQuestion: q10,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q9 -> Q10
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q9,
|
||||||
|
toQuestion: q10,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* Q10 BRANCH
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
*
|
||||||
|
* YES -> Q11
|
||||||
|
* NO -> Q12
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q10,
|
||||||
|
toQuestion: q11,
|
||||||
|
type: QuestionTransitionType.CHOICE,
|
||||||
|
condition: {
|
||||||
|
choiceId: q10Yes.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q10,
|
||||||
|
toQuestion: q12,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q11 -> Q12
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q11,
|
||||||
|
toQuestion: q12,
|
||||||
|
type: QuestionTransitionType.DEFAULT,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Q12 -> END
|
||||||
|
*/
|
||||||
|
|
||||||
|
await transitionRepository.save(
|
||||||
|
transitionRepository.create({
|
||||||
|
test: savedTest,
|
||||||
|
fromQuestion: q12,
|
||||||
|
toQuestion: null,
|
||||||
|
type: QuestionTransitionType.END,
|
||||||
|
condition: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"otp": "Otp"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversationId": "Conversation Id",
|
||||||
|
"message": "Message"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"internalServerError": "Internal Server Error",
|
"internalServerError": "Internal Server Error",
|
||||||
"tooManyRequests": "Too many requests! Try again after {minutes} min.",
|
"tooManyRequests": "Too many requests! Try again after {minutes} min.",
|
||||||
"requestTimeout": "Unable to process your request at the moment.",
|
"requestTimeout": "Unable to process your request at the moment.",
|
||||||
"entityNotFound": "{entity} not found."
|
"entityNotFound": "{entity} not found.",
|
||||||
|
"sameProcessInProgress": "Same process is in progress."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "{entity} created successfully.",
|
"createSuccess": "{entity} created successfully.",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversation": "Conversation"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
@@ -31,8 +30,12 @@
|
|||||||
"endMustNotHaveCondition": "End must not have condition.",
|
"endMustNotHaveCondition": "End must not have condition.",
|
||||||
"invalidTransition": "Invalid Transition!",
|
"invalidTransition": "Invalid Transition!",
|
||||||
"defaultMustNotHaveCondition": "Default must not have condition.",
|
"defaultMustNotHaveCondition": "Default must not have condition.",
|
||||||
|
"fromQuestionMustBeSingleChoice": "fromQuestion must be single choice.",
|
||||||
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
||||||
"invalidChoiceCondition": "Invalid choice condition!",
|
"invalidChoiceCondition": "Invalid choice condition!",
|
||||||
|
"invalidMultiChoiceCondition": "Invalid multi choice condition!",
|
||||||
|
"invalidMultiChoiceOperator": "Invalid multi choice operator!",
|
||||||
|
"duplicateChoiceIds": "Duplicate choice Ids!",
|
||||||
"choiceNotBelongToQuestion": "Choice does not belong to the question.",
|
"choiceNotBelongToQuestion": "Choice does not belong to the question.",
|
||||||
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
||||||
"invalidNumberRange": "Invalid number range!",
|
"invalidNumberRange": "Invalid number range!",
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
"questionId": "Question's Id",
|
"questionId": "Question's Id",
|
||||||
"submission": "Submission",
|
"submission": "Submission",
|
||||||
"submissionId": "Submission's Id",
|
"submissionId": "Submission's Id",
|
||||||
"choiceId": "Choice's Id"
|
"choiceId": "Choice's Id",
|
||||||
|
"choiceIds": "Choice Ids list"
|
||||||
},
|
},
|
||||||
"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.",
|
||||||
|
"emptyChoiceIds": "Choice Ids should be provided as answer.",
|
||||||
|
"transitionNotFound": "No transition found for the test",
|
||||||
|
"uniqueSubmissionQuestion": "Question's Answer already exists."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"intPrice": "Price should be an integer.",
|
"intPrice": "Price should be an integer.",
|
||||||
"priceAccessConstraint": "Test's price depends on access type of 'Paid'."
|
"priceAccessConstraint": "Test's price depends on access type of 'Paid'.",
|
||||||
|
"mustHaveSingleStart": "Test must have single start.",
|
||||||
|
"hasOrphanQuestions": "Test has some orphan questions.",
|
||||||
|
"hasCycle": "Test has cycle.",
|
||||||
|
"notEndingFlow": "Test include some flows which never end.",
|
||||||
|
"notActiveTest": "Test is not active.",
|
||||||
|
"multiChoiceMustHaveFallback": "Multi choice must have fallback.",
|
||||||
|
"questionHasMultipleFallbacks": "Question has multiple fallbacks."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "Test Created Successfully."
|
"createSuccess": "Test Created Successfully."
|
||||||
|
|||||||
@@ -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": "با موفقیت خارج شدید."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversationId": "شناسه مکالمه",
|
||||||
|
"message": "پیام"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"internalServerError": "خطایی رخ داد",
|
"internalServerError": "خطایی رخ داد",
|
||||||
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
||||||
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
||||||
"entityNotFound": "{entity} مورد نظر یافت نشد."
|
"entityNotFound": "{entity} مورد نظر یافت نشد.",
|
||||||
|
"sameProcessInProgress": "فرایند مشابهی در حال اجراست."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"conversation": "مکالمه"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
"metadata": "تنظیمات",
|
"metadata": "تنظیمات",
|
||||||
"question": "سوال",
|
"question": "سوال",
|
||||||
"text": "متن",
|
"text": "متن",
|
||||||
"value": "مقدار",
|
|
||||||
"choice": "گزینه",
|
"choice": "گزینه",
|
||||||
"score": "نمره",
|
"score": "نمره",
|
||||||
"weight": "وزن",
|
"weight": "وزن",
|
||||||
@@ -31,8 +30,12 @@
|
|||||||
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
||||||
"invalidTransition": "مسیر اشتباه است!",
|
"invalidTransition": "مسیر اشتباه است!",
|
||||||
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
||||||
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.",
|
"fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.",
|
||||||
|
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.",
|
||||||
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
||||||
|
"invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!",
|
||||||
|
"invalidMultiChoiceOperator": "عملگر شرط اشتباه است!",
|
||||||
|
"duplicateChoiceIds": "شناسه گزینه ها تکراری است!",
|
||||||
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
||||||
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
||||||
"invalidNumberRange": "بازه عددی اشتباه است!",
|
"invalidNumberRange": "بازه عددی اشتباه است!",
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
"questionId": "شناسه سوال",
|
"questionId": "شناسه سوال",
|
||||||
"submission": "Submission",
|
"submission": "Submission",
|
||||||
"submissionId": "شناسه Submission",
|
"submissionId": "شناسه Submission",
|
||||||
"choiceId": "شناسه گزینه"
|
"choiceId": "شناسه گزینه",
|
||||||
|
"choiceIds": "لیست شناسه گزینه ها"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
|
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
|
||||||
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
||||||
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
||||||
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
||||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود."
|
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
|
||||||
|
"emptyChoiceIds": "لیستی از شناسه گزینه ها باید به عنوان پاسخ مشخص شود.",
|
||||||
|
"transitionNotFound": "مسیری برای تست پیدا نشد.",
|
||||||
|
"uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"intPrice": "قیمت باید عدد صحیح باشد.",
|
"intPrice": "قیمت باید عدد صحیح باشد.",
|
||||||
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است."
|
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است.",
|
||||||
|
"mustHaveSingleStart": "تست باید یک سوال شروع داشته باشد.",
|
||||||
|
"hasOrphanQuestions": "تست شامل تعدادی سوال یتیم است.",
|
||||||
|
"hasCycle": "تست شامل حلقه است.",
|
||||||
|
"notEndingFlow": "تست شامل جریان بی پایان است.",
|
||||||
|
"notActiveTest": "تست مورد نظر فعال نیست.",
|
||||||
|
"multiChoiceMustHaveFallback": "سوال چند انتخابی باید یک مسیر جایگزین داشته باشد.",
|
||||||
|
"questionHasMultipleFallbacks": "سوال چند جایگزین دارد!"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "تست با موفقیت ایجاد شد."
|
"createSuccess": "تست با موفقیت ایجاد شد."
|
||||||
|
|||||||
+13
-1
@@ -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,67 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class SubmissionCurrentQuestion1786282238898 implements MigrationInterface {
|
||||||
|
name = 'SubmissionCurrentQuestion1786282238898';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "public"."unq_test_start"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "public"."unq_question_end"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "public"."unq_question_default"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "public"."unq_question_choice"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "public"."unq_question_number_range"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question_transition" DROP CONSTRAINT "ex_question_number_range_overlap"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" RENAME COLUMN "currentQuestionOrder" TO "currentQuestionId"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" DROP DEFAULT`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" ADD CONSTRAINT "FK_71b22c49b79a4eb32e9b1f05c76" FOREIGN KEY ("currentQuestionId") REFERENCES "question"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" DROP CONSTRAINT "FK_71b22c49b79a4eb32e9b1f05c76"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" SET DEFAULT '1'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission" RENAME COLUMN "currentQuestionId" TO "currentQuestionOrder"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question_transition" ADD CONSTRAINT "ex_question_number_range_overlap" EXCLUDE USING gist ("fromQuestionId" WITH =, int4range(((condition ->> 'min'::text))::integer, ((condition ->> 'max'::text))::integer, '[]'::text) WITH &&) WHERE ((type = 'number-range'::question_transition_type_enum))`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "unq_question_number_range" ON "question_transition" ("fromQuestionId") WHERE (type = 'number-range'::question_transition_type_enum)`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "unq_question_choice" ON "question_transition" ("fromQuestionId") WHERE (type = 'choice'::question_transition_type_enum)`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "unq_question_default" ON "question_transition" ("fromQuestionId") WHERE (type = 'default'::question_transition_type_enum)`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "unq_question_end" ON "question_transition" ("fromQuestionId") WHERE (type = 'end'::question_transition_type_enum)`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "unq_test_start" ON "question_transition" ("testId") WHERE (type = 'start'::question_transition_type_enum)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class MultiChoiceSubmission1787294625853 implements MigrationInterface {
|
||||||
|
name = 'MultiChoiceSubmission1787294625853';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX "public"."IDX_ea78aac2e76e03f1c2fc720f1d"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "submission_answer_choices_choice" ("submissionAnswerId" integer NOT NULL, "choiceId" integer NOT NULL, CONSTRAINT "PK_d3154f459415dc8f97a4bb10a1e" PRIMARY KEY ("submissionAnswerId", "choiceId"))`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6f1902623cd8b3b52a5688a949" ON "submission_answer_choices_choice" ("submissionAnswerId") `,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_2e699b5f339fb37917549c2fbf" ON "submission_answer_choices_choice" ("choiceId") `,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE "public"."question_transition_type_enum" RENAME TO "question_transition_type_enum_old"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE "public"."question_transition_type_enum" AS ENUM('start', 'default', 'choice', 'multi-choice', 'number-range', 'end')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question_transition" ALTER COLUMN "type" TYPE "public"."question_transition_type_enum" USING "type"::"text"::"public"."question_transition_type_enum"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TYPE "public"."question_transition_type_enum_old"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE "public"."question_type_enum" RENAME TO "question_type_enum_old"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE "public"."question_type_enum" AS ENUM('single-choice', 'multi-choice', 'numeric', 'text')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question" ALTER COLUMN "type" DROP DEFAULT`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question" ALTER COLUMN "type" TYPE "public"."question_type_enum" USING "type"::"text"::"public"."question_type_enum"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question" ALTER COLUMN "type" SET DEFAULT 'single-choice'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."question_type_enum_old"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "UNQ_SUBMISSION_QUESTION_IDX" ON "submission_answer" ("submissionId", "questionId") `,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission_answer_choices_choice" ADD CONSTRAINT "FK_6f1902623cd8b3b52a5688a949b" FOREIGN KEY ("submissionAnswerId") REFERENCES "submission_answer"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission_answer_choices_choice" ADD CONSTRAINT "FK_2e699b5f339fb37917549c2fbfc" FOREIGN KEY ("choiceId") REFERENCES "choice"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission_answer_choices_choice" DROP CONSTRAINT "FK_2e699b5f339fb37917549c2fbfc"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "submission_answer_choices_choice" DROP CONSTRAINT "FK_6f1902623cd8b3b52a5688a949b"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX "public"."UNQ_SUBMISSION_QUESTION_IDX"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE "public"."question_type_enum_old" AS ENUM('multi-choice', 'numeric', 'text')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question" ALTER COLUMN "type" DROP DEFAULT`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question" ALTER COLUMN "type" TYPE "public"."question_type_enum_old" USING "type"::"text"::"public"."question_type_enum_old"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question" ALTER COLUMN "type" SET DEFAULT 'multi-choice'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."question_type_enum"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE "public"."question_type_enum_old" RENAME TO "question_type_enum"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE "public"."question_transition_type_enum_old" AS ENUM('start', 'default', 'choice', 'number-range', 'end')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "question_transition" ALTER COLUMN "type" TYPE "public"."question_transition_type_enum_old" USING "type"::"text"::"public"."question_transition_type_enum_old"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TYPE "public"."question_transition_type_enum"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE "public"."question_transition_type_enum_old" RENAME TO "question_transition_type_enum"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX "public"."IDX_2e699b5f339fb37917549c2fbf"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX "public"."IDX_6f1902623cd8b3b52a5688a949"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "submission_answer_choices_choice"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "IDX_ea78aac2e76e03f1c2fc720f1d" ON "submission_answer" ("questionId", "submissionId") `,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { UNIQUE_QUESTION_MULTI_CHOICE } from '@/modules/questions/constants';
|
||||||
|
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class MultiChoiceTransitionUniqueIndex1787294625900 implements MigrationInterface {
|
||||||
|
name = 'MultiChoiceTransitionUniqueIndex1787294625900';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX ${UNIQUE_QUESTION_MULTI_CHOICE}
|
||||||
|
ON "question_transition" (
|
||||||
|
"fromQuestionId",
|
||||||
|
"type",
|
||||||
|
(("condition" ->> 'operator')),
|
||||||
|
(("condition" -> 'choiceIds'))
|
||||||
|
)
|
||||||
|
WHERE "type" = '${QuestionTransitionType.MULTI_CHOICE}'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX ${UNIQUE_QUESTION_MULTI_CHOICE}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
DELETE {{baseUrl}}/admin/question-transitions/26
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
Content-Type: application/json
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
PATCH {{baseUrl}}/admin/question-transitions/26
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"toQuestion": 13
|
||||||
|
}
|
||||||
+6
-5
@@ -3,10 +3,11 @@ Authorization: Bearer {{$global.accessToken}}
|
|||||||
Content-Type: application/json
|
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
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { GlobalAuthGuard } from './guards/global-auth.guard';
|
|||||||
import { RefreshTokensProvider } from './providers/refresh-tokens.provider';
|
import { RefreshTokensProvider } from './providers/refresh-tokens.provider';
|
||||||
import { RedisModule } from '@/common/modules/redis/redis.module';
|
import { RedisModule } from '@/common/modules/redis/redis.module';
|
||||||
import { AuthRedisProvider } from './providers/auth-redis.provider';
|
import { AuthRedisProvider } from './providers/auth-redis.provider';
|
||||||
|
import { LogoutProvider } from './providers/logout.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -43,6 +44,7 @@ import { AuthRedisProvider } from './providers/auth-redis.provider';
|
|||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RefreshTokensProvider,
|
RefreshTokensProvider,
|
||||||
AuthRedisProvider,
|
AuthRedisProvider,
|
||||||
|
LogoutProvider,
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: GlobalAuthGuard,
|
useClass: GlobalAuthGuard,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { ExtractJwt } from 'passport-jwt';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
export const AccessToken = createParamDecorator(
|
||||||
|
(_: unknown, ctx: ExecutionContext): string | undefined => {
|
||||||
|
const request: Request = ctx.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
return ExtractJwt.fromAuthHeaderAsBearerToken()(request) ?? undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LogoutDTO {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
description: "User's Refresh Token",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ChatbotService } from './providers/chatbot.service';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { ChatDTO } from './dtos/chat.dto';
|
||||||
|
import { ActiveUser } from '@/common/decorators/active-user.decorator';
|
||||||
|
import { User } from '../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Controller('chatbot')
|
||||||
|
export class ChatbotController {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject Chatbot Service
|
||||||
|
*/
|
||||||
|
private readonly chatbotService: ChatbotService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('chat')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
public async chat(@Body() chatDto: ChatDTO, @ActiveUser() user: User) {
|
||||||
|
return this.chatbotService.chat(
|
||||||
|
user,
|
||||||
|
chatDto.message,
|
||||||
|
chatDto.conversationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('stream')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
public async streamChat(
|
||||||
|
@Body() chatDto: ChatDTO,
|
||||||
|
@ActiveUser() user: User,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
for await (const content of this.chatbotService.streamChat(
|
||||||
|
user,
|
||||||
|
chatDto.message,
|
||||||
|
chatDto.conversationId,
|
||||||
|
)) {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
message: content,
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatbotController } from './chatbot.controller';
|
||||||
|
import { ChatbotService } from './providers/chatbot.service';
|
||||||
|
import { ConversationModule } from '../conversation/conversation.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConversationModule],
|
||||||
|
controllers: [ChatbotController],
|
||||||
|
providers: [ChatbotService],
|
||||||
|
})
|
||||||
|
export class ChatbotModule {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const CHATBOT_SYSTEM_PROMPT = `
|
||||||
|
You are a medical AI assistant named Heala (Or in persian: هیلا).
|
||||||
|
|
||||||
|
Your role is to provide general medical information
|
||||||
|
and health education.
|
||||||
|
|
||||||
|
- Do not provide definitive diagnoses.
|
||||||
|
- Do not prescribe medications or dosages.
|
||||||
|
- Do not fabricate medical information.
|
||||||
|
- If symptoms may indicate an emergency, recommend
|
||||||
|
seeking urgent medical care.
|
||||||
|
- Clearly communicate uncertainty when appropriate.
|
||||||
|
- Answer in Persian when the user asks in Persian.
|
||||||
|
`;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
|
|
||||||
|
export class ChatDTO {
|
||||||
|
@IsInt({
|
||||||
|
message: t('validation.wrongFieldFormat', {
|
||||||
|
field: '$t(chatbot.fields.conversationId)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
conversationId?: number;
|
||||||
|
|
||||||
|
@IsString({
|
||||||
|
message: t('validation.wrongFieldFormat', {
|
||||||
|
field: '$t(chatbot.fields.message)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
@IsNotEmpty({
|
||||||
|
message: t('validation.requiredField', {
|
||||||
|
field: '$t(chatbot.fields.message)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
message!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
POST {{baseUrl}}/chatbot/chat
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟"
|
||||||
|
}
|
||||||
|
|
||||||
|
# POST {{baseUrl}}/chatbot/stream/
|
||||||
|
# Content-Type: application/json
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CHATBOT_SYSTEM_PROMPT } from '../constants';
|
||||||
|
import { User } from '@/modules/users/entities/user.entity';
|
||||||
|
import { Conversation } from '@/modules/conversation/entities/conversation.entity';
|
||||||
|
import { ConversationService } from '@/modules/conversation/providers/conversation.service';
|
||||||
|
import { MessageRole } from '@/modules/conversation/enums/message-role.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatbotService {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject Config Service
|
||||||
|
*/
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Conversation Service
|
||||||
|
*/
|
||||||
|
private readonly conversationService: ConversationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async getOpenRouter() {
|
||||||
|
const { OpenRouter } = await import('@openrouter/sdk');
|
||||||
|
|
||||||
|
return new OpenRouter({
|
||||||
|
apiKey: this.configService.getOrThrow<string>('openRouter.apiKey'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getConversationMessages(
|
||||||
|
user: User,
|
||||||
|
message: string,
|
||||||
|
conversationId?: number,
|
||||||
|
) {
|
||||||
|
let conversation: Conversation;
|
||||||
|
|
||||||
|
if (conversationId) {
|
||||||
|
conversation = await this.conversationService.findConversationById(
|
||||||
|
conversationId,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
conversation = await this.conversationService.createConversation(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.conversationService.addMessage(
|
||||||
|
conversation,
|
||||||
|
MessageRole.USER,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages = await this.conversationService.getMessages(conversation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system' as const,
|
||||||
|
content: CHATBOT_SYSTEM_PROMPT.trim(),
|
||||||
|
},
|
||||||
|
...messages.map((message) => ({
|
||||||
|
role:
|
||||||
|
message.role === MessageRole.USER
|
||||||
|
? ('user' as const)
|
||||||
|
: ('assistant' as const),
|
||||||
|
content: message.content,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async chat(user: User, message: string, conversationId?: number) {
|
||||||
|
const { conversation, messages } = await this.getConversationMessages(
|
||||||
|
user,
|
||||||
|
message,
|
||||||
|
conversationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openRouter = await this.getOpenRouter();
|
||||||
|
|
||||||
|
const response = await openRouter.chat.send({
|
||||||
|
chatRequest: {
|
||||||
|
model: this.configService.getOrThrow<string>('openRouter.model'),
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response instanceof ReadableStream) {
|
||||||
|
throw new Error('Expected non-streaming response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content;
|
||||||
|
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
throw new Error('OpenRouter returned an unexpected response');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.conversationService.addMessage(
|
||||||
|
conversation,
|
||||||
|
MessageRole.ASSISTANT,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
message: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async *streamChat(
|
||||||
|
user: User,
|
||||||
|
message: string,
|
||||||
|
conversationId?: number,
|
||||||
|
) {
|
||||||
|
const { conversation, messages } = await this.getConversationMessages(
|
||||||
|
user,
|
||||||
|
message,
|
||||||
|
conversationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openRouter = await this.getOpenRouter();
|
||||||
|
|
||||||
|
const response = await openRouter.chat.send({
|
||||||
|
chatRequest: {
|
||||||
|
model: this.configService.getOrThrow<string>('openRouter.model'),
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('choices' in response) {
|
||||||
|
throw new Error('Expected streaming response');
|
||||||
|
}
|
||||||
|
|
||||||
|
let assistantMessage = '';
|
||||||
|
|
||||||
|
for await (const chunk of response) {
|
||||||
|
const content = chunk.choices[0]?.delta?.content;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMessage += content;
|
||||||
|
|
||||||
|
yield { conversationId: conversation.id, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assistantMessage) {
|
||||||
|
await this.conversationService.addMessage(
|
||||||
|
conversation,
|
||||||
|
MessageRole.ASSISTANT,
|
||||||
|
assistantMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Conversation } from './entities/conversation.entity';
|
||||||
|
import { Message } from './entities/message.entity';
|
||||||
|
import { ConversationService } from './providers/conversation.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Conversation, Message])],
|
||||||
|
providers: [ConversationService],
|
||||||
|
exports: [ConversationService],
|
||||||
|
})
|
||||||
|
export class ConversationModule {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { User } from '@/modules/users/entities/user.entity';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
BaseEntity,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Message } from './message.entity';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Conversation extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
title!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
user!: User;
|
||||||
|
|
||||||
|
@OneToMany(() => Message, (message) => message.conversation)
|
||||||
|
messages!: Message[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
BaseEntity,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Conversation } from './conversation.entity';
|
||||||
|
import { MessageRole } from '../enums/message-role.enum';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Message extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => Conversation, (conversation) => conversation.messages, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
conversation!: Conversation;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: MessageRole,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
role!: MessageRole;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum ConversationType {
|
||||||
|
AI = 'ai',
|
||||||
|
DOCTOR = 'doctor',
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum MessageRole {
|
||||||
|
USER = 'user',
|
||||||
|
ASSISTANT = 'assistant',
|
||||||
|
DOCTOR = 'doctor',
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Conversation } from '../entities/conversation.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Message } from '../entities/message.entity';
|
||||||
|
import { User } from '@/modules/users/entities/user.entity';
|
||||||
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
|
import { MessageRole } from '../enums/message-role.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConversationService {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject Conversation Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(Conversation)
|
||||||
|
private readonly conversationRepository: Repository<Conversation>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Message Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(Message)
|
||||||
|
private readonly messageRepository: Repository<Message>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async createConversation(
|
||||||
|
user: User,
|
||||||
|
title?: string,
|
||||||
|
): Promise<Conversation> {
|
||||||
|
const conversation = this.conversationRepository.create({
|
||||||
|
user,
|
||||||
|
title: title ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.conversationRepository.save(conversation);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findConversationById(
|
||||||
|
id: number,
|
||||||
|
user: User,
|
||||||
|
): Promise<Conversation> {
|
||||||
|
let conversation: Conversation | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
conversation = await this.conversationRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
throw new AppException(
|
||||||
|
'common.errors.entityNotFound',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
{ entity: '$t(conversations.fields.conversation)' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addMessage(
|
||||||
|
conversation: Conversation,
|
||||||
|
role: MessageRole,
|
||||||
|
content: string,
|
||||||
|
): Promise<Message> {
|
||||||
|
const message = this.messageRepository.create({
|
||||||
|
conversation,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.messageRepository.save(message);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMessages(conversation: Conversation): Promise<Message[]> {
|
||||||
|
let messages: Array<Message> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
messages = await this.messageRepository.find({
|
||||||
|
where: {
|
||||||
|
conversation: {
|
||||||
|
id: conversation.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
createdAt: 'ASC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
BaseEntity,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index(['phone', 'used'])
|
@Index(['phone', 'used'])
|
||||||
export class OTP {
|
export class OTP extends BaseEntity {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export const UNIQUE_QUESTION_END = 'UNQ_QUESTION_END';
|
|||||||
export const UNIQUE_TEST_START = 'UNQ_TEST_START';
|
export const UNIQUE_TEST_START = 'UNQ_TEST_START';
|
||||||
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
||||||
'EX_QUESTION_NUMBER_RANGE_OVERLAP';
|
'EX_QUESTION_NUMBER_RANGE_OVERLAP';
|
||||||
|
export const UNIQUE_QUESTION_MULTI_CHOICE = 'UNQ_QUESTION_MULTI_CHOICE';
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Validate,
|
Validate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { QuestionType } from '../enums/question-type.enum';
|
import { QuestionType } from '../enums/question-type.enum';
|
||||||
import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto';
|
import { SingleChoiceMetadataDTO } from './single-choice-metadata.dto';
|
||||||
import { NumericMetadataDTO } from './numeric-metadata.dto';
|
import { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||||
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
||||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
@@ -60,5 +60,5 @@ export class CreateQuestionDTO {
|
|||||||
|
|
||||||
@Validate(QuestionMetadataValidator)
|
@Validate(QuestionMetadataValidator)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { IsBoolean, IsOptional } from 'class-validator';
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class MultiChoiceMetadataDTO {
|
export class SingleChoiceMetadataDTO {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
allowOther?: boolean;
|
allowOther?: boolean;
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
import { QuestionType } from '../enums/question-type.enum';
|
import { QuestionType } from '../enums/question-type.enum';
|
||||||
import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto';
|
import { SingleChoiceMetadataDTO } from './single-choice-metadata.dto';
|
||||||
import { NumericMetadataDTO } from './numeric-metadata.dto';
|
import { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||||
|
|
||||||
export class UpdateQuestionDTO {
|
export class UpdateQuestionDTO {
|
||||||
@@ -48,5 +48,5 @@ export class UpdateQuestionDTO {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +1,21 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Test, (test) => test.transitions, { onDelete: 'CASCADE' })
|
||||||
test!: Test;
|
test!: Test;
|
||||||
|
|
||||||
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export class Question {
|
|||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: QuestionType,
|
enum: QuestionType,
|
||||||
default: QuestionType.MULTI_CHOICE,
|
default: QuestionType.SINGLE_CHOICE,
|
||||||
})
|
})
|
||||||
type!: QuestionType;
|
type!: QuestionType;
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ export class Question {
|
|||||||
outgoingTransitions!: Array<QuestionTransition>;
|
outgoingTransitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
||||||
incomingTransition!: Array<QuestionTransition>;
|
incomingTransitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum MultiChoiceTConditionOperator {
|
||||||
|
ANY = 'any',
|
||||||
|
ALL = 'all',
|
||||||
|
EXACT = 'exact',
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user