Compare commits
8
Commits
fbce16ef60
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace4342870 | ||
|
|
195eca1015 | ||
|
|
f3fe1f55e4 | ||
|
|
5c4e9df341 | ||
|
|
9842f4db31 | ||
|
|
13ba11f951 | ||
|
|
76f6c97a9e | ||
|
|
17b47ffd91 |
Generated
+4200
-116
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,9 @@
|
||||
"seed:prod": "cross-env NODE_ENV=production node dist/database/seeds/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adminjs/express": "^5.1.0",
|
||||
"@adminjs/nestjs": "^5.1.1",
|
||||
"@adminjs/typeorm": "^4.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -40,10 +43,14 @@
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@openrouter/sdk": "^1.3.33",
|
||||
"adminjs": "^6.8.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express-formidable": "^1.2.0",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.2.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"joi": "^18.0.2",
|
||||
@@ -66,6 +73,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
|
||||
+16
-1
@@ -24,6 +24,11 @@ import { AdminModule } from './modules/admin/admin.module';
|
||||
import { AppThrottlerGuard } from './common/guards/app-throttler.guard';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggingModule } from './common/modules/logging/logging.module';
|
||||
import { ChatbotModule } from './modules/chatbot/chatbot.module';
|
||||
import { ConversationModule } from './modules/conversation/conversation.module';
|
||||
import { AdminPanelModule } from './modules/admin-panel/admin-panel.module';
|
||||
import openRouterConfig from './config/open-router.config';
|
||||
import adminConfig from './config/admin.config';
|
||||
|
||||
const ENV = process.env.NODE_ENV;
|
||||
|
||||
@@ -48,7 +53,14 @@ const ENV = process.env.NODE_ENV;
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: !ENV ? '.env' : `.env.${ENV}`,
|
||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
openRouterConfig,
|
||||
adminConfig,
|
||||
],
|
||||
validationSchema: environmentValidation,
|
||||
}),
|
||||
|
||||
@@ -96,7 +108,10 @@ const ENV = process.env.NODE_ENV;
|
||||
SubmissionsModule,
|
||||
ScoringModule,
|
||||
AdminModule,
|
||||
AdminPanelModule,
|
||||
LoggingModule,
|
||||
ChatbotModule,
|
||||
ConversationModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('admin', () => ({
|
||||
enabled: process.env.ADMIN_PANEL_ENABLED !== 'false',
|
||||
rootPath: process.env.ADMIN_ROOT_PATH || '/admin',
|
||||
cookiePassword: process.env.ADMIN_COOKIE_PASSWORD,
|
||||
cookieName: 'heala.admin.sid',
|
||||
sessionTtlMinutes: parseInt(process.env.ADMIN_SESSION_TTL_MINUTES || '120'),
|
||||
secureCookies: process.env.NODE_ENV === 'production',
|
||||
}));
|
||||
@@ -23,4 +23,11 @@ export default Joi.object({
|
||||
CORS_SUBDOMAIN: Joi.string().required(),
|
||||
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,
|
||||
}));
|
||||
@@ -47,12 +47,31 @@ async function prepareTests() {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"otpSent": "OTP sent to {phone}.",
|
||||
"sendPass": "Please send password."
|
||||
"sendPass": "Please send password.",
|
||||
"loggedOut": "Logged out successfully."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversationId": "Conversation Id",
|
||||
"message": "Message"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversation": "Conversation"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"otpSent": "کد تایید به شماره {phone} ارسال شد.",
|
||||
"sendPass": "لطفا رمز عبور را ارسال کنید."
|
||||
"sendPass": "لطفا رمز عبور را ارسال کنید.",
|
||||
"loggedOut": "با موفقیت خارج شدید."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversationId": "شناسه مکالمه",
|
||||
"message": "پیام"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversation": "مکالمه"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ResponseInterceptor } from './common/interceptors/response.interceptor'
|
||||
import helmet from 'helmet';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AdminPanelSetup } from './modules/admin-panel/admin-panel.setup';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
@@ -14,6 +15,10 @@ async function bootstrap() {
|
||||
|
||||
app.setGlobalPrefix('v1');
|
||||
|
||||
// Mount the admin panel before Nest registers its body parsers; the setup
|
||||
// itself is deferred until the DI container is ready (after app.init)
|
||||
AdminPanelSetup.mount(app);
|
||||
|
||||
app.useGlobalPipes(
|
||||
new I18nValidationPipe({
|
||||
whitelist: true,
|
||||
@@ -94,6 +99,10 @@ async function bootstrap() {
|
||||
|
||||
SwaggerModule.setup('v1/docs', app, document);
|
||||
|
||||
await app.init();
|
||||
|
||||
AdminPanelSetup.setup(app);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { ApiAppResponse } from '@/common/decorators/api-app-response.decorator';
|
||||
import { NewUserLoginResponseDTO } from './dtos/responses/new-user-login.dto';
|
||||
import { RequirePasswordDTO } from './dtos/responses/require-password.dto';
|
||||
import { TokensResponseDTO } from './dtos/responses/tokens.dto';
|
||||
import { LogoutDTO } from './dtos/logout.dto';
|
||||
import { ActiveUser } from '@/common/decorators/active-user.decorator';
|
||||
import { AccessToken } from './decorators/access-token.decorator';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -126,4 +130,32 @@ export class AuthController {
|
||||
public async refreshToken(@Body() refreshTokenDto: RefreshTokenDTO) {
|
||||
return await this.authService.refreshToken(refreshTokenDto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiAppResponse([
|
||||
{
|
||||
status: HttpStatus.OK,
|
||||
variants: [
|
||||
{
|
||||
messageExample: 'با موفقیت خارج شدید.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
variants: [
|
||||
{
|
||||
messageExample: 'دسترسی غیر مجاز',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
public async logout(
|
||||
@ActiveUser() user: User,
|
||||
@Body() logoutDto: LogoutDTO,
|
||||
@AccessToken() accessToken: string,
|
||||
) {
|
||||
return await this.authService.logout(user.id, logoutDto, accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GlobalAuthGuard } from './guards/global-auth.guard';
|
||||
import { RefreshTokensProvider } from './providers/refresh-tokens.provider';
|
||||
import { RedisModule } from '@/common/modules/redis/redis.module';
|
||||
import { AuthRedisProvider } from './providers/auth-redis.provider';
|
||||
import { LogoutProvider } from './providers/logout.provider';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -43,6 +44,7 @@ import { AuthRedisProvider } from './providers/auth-redis.provider';
|
||||
JwtStrategy,
|
||||
RefreshTokensProvider,
|
||||
AuthRedisProvider,
|
||||
LogoutProvider,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: GlobalAuthGuard,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { ExtractJwt } from 'passport-jwt';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export const AccessToken = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext): string | undefined => {
|
||||
const request: Request = ctx.switchToHttp().getRequest();
|
||||
|
||||
return ExtractJwt.fromAuthHeaderAsBearerToken()(request) ?? undefined;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LogoutDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
description: "User's Refresh Token",
|
||||
required: true,
|
||||
})
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
POST {{baseUrl}}/auth/logout/
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"refreshToken": "{{$global.refreshToken}}"
|
||||
}
|
||||
@@ -2,8 +2,8 @@ POST {{baseUrl}}/auth/verify-otp
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"phone": "09121212123",
|
||||
"otp": "26989"
|
||||
"phone": "09333026363",
|
||||
"otp": "23122"
|
||||
}
|
||||
|
||||
{{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface AccessTokenPayload {
|
||||
sub: number;
|
||||
phone: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
|
||||
@@ -21,4 +21,14 @@ export class AuthRedisProvider {
|
||||
async revokeRefreshToken(jti: string) {
|
||||
await this.redisService.del(`rt:${jti}`);
|
||||
}
|
||||
|
||||
async blacklistAccessToken(jti: string, ttl: number) {
|
||||
if (ttl <= 0) return;
|
||||
|
||||
await this.redisService.set(`at:bl:${jti}`, '1', ttl);
|
||||
}
|
||||
|
||||
async isAccessTokenBlacklisted(jti: string) {
|
||||
return !!(await this.redisService.get(`at:bl:${jti}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { VerifyOtpDTO } from '../dtos/verify-otp.dto';
|
||||
import { VerifyOTPProvider } from './verify-otp.provider';
|
||||
import { RefreshTokenDTO } from '../dtos/refresh-token.dto';
|
||||
import { RefreshTokensProvider } from './refresh-tokens.provider';
|
||||
import { LogoutDTO } from '../dtos/logout.dto';
|
||||
import { LogoutProvider } from './logout.provider';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -23,6 +25,11 @@ export class AuthService {
|
||||
* Inject RefreshToken Provider
|
||||
*/
|
||||
private readonly refreshTokensProvider: RefreshTokensProvider,
|
||||
|
||||
/**
|
||||
* Inject Logout Provider
|
||||
*/
|
||||
private readonly logoutProvider: LogoutProvider,
|
||||
) {}
|
||||
|
||||
public async logIn(loginDto: LoginDTO) {
|
||||
@@ -36,4 +43,12 @@ export class AuthService {
|
||||
public async refreshToken(refreshTokenDto: RefreshTokenDTO) {
|
||||
return await this.refreshTokensProvider.refreshTokens(refreshTokenDto);
|
||||
}
|
||||
|
||||
public async logout(
|
||||
userId: number,
|
||||
logoutDto: LogoutDTO,
|
||||
accessToken: string,
|
||||
) {
|
||||
return await this.logoutProvider.logout(userId, logoutDto, accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { User } from '@/modules/users/entities/user.entity';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
RefreshTokenPayload,
|
||||
@@ -51,13 +52,15 @@ export class GenerateTokenProvider {
|
||||
}
|
||||
|
||||
public async generateTokens(user: User, jti: string) {
|
||||
const accessJti = randomUUID();
|
||||
|
||||
const [access, refresh] = await Promise.all([
|
||||
// Sign Access Token
|
||||
this.signToken<Partial<AccessTokenPayload>>(
|
||||
user.id,
|
||||
this.jwtConfiguration.access.expiresIn,
|
||||
this.jwtConfiguration.access.secret,
|
||||
{ phone: user.phone },
|
||||
{ phone: user.phone, jti: accessJti },
|
||||
),
|
||||
|
||||
// Sign Refresh Token
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import jwtConfig from '@/config/jwt.config';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
RefreshTokenPayload,
|
||||
} from '../interfaces/jwt.interface';
|
||||
import { AuthRedisProvider } from './auth-redis.provider';
|
||||
import { LogoutDTO } from '../dtos/logout.dto';
|
||||
import { AppResponse } from '@/common/responses';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class LogoutProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject JwtService
|
||||
*/
|
||||
private readonly jwtService: JwtService,
|
||||
|
||||
/**
|
||||
* Inject jwtConfig
|
||||
*/
|
||||
@Inject(jwtConfig.KEY)
|
||||
private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
|
||||
|
||||
/**
|
||||
* Inject AuthRedis Provider
|
||||
*/
|
||||
private readonly authRedisProvider: AuthRedisProvider,
|
||||
|
||||
/**
|
||||
* Inject I18n Service
|
||||
*/
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
public async logout(
|
||||
userId: number,
|
||||
logoutDto: LogoutDTO,
|
||||
accessToken: string,
|
||||
) {
|
||||
if (!accessToken) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
const refreshPayload =
|
||||
await this.jwtService.verifyAsync<RefreshTokenPayload>(
|
||||
logoutDto.refreshToken,
|
||||
{
|
||||
secret: this.jwtConfiguration.refresh.secret,
|
||||
issuer: this.jwtConfiguration.issuer,
|
||||
audience: this.jwtConfiguration.audience,
|
||||
},
|
||||
);
|
||||
|
||||
if (refreshPayload.sub !== userId || !refreshPayload.jti) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
// The guard already validated this token; verify again to be safe
|
||||
const accessPayload = await this.jwtService.verifyAsync<AccessTokenPayload>(
|
||||
accessToken,
|
||||
{
|
||||
secret: this.jwtConfiguration.access.secret,
|
||||
issuer: this.jwtConfiguration.issuer,
|
||||
audience: this.jwtConfiguration.audience,
|
||||
},
|
||||
);
|
||||
|
||||
if (accessPayload.sub !== userId || !accessPayload.jti) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
// Only blacklist until the access token would have expired anyway
|
||||
const ttl = accessPayload.exp - Math.floor(Date.now() / 1000);
|
||||
|
||||
await Promise.all([
|
||||
this.authRedisProvider.revokeRefreshToken(refreshPayload.jti),
|
||||
this.authRedisProvider.blacklistAccessToken(accessPayload.jti, ttl),
|
||||
]);
|
||||
|
||||
const message = this.i18nService.translate('auth.messages.loggedOut');
|
||||
|
||||
return new AppResponse(null, message);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AccessTokenPayload } from '../interfaces/jwt.interface';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { UsersService } from '@/modules/users/providers/users.service';
|
||||
import { AuthRedisProvider } from '../providers/auth-redis.provider';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -20,6 +21,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
* Inject Users Service
|
||||
*/
|
||||
private readonly usersService: UsersService,
|
||||
|
||||
/**
|
||||
* Inject AuthRedis Provider
|
||||
*/
|
||||
private readonly authRedisProvider: AuthRedisProvider,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -31,6 +37,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
async validate(payload: AccessTokenPayload): Promise<User> {
|
||||
if (
|
||||
!payload.jti ||
|
||||
(await this.authRedisProvider.isAccessTokenBlacklisted(payload.jti))
|
||||
) {
|
||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findOneByPhone(payload.phone);
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ChatbotService } from './providers/chatbot.service';
|
||||
import type { Response } from 'express';
|
||||
import { ChatDTO } from './dtos/chat.dto';
|
||||
import { ActiveUser } from '@/common/decorators/active-user.decorator';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Controller('chatbot')
|
||||
export class ChatbotController {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Chatbot Service
|
||||
*/
|
||||
private readonly chatbotService: ChatbotService,
|
||||
) {}
|
||||
|
||||
@Post('chat')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
public async chat(@Body() chatDto: ChatDTO, @ActiveUser() user: User) {
|
||||
return this.chatbotService.chat(
|
||||
user,
|
||||
chatDto.message,
|
||||
chatDto.conversationId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('stream')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
public async streamChat(
|
||||
@Body() chatDto: ChatDTO,
|
||||
@ActiveUser() user: User,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
for await (const content of this.chatbotService.streamChat(
|
||||
user,
|
||||
chatDto.message,
|
||||
chatDto.conversationId,
|
||||
)) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
message: content,
|
||||
})}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatbotController } from './chatbot.controller';
|
||||
import { ChatbotService } from './providers/chatbot.service';
|
||||
import { ConversationModule } from '../conversation/conversation.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConversationModule],
|
||||
controllers: [ChatbotController],
|
||||
providers: [ChatbotService],
|
||||
})
|
||||
export class ChatbotModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const CHATBOT_SYSTEM_PROMPT = `
|
||||
You are a medical AI assistant named Heala (Or in persian: هیلا).
|
||||
|
||||
Your role is to provide general medical information
|
||||
and health education.
|
||||
|
||||
- Do not provide definitive diagnoses.
|
||||
- Do not prescribe medications or dosages.
|
||||
- Do not fabricate medical information.
|
||||
- If symptoms may indicate an emergency, recommend
|
||||
seeking urgent medical care.
|
||||
- Clearly communicate uncertainty when appropriate.
|
||||
- Answer in Persian when the user asks in Persian.
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||
|
||||
export class ChatDTO {
|
||||
@IsInt({
|
||||
message: t('validation.wrongFieldFormat', {
|
||||
field: '$t(chatbot.fields.conversationId)',
|
||||
}),
|
||||
})
|
||||
@IsOptional()
|
||||
conversationId?: number;
|
||||
|
||||
@IsString({
|
||||
message: t('validation.wrongFieldFormat', {
|
||||
field: '$t(chatbot.fields.message)',
|
||||
}),
|
||||
})
|
||||
@IsNotEmpty({
|
||||
message: t('validation.requiredField', {
|
||||
field: '$t(chatbot.fields.message)',
|
||||
}),
|
||||
})
|
||||
message!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
POST {{baseUrl}}/chatbot/chat
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟"
|
||||
}
|
||||
|
||||
# POST {{baseUrl}}/chatbot/stream/
|
||||
# Content-Type: application/json
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CHATBOT_SYSTEM_PROMPT } from '../constants';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { Conversation } from '@/modules/conversation/entities/conversation.entity';
|
||||
import { ConversationService } from '@/modules/conversation/providers/conversation.service';
|
||||
import { MessageRole } from '@/modules/conversation/enums/message-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class ChatbotService {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Config Service
|
||||
*/
|
||||
private readonly configService: ConfigService,
|
||||
|
||||
/**
|
||||
* Inject Conversation Service
|
||||
*/
|
||||
private readonly conversationService: ConversationService,
|
||||
) {}
|
||||
|
||||
private async getOpenRouter() {
|
||||
const { OpenRouter } = await import('@openrouter/sdk');
|
||||
|
||||
return new OpenRouter({
|
||||
apiKey: this.configService.getOrThrow<string>('openRouter.apiKey'),
|
||||
});
|
||||
}
|
||||
|
||||
private async getConversationMessages(
|
||||
user: User,
|
||||
message: string,
|
||||
conversationId?: number,
|
||||
) {
|
||||
let conversation: Conversation;
|
||||
|
||||
if (conversationId) {
|
||||
conversation = await this.conversationService.findConversationById(
|
||||
conversationId,
|
||||
user,
|
||||
);
|
||||
} else {
|
||||
conversation = await this.conversationService.createConversation(user);
|
||||
}
|
||||
|
||||
await this.conversationService.addMessage(
|
||||
conversation,
|
||||
MessageRole.USER,
|
||||
message,
|
||||
);
|
||||
|
||||
const messages = await this.conversationService.getMessages(conversation);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
messages: [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: CHATBOT_SYSTEM_PROMPT.trim(),
|
||||
},
|
||||
...messages.map((message) => ({
|
||||
role:
|
||||
message.role === MessageRole.USER
|
||||
? ('user' as const)
|
||||
: ('assistant' as const),
|
||||
content: message.content,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public async chat(user: User, message: string, conversationId?: number) {
|
||||
const { conversation, messages } = await this.getConversationMessages(
|
||||
user,
|
||||
message,
|
||||
conversationId,
|
||||
);
|
||||
|
||||
const openRouter = await this.getOpenRouter();
|
||||
|
||||
const response = await openRouter.chat.send({
|
||||
chatRequest: {
|
||||
model: this.configService.getOrThrow<string>('openRouter.model'),
|
||||
messages,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof ReadableStream) {
|
||||
throw new Error('Expected non-streaming response');
|
||||
}
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('OpenRouter returned an unexpected response');
|
||||
}
|
||||
|
||||
await this.conversationService.addMessage(
|
||||
conversation,
|
||||
MessageRole.ASSISTANT,
|
||||
content,
|
||||
);
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
message: content,
|
||||
};
|
||||
}
|
||||
|
||||
public async *streamChat(
|
||||
user: User,
|
||||
message: string,
|
||||
conversationId?: number,
|
||||
) {
|
||||
const { conversation, messages } = await this.getConversationMessages(
|
||||
user,
|
||||
message,
|
||||
conversationId,
|
||||
);
|
||||
|
||||
const openRouter = await this.getOpenRouter();
|
||||
|
||||
const response = await openRouter.chat.send({
|
||||
chatRequest: {
|
||||
model: this.configService.getOrThrow<string>('openRouter.model'),
|
||||
messages,
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
if ('choices' in response) {
|
||||
throw new Error('Expected streaming response');
|
||||
}
|
||||
|
||||
let assistantMessage = '';
|
||||
|
||||
for await (const chunk of response) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assistantMessage += content;
|
||||
|
||||
yield { conversationId: conversation.id, content };
|
||||
}
|
||||
|
||||
if (assistantMessage) {
|
||||
await this.conversationService.addMessage(
|
||||
conversation,
|
||||
MessageRole.ASSISTANT,
|
||||
assistantMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Conversation } from './entities/conversation.entity';
|
||||
import { Message } from './entities/message.entity';
|
||||
import { ConversationService } from './providers/conversation.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Conversation, Message])],
|
||||
providers: [ConversationService],
|
||||
exports: [ConversationService],
|
||||
})
|
||||
export class ConversationModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { Message } from './message.entity';
|
||||
|
||||
@Entity()
|
||||
export class Conversation extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
title!: string | null;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
@OneToMany(() => Message, (message) => message.conversation)
|
||||
messages!: Message[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { Conversation } from './conversation.entity';
|
||||
import { MessageRole } from '../enums/message-role.enum';
|
||||
|
||||
@Entity()
|
||||
export class Message extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@ManyToOne(() => Conversation, (conversation) => conversation.messages, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
conversation!: Conversation;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageRole,
|
||||
nullable: false,
|
||||
})
|
||||
role!: MessageRole;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ConversationType {
|
||||
AI = 'ai',
|
||||
DOCTOR = 'doctor',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
DOCTOR = 'doctor',
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Conversation } from '../entities/conversation.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Message } from '../entities/message.entity';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { MessageRole } from '../enums/message-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class ConversationService {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Conversation Repository
|
||||
*/
|
||||
@InjectRepository(Conversation)
|
||||
private readonly conversationRepository: Repository<Conversation>,
|
||||
|
||||
/**
|
||||
* Inject Message Repository
|
||||
*/
|
||||
@InjectRepository(Message)
|
||||
private readonly messageRepository: Repository<Message>,
|
||||
) {}
|
||||
|
||||
public async createConversation(
|
||||
user: User,
|
||||
title?: string,
|
||||
): Promise<Conversation> {
|
||||
const conversation = this.conversationRepository.create({
|
||||
user,
|
||||
title: title ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.conversationRepository.save(conversation);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
}
|
||||
|
||||
public async findConversationById(
|
||||
id: number,
|
||||
user: User,
|
||||
): Promise<Conversation> {
|
||||
let conversation: Conversation | null = null;
|
||||
|
||||
try {
|
||||
conversation = await this.conversationRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
throw new AppException(
|
||||
'common.errors.entityNotFound',
|
||||
HttpStatus.NOT_FOUND,
|
||||
{ entity: '$t(conversations.fields.conversation)' },
|
||||
);
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
public async addMessage(
|
||||
conversation: Conversation,
|
||||
role: MessageRole,
|
||||
content: string,
|
||||
): Promise<Message> {
|
||||
const message = this.messageRepository.create({
|
||||
conversation,
|
||||
role,
|
||||
content,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.messageRepository.save(message);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMessages(conversation: Conversation): Promise<Message[]> {
|
||||
let messages: Array<Message> = [];
|
||||
|
||||
try {
|
||||
messages = await this.messageRepository.find({
|
||||
where: {
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
@Index(['phone', 'used'])
|
||||
export class OTP {
|
||||
export class OTP extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { Question } from './question.entity';
|
||||
import { UNIQUE_QUESTION_ORDER_IDX } from '../constants';
|
||||
|
||||
@Entity()
|
||||
@Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true })
|
||||
export class Choice {
|
||||
export class Choice extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Question } from './question.entity';
|
||||
|
||||
@Entity()
|
||||
export class QuestionRangeRule {
|
||||
export class QuestionRangeRule extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Test } from '@/modules/tests/entities/test.entity';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Question } from './question.entity';
|
||||
import { QuestionTransitionType } from '../enums/question-transition-type.enum';
|
||||
import type { TransitionConditionType } from '../types/transition-condition.type';
|
||||
|
||||
@Entity()
|
||||
export class QuestionTransition {
|
||||
export class QuestionTransition extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { QuestionType } from '../enums/question-type.enum';
|
||||
import type { QuestionMetadata } from '../types/question-metadata.type';
|
||||
@@ -17,7 +18,7 @@ import { QuestionTransition } from './question-transition.entity';
|
||||
|
||||
@Entity()
|
||||
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
||||
export class Question {
|
||||
export class Question extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Test } from '@/modules/tests/entities/test.entity';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class ResultRange {
|
||||
export class ResultRange extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { Submission } from './submission.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
@@ -17,7 +18,7 @@ import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants';
|
||||
@Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], {
|
||||
unique: true,
|
||||
})
|
||||
export class SubmissionAnswer {
|
||||
export class SubmissionAnswer extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { Submission } from './submission.entity';
|
||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||
|
||||
@Entity()
|
||||
@Check(`"probability" >= 0 AND "probability" <= 100`)
|
||||
export class SubmissionResult {
|
||||
export class SubmissionResult extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||
import { SubmissionAnswer } from './submission-answer.entity';
|
||||
@@ -15,7 +16,7 @@ import { SubmissionResult } from './submission-result.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
|
||||
@Entity()
|
||||
export class Submission {
|
||||
export class Submission extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
GET {{baseUrl}}/submissions/6183e332-d28d-45ba-90a7-942f9d15100f/history
|
||||
GET {{baseUrl}}/submissions/1/history
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
GET {{baseUrl}}/submissions
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
POST {{baseUrl}}/submissions/start
|
||||
Content-Type: application/json
|
||||
# Authorization: Bearer {{$global.accessToken}}
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"testId": 3
|
||||
"testId": 1
|
||||
}
|
||||
|
||||
POST {{baseUrl}}/submissions/6183e332-d28d-45ba-90a7-942f9d15100f/answer
|
||||
POST {{baseUrl}}/submissions/2/answer
|
||||
Content-Type: application/json
|
||||
# Authorization: Bearer {{$global.accessToken}}
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"choiceId": 57
|
||||
// "numericValue": 10
|
||||
// "choiceIds": [45]
|
||||
"choiceId": 33
|
||||
// "numericValue": 1
|
||||
// "choiceIds": []
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
import { AccessType } from '../enums/access-types.enum';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
@@ -18,7 +19,7 @@ import { QuestionTransition } from '@/modules/questions/entities/question-transi
|
||||
OR
|
||||
("price" IS NOT NULL AND "accessType" = 'paid')
|
||||
`)
|
||||
export class Test {
|
||||
export class Test extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
GET {{baseUrl}}/tests
|
||||
|
||||
GET {{baseUrl}}/tests/4
|
||||
GET {{baseUrl}}/tests/1
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
BaseEntity,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
export class User extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user