feat: add admin js

This commit is contained in:
2026-10-07 16:34:11 +03:30
parent 195eca1015
commit ace4342870
23 changed files with 4573 additions and 132 deletions
+4182 -116
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -30,6 +30,9 @@
"seed:prod": "cross-env NODE_ENV=production node dist/database/seeds/seed.js" "seed:prod": "cross-env NODE_ENV=production node dist/database/seeds/seed.js"
}, },
"dependencies": { "dependencies": {
"@adminjs/express": "^5.1.0",
"@adminjs/nestjs": "^5.1.1",
"@adminjs/typeorm": "^4.0.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -41,10 +44,13 @@
"@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", "@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",
@@ -67,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",
+4
View File
@@ -26,7 +26,9 @@ import { ThrottlerModule } from '@nestjs/throttler';
import { LoggingModule } from './common/modules/logging/logging.module'; import { LoggingModule } from './common/modules/logging/logging.module';
import { ChatbotModule } from './modules/chatbot/chatbot.module'; import { ChatbotModule } from './modules/chatbot/chatbot.module';
import { ConversationModule } from './modules/conversation/conversation.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 openRouterConfig from './config/open-router.config';
import adminConfig from './config/admin.config';
const ENV = process.env.NODE_ENV; const ENV = process.env.NODE_ENV;
@@ -57,6 +59,7 @@ const ENV = process.env.NODE_ENV;
jwtConfig, jwtConfig,
redisConfig, redisConfig,
openRouterConfig, openRouterConfig,
adminConfig,
], ],
validationSchema: environmentValidation, validationSchema: environmentValidation,
}), }),
@@ -105,6 +108,7 @@ const ENV = process.env.NODE_ENV;
SubmissionsModule, SubmissionsModule,
ScoringModule, ScoringModule,
AdminModule, AdminModule,
AdminPanelModule,
LoggingModule, LoggingModule,
ChatbotModule, ChatbotModule,
ConversationModule, ConversationModule,
+10
View File
@@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('admin', () => ({
enabled: process.env.ADMIN_PANEL_ENABLED !== 'false',
rootPath: process.env.ADMIN_ROOT_PATH || '/admin',
cookiePassword: process.env.ADMIN_COOKIE_PASSWORD,
cookieName: 'heala.admin.sid',
sessionTtlMinutes: parseInt(process.env.ADMIN_SESSION_TTL_MINUTES || '120'),
secureCookies: process.env.NODE_ENV === 'production',
}));
+5
View File
@@ -25,4 +25,9 @@ export default Joi.object({
LOG_LEVEL: Joi.string().default('info'), LOG_LEVEL: Joi.string().default('info'),
OPENROUTER_API_KEY: Joi.string().required(), OPENROUTER_API_KEY: Joi.string().required(),
OPENROUTER_MODEL: 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),
}); });
+9
View File
@@ -6,6 +6,7 @@ import { ResponseInterceptor } from './common/interceptors/response.interceptor'
import helmet from 'helmet'; import helmet from 'helmet';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { AdminPanelSetup } from './modules/admin-panel/admin-panel.setup';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true }); const app = await NestFactory.create(AppModule, { bufferLogs: true });
@@ -14,6 +15,10 @@ async function bootstrap() {
app.setGlobalPrefix('v1'); app.setGlobalPrefix('v1');
// Mount the admin panel before Nest registers its body parsers; the setup
// itself is deferred until the DI container is ready (after app.init)
AdminPanelSetup.mount(app);
app.useGlobalPipes( app.useGlobalPipes(
new I18nValidationPipe({ new I18nValidationPipe({
whitelist: true, whitelist: true,
@@ -94,6 +99,10 @@ async function bootstrap() {
SwaggerModule.setup('v1/docs', app, document); SwaggerModule.setup('v1/docs', app, document);
await app.init();
AdminPanelSetup.setup(app);
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();
@@ -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,
};
}
}
@@ -7,11 +7,12 @@ import {
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Message } from './message.entity'; import { Message } from './message.entity';
@Entity() @Entity()
export class Conversation { export class Conversation extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -4,12 +4,13 @@ import {
Entity, Entity,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Conversation } from './conversation.entity'; import { Conversation } from './conversation.entity';
import { MessageRole } from '../enums/message-role.enum'; import { MessageRole } from '../enums/message-role.enum';
@Entity() @Entity()
export class Message { export class Message extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
+2 -1
View File
@@ -5,11 +5,12 @@ import {
Entity, Entity,
Index, Index,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
@Entity() @Entity()
@Index(['phone', 'used']) @Index(['phone', 'used'])
export class OTP { export class OTP extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -4,13 +4,14 @@ import {
Index, Index,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Question } from './question.entity'; import { Question } from './question.entity';
import { UNIQUE_QUESTION_ORDER_IDX } from '../constants'; import { UNIQUE_QUESTION_ORDER_IDX } from '../constants';
@Entity() @Entity()
@Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true }) @Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true })
export class Choice { export class Choice extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -1,8 +1,14 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Question } from './question.entity'; import { Question } from './question.entity';
@Entity() @Entity()
export class QuestionRangeRule { export class QuestionRangeRule extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -1,11 +1,17 @@
import { Test } from '@/modules/tests/entities/test.entity'; import { Test } from '@/modules/tests/entities/test.entity';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Question } from './question.entity'; import { Question } from './question.entity';
import { QuestionTransitionType } from '../enums/question-transition-type.enum'; import { QuestionTransitionType } from '../enums/question-transition-type.enum';
import type { TransitionConditionType } from '../types/transition-condition.type'; import type { TransitionConditionType } from '../types/transition-condition.type';
@Entity() @Entity()
export class QuestionTransition { export class QuestionTransition extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -7,6 +7,7 @@ import {
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { QuestionType } from '../enums/question-type.enum'; import { QuestionType } from '../enums/question-type.enum';
import type { QuestionMetadata } from '../types/question-metadata.type'; import type { QuestionMetadata } from '../types/question-metadata.type';
@@ -17,7 +18,7 @@ import { QuestionTransition } from './question-transition.entity';
@Entity() @Entity()
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true }) @Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
export class Question { export class Question extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -1,8 +1,14 @@
import { Test } from '@/modules/tests/entities/test.entity'; import { Test } from '@/modules/tests/entities/test.entity';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity() @Entity()
export class ResultRange { export class ResultRange extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -7,6 +7,7 @@ import {
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Submission } from './submission.entity'; import { Submission } from './submission.entity';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
@@ -17,7 +18,7 @@ import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants';
@Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], { @Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], {
unique: true, unique: true,
}) })
export class SubmissionAnswer { export class SubmissionAnswer extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -7,13 +7,14 @@ import {
ManyToOne, ManyToOne,
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { Submission } from './submission.entity'; import { Submission } from './submission.entity';
import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
@Entity() @Entity()
@Check(`"probability" >= 0 AND "probability" <= 100`) @Check(`"probability" >= 0 AND "probability" <= 100`)
export class SubmissionResult { export class SubmissionResult extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -8,6 +8,7 @@ import {
OneToMany, OneToMany,
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { SubmissionStatus } from '../enums/submission-status.enum'; import { SubmissionStatus } from '../enums/submission-status.enum';
import { SubmissionAnswer } from './submission-answer.entity'; import { SubmissionAnswer } from './submission-answer.entity';
@@ -15,7 +16,7 @@ import { SubmissionResult } from './submission-result.entity';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
@Entity() @Entity()
export class Submission { export class Submission extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
+2 -1
View File
@@ -5,6 +5,7 @@ import {
Entity, Entity,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
import { AccessType } from '../enums/access-types.enum'; import { AccessType } from '../enums/access-types.enum';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
@@ -18,7 +19,7 @@ import { QuestionTransition } from '@/modules/questions/entities/question-transi
OR OR
("price" IS NOT NULL AND "accessType" = 'paid') ("price" IS NOT NULL AND "accessType" = 'paid')
`) `)
export class Test { export class Test extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
+2 -1
View File
@@ -6,10 +6,11 @@ import {
Index, Index,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
BaseEntity,
} from 'typeorm'; } from 'typeorm';
@Entity() @Entity()
export class User { export class User extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;