Compare commits
2
Commits
f3fe1f55e4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace4342870 | ||
|
|
195eca1015 |
Generated
+4182
-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"
|
"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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}));
|
||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { RequirePasswordDTO } from './dtos/responses/require-password.dto';
|
|||||||
import { TokensResponseDTO } from './dtos/responses/tokens.dto';
|
import { TokensResponseDTO } from './dtos/responses/tokens.dto';
|
||||||
import { LogoutDTO } from './dtos/logout.dto';
|
import { LogoutDTO } from './dtos/logout.dto';
|
||||||
import { ActiveUser } from '@/common/decorators/active-user.decorator';
|
import { ActiveUser } from '@/common/decorators/active-user.decorator';
|
||||||
|
import { AccessToken } from './decorators/access-token.decorator';
|
||||||
import { User } from '@/modules/users/entities/user.entity';
|
import { User } from '@/modules/users/entities/user.entity';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -150,7 +151,11 @@ export class AuthController {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
public async logout(@ActiveUser() user: User, @Body() logoutDto: LogoutDTO) {
|
public async logout(
|
||||||
return await this.authService.logout(user.id, logoutDto);
|
@ActiveUser() user: User,
|
||||||
|
@Body() logoutDto: LogoutDTO,
|
||||||
|
@AccessToken() accessToken: string,
|
||||||
|
) {
|
||||||
|
return await this.authService.logout(user.id, logoutDto, accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
|
POST {{baseUrl}}/auth/logout/
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"refreshToken": "{{$global.refreshToken}}"
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ export class AuthService {
|
|||||||
return await this.refreshTokensProvider.refreshTokens(refreshTokenDto);
|
return await this.refreshTokensProvider.refreshTokens(refreshTokenDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(userId: number, logoutDto: LogoutDTO) {
|
public async logout(
|
||||||
return await this.logoutProvider.logout(userId, logoutDto);
|
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
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import type { ConfigType } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import jwtConfig from '@/config/jwt.config';
|
import jwtConfig from '@/config/jwt.config';
|
||||||
import { RefreshTokenPayload } from '../interfaces/jwt.interface';
|
import {
|
||||||
|
AccessTokenPayload,
|
||||||
|
RefreshTokenPayload,
|
||||||
|
} from '../interfaces/jwt.interface';
|
||||||
import { AuthRedisProvider } from './auth-redis.provider';
|
import { AuthRedisProvider } from './auth-redis.provider';
|
||||||
import { LogoutDTO } from '../dtos/logout.dto';
|
import { LogoutDTO } from '../dtos/logout.dto';
|
||||||
import { AppResponse } from '@/common/responses';
|
import { AppResponse } from '@/common/responses';
|
||||||
@@ -33,21 +36,50 @@ export class LogoutProvider {
|
|||||||
private readonly i18nService: I18nService,
|
private readonly i18nService: I18nService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async logout(userId: number, logoutDto: LogoutDTO) {
|
public async logout(
|
||||||
const payload = await this.jwtService.verifyAsync<RefreshTokenPayload>(
|
userId: number,
|
||||||
logoutDto.refreshToken,
|
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.refresh.secret,
|
secret: this.jwtConfiguration.access.secret,
|
||||||
issuer: this.jwtConfiguration.issuer,
|
issuer: this.jwtConfiguration.issuer,
|
||||||
audience: this.jwtConfiguration.audience,
|
audience: this.jwtConfiguration.audience,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (payload.sub !== userId || !payload.jti) {
|
if (accessPayload.sub !== userId || !accessPayload.jti) {
|
||||||
throw new UnauthorizedException('auth.errors.unauthorized');
|
throw new UnauthorizedException('auth.errors.unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.authRedisProvider.revokeRefreshToken(payload.jti);
|
// 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');
|
const message = this.i18nService.translate('auth.messages.loggedOut');
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user