forked from Shared/esg
initial commit
This commit is contained in:
43
src/app.module.ts
Normal file
43
src/app.module.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { AppConfigModule } from './config/config.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||
import { LoggingModule } from './logging/logging.module';
|
||||
import { ProvidersModule } from './providers/providers.module';
|
||||
import { InquiryModule } from './inquiry/inquiry.module';
|
||||
import { DatabaseSeederModule } from './database/database-seeder.module';
|
||||
|
||||
/**
|
||||
* Root application module — wires all feature and infrastructure modules.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
CommonModule,
|
||||
AuditModule,
|
||||
MongooseModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
uri: config.get<string>('mongodb.uri'),
|
||||
}),
|
||||
}),
|
||||
RateLimitModule,
|
||||
LoggingModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
DatabaseSeederModule,
|
||||
ProvidersModule,
|
||||
InquiryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
12
src/audit/audit.module.ts
Normal file
12
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: AuditLog.name, schema: AuditLogSchema }])],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
37
src/audit/audit.service.ts
Normal file
37
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { AuditEvent } from '../common/enums/audit-event.enum';
|
||||
import { AuditLog, AuditLogDocument } from './schemas/audit-log.schema';
|
||||
|
||||
export interface AuditContext {
|
||||
userId?: string;
|
||||
actorId?: string;
|
||||
username?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central audit writer — all security-sensitive actions flow through here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@InjectModel(AuditLog.name)
|
||||
private readonly auditModel: Model<AuditLogDocument>,
|
||||
) {}
|
||||
|
||||
async log(event: AuditEvent, context: AuditContext = {}): Promise<void> {
|
||||
await this.auditModel.create({
|
||||
event,
|
||||
userId: context.userId ? new Types.ObjectId(context.userId) : undefined,
|
||||
actorId: context.actorId ? new Types.ObjectId(context.actorId) : undefined,
|
||||
username: context.username,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: context.metadata ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
37
src/audit/schemas/audit-log.schema.ts
Normal file
37
src/audit/schemas/audit-log.schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { AuditEvent } from '../../common/enums/audit-event.enum';
|
||||
|
||||
export type AuditLogDocument = HydratedDocument<AuditLog>;
|
||||
|
||||
/**
|
||||
* Immutable security audit trail — separate from inquiry_logs (business audit).
|
||||
*/
|
||||
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'audit_logs' })
|
||||
export class AuditLog {
|
||||
@Prop({ required: true, enum: AuditEvent, index: true })
|
||||
event!: AuditEvent;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', index: true })
|
||||
userId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'User' })
|
||||
actorId?: Types.ObjectId;
|
||||
|
||||
@Prop()
|
||||
username?: string;
|
||||
|
||||
@Prop()
|
||||
ip?: string;
|
||||
|
||||
@Prop()
|
||||
userAgent?: string;
|
||||
|
||||
@Prop({ type: Object, default: {} })
|
||||
metadata!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const AuditLogSchema = SchemaFactory.createForClass(AuditLog);
|
||||
|
||||
AuditLogSchema.index({ createdAt: -1 });
|
||||
AuditLogSchema.index({ event: 1, createdAt: -1 });
|
||||
69
src/auth/auth.controller.ts
Normal file
69
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { AuthTokensDto } from './dto/auth-tokens.dto';
|
||||
import { ProfileDto } from './dto/profile.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AuthenticatedUser } from './interfaces/authenticated-user.interface';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with username and password' })
|
||||
@ApiResponse({ status: 200, type: AuthTokensDto })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
@ApiResponse({ status: 403, description: 'Inactive or blocked account' })
|
||||
async login(@Body() dto: LoginDto, @Req() req: Request): Promise<AuthTokensDto> {
|
||||
return this.authService.login(dto, {
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiResponse({ status: 200, type: AuthTokensDto })
|
||||
async refresh(@Body() dto: RefreshTokenDto, @Req() req: Request): Promise<AuthTokensDto> {
|
||||
return this.authService.refresh(dto.refreshToken, {
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
|
||||
@ApiResponse({ status: 200, description: 'Logged out successfully' })
|
||||
async logout(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Req() req: Request,
|
||||
): Promise<{ message: string }> {
|
||||
await this.authService.logout(user.id, {
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiResponse({ status: 200, type: ProfileDto })
|
||||
async profile(@CurrentUser() user: AuthenticatedUser): Promise<ProfileDto> {
|
||||
return this.authService.getProfile(user.id);
|
||||
}
|
||||
}
|
||||
54
src/auth/auth.module.ts
Normal file
54
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PasswordService } from './services/password.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
import { InquiryAccessGuard } from './guards/inquiry-access.guard';
|
||||
import { ApiKeyGuard } from './guards/api-key.guard';
|
||||
|
||||
/**
|
||||
* Authentication & authorization — JWT, RBAC, inquiry access.
|
||||
* ApiKeyGuard retained for future M2M auth alongside JWT.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt-access' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('auth.jwtSecret'),
|
||||
}),
|
||||
}),
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TokenService,
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
InquiryAccessGuard,
|
||||
ApiKeyGuard,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TokenService,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
InquiryAccessGuard,
|
||||
ApiKeyGuard,
|
||||
PassportModule,
|
||||
JwtModule,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
158
src/auth/auth.service.ts
Normal file
158
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuditEvent } from '../common/enums/audit-event.enum';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UsersService, RequestContext } from '../users/users.service';
|
||||
import { PasswordService } from './services/password.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthTokensDto } from './dto/auth-tokens.dto';
|
||||
import { ProfileDto } from './dto/profile.dto';
|
||||
import { UserMapper } from '../users/mappers/user.mapper';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
import { AuthenticatedUser } from './interfaces/authenticated-user.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly passwordService: PasswordService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto, context: RequestContext = {}): Promise<AuthTokensDto> {
|
||||
const user = await this.usersService.findByUsername(dto.username, true);
|
||||
|
||||
if (!user) {
|
||||
await this.auditService.log(AuditEvent.LOGIN_FAILED, {
|
||||
username: dto.username,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { reason: 'invalid_credentials' },
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context);
|
||||
throw new ForbiddenException('Account is inactive');
|
||||
}
|
||||
|
||||
if (user.isBlocked) {
|
||||
await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context);
|
||||
throw new ForbiddenException('Account is blocked');
|
||||
}
|
||||
|
||||
const valid = await this.passwordService.compare(dto.password, user.password);
|
||||
if (!valid) {
|
||||
await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context);
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.usersService.saveRefreshToken(user._id.toString(), tokens.refreshToken);
|
||||
await this.usersService.recordLogin(user._id.toString());
|
||||
|
||||
await this.auditService.log(AuditEvent.LOGIN, {
|
||||
userId: user._id.toString(),
|
||||
username: user.username,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
});
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, context: RequestContext = {}): Promise<AuthTokensDto> {
|
||||
let payload: { sub: string };
|
||||
try {
|
||||
payload = this.tokenService.verifyRefreshToken(refreshToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user.isActive) {
|
||||
throw new ForbiddenException('Account is inactive');
|
||||
}
|
||||
if (user.isBlocked) {
|
||||
throw new ForbiddenException('Account is blocked');
|
||||
}
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.usersService.saveRefreshToken(user._id.toString(), tokens.refreshToken);
|
||||
|
||||
await this.auditService.log(AuditEvent.LOGIN, {
|
||||
userId: user._id.toString(),
|
||||
username: user.username,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { type: 'refresh' },
|
||||
});
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async logout(userId: string, context: RequestContext = {}): Promise<void> {
|
||||
await this.usersService.clearRefreshToken(userId);
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
await this.auditService.log(AuditEvent.LOGOUT, {
|
||||
userId,
|
||||
username: user.username,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string): Promise<ProfileDto> {
|
||||
const user = await this.usersService.findById(userId);
|
||||
return UserMapper.toProfileDto(user);
|
||||
}
|
||||
|
||||
private async issueTokens(user: {
|
||||
_id: { toString(): string };
|
||||
role: JwtPayload['role'];
|
||||
clientType: JwtPayload['clientType'];
|
||||
appName: string;
|
||||
}): Promise<AuthTokensDto> {
|
||||
const payload: JwtPayload = {
|
||||
sub: user._id.toString(),
|
||||
role: user.role,
|
||||
clientType: user.clientType,
|
||||
appName: user.appName,
|
||||
};
|
||||
|
||||
const pair = this.tokenService.generateTokenPair(payload);
|
||||
return {
|
||||
accessToken: pair.accessToken,
|
||||
refreshToken: pair.refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 900,
|
||||
};
|
||||
}
|
||||
|
||||
private async auditFailedLogin(
|
||||
userId: string,
|
||||
username: string,
|
||||
reason: string,
|
||||
context: RequestContext,
|
||||
): Promise<void> {
|
||||
await this.auditService.log(AuditEvent.LOGIN_FAILED, {
|
||||
userId,
|
||||
username,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { reason },
|
||||
});
|
||||
}
|
||||
}
|
||||
4
src/auth/constants/auth.constants.ts
Normal file
4
src/auth/constants/auth.constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const INQUIRY_ACCESS_KEY = 'inquiryAccess';
|
||||
export const JWT_ACCESS_STRATEGY = 'jwt-access';
|
||||
export const JWT_REFRESH_STRATEGY = 'jwt-refresh';
|
||||
13
src/auth/decorators/current-user.decorator.ts
Normal file
13
src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
|
||||
|
||||
/**
|
||||
* Injects the authenticated user from the request (set by JwtStrategy).
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||
const request = ctx.switchToHttp().getRequest<Request & { user: AuthenticatedUser }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
10
src/auth/decorators/inquiry-access.decorator.ts
Normal file
10
src/auth/decorators/inquiry-access.decorator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants';
|
||||
|
||||
/**
|
||||
* Declares which inquiry type an endpoint requires.
|
||||
* InquiryAccessGuard reads this metadata and checks user.allowedInquiries.
|
||||
*/
|
||||
export const InquiryAccess = (inquiryType: InquiryType) =>
|
||||
SetMetadata(INQUIRY_ACCESS_KEY, inquiryType);
|
||||
6
src/auth/decorators/roles.decorator.ts
Normal file
6
src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
import { ROLES_KEY } from '../constants/auth.constants';
|
||||
|
||||
/** Restrict route to one or more RBAC roles. Use with RolesGuard. */
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
15
src/auth/dto/auth-tokens.dto.ts
Normal file
15
src/auth/dto/auth-tokens.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AuthTokensDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty({ example: 'Bearer' })
|
||||
tokenType!: string;
|
||||
|
||||
@ApiProperty({ example: 900, description: 'Access token lifetime in seconds' })
|
||||
expiresIn!: number;
|
||||
}
|
||||
15
src/auth/dto/login.dto.ts
Normal file
15
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'admin' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ example: 'SecureP@ssw0rd' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
62
src/auth/dto/profile.dto.ts
Normal file
62
src/auth/dto/profile.dto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ClientType } from '../../common/enums/client-type.enum';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
|
||||
export class ProfileDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
fullName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty()
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
mobile?: string;
|
||||
|
||||
@ApiProperty({ enum: Role })
|
||||
role!: Role;
|
||||
|
||||
@ApiProperty({ enum: ClientType })
|
||||
clientType!: ClientType;
|
||||
|
||||
@ApiProperty()
|
||||
appName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
clientName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
isActive!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
isBlocked!: boolean;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
allowedInquiries!: string[];
|
||||
|
||||
@ApiProperty()
|
||||
requestLimitPerMinute!: number;
|
||||
|
||||
@ApiProperty()
|
||||
requestLimitPerDay!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalRequests!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
lastLoginAt?: Date;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
passwordChangedAt?: Date;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
9
src/auth/dto/refresh-token.dto.ts
Normal file
9
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: 'Refresh token from login response' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
}
|
||||
34
src/auth/guards/api-key.guard.ts
Normal file
34
src/auth/guards/api-key.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { API_KEY_HEADER } from '../../common/constants/app.constants';
|
||||
|
||||
/**
|
||||
* API key authentication for inquiry endpoints.
|
||||
* Expects x-api-key header matching configured API_KEY.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const apiKey = request.headers[API_KEY_HEADER] as string | undefined;
|
||||
const expected = this.configService.get<string>('auth.apiKey');
|
||||
|
||||
if (!expected) {
|
||||
throw new UnauthorizedException('API key authentication is not configured');
|
||||
}
|
||||
|
||||
if (!apiKey || apiKey !== expected) {
|
||||
throw new UnauthorizedException('Invalid or missing API key');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
45
src/auth/guards/inquiry-access.guard.ts
Normal file
45
src/auth/guards/inquiry-access.guard.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { ADMIN_ROLES } from '../../common/enums/role.enum';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants';
|
||||
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
|
||||
|
||||
/**
|
||||
* Validates that the user may call a specific inquiry endpoint.
|
||||
* ADMIN / SUPER_ADMIN bypass — external USER clients need allowedInquiries.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InquiryAccessGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredInquiry = this.reflector.getAllAndOverride<InquiryType>(INQUIRY_ACCESS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredInquiry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user: AuthenticatedUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
|
||||
if (ADMIN_ROLES.includes(user.role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const inquiryKey = requiredInquiry as string;
|
||||
if (!user.allowedInquiries.includes(inquiryKey)) {
|
||||
throw new ForbiddenException(`Access denied for inquiry: ${inquiryKey}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
21
src/auth/guards/jwt-auth.guard.ts
Normal file
21
src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants';
|
||||
|
||||
/**
|
||||
* Protects routes with Passport JWT access strategy.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard(JWT_ACCESS_STRATEGY) {
|
||||
override handleRequest<TUser>(
|
||||
err: Error | null,
|
||||
user: TUser | false,
|
||||
_info: unknown,
|
||||
_context: ExecutionContext,
|
||||
): TUser {
|
||||
if (err || !user) {
|
||||
throw err ?? new UnauthorizedException('Unauthorized');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
34
src/auth/guards/roles.guard.ts
Normal file
34
src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { ROLES_KEY } from '../constants/auth.constants';
|
||||
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
|
||||
/**
|
||||
* Enforces @Roles() metadata against the authenticated user's role.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user: AuthenticatedUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !requiredRoles.includes(user.role)) {
|
||||
throw new ForbiddenException('Insufficient role permissions');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
18
src/auth/interfaces/authenticated-user.interface.ts
Normal file
18
src/auth/interfaces/authenticated-user.interface.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ClientType } from '../../common/enums/client-type.enum';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
|
||||
/**
|
||||
* User object attached to the request after JWT validation.
|
||||
* Subset of the User document — avoids loading sensitive fields on every request.
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
clientType: ClientType;
|
||||
appName: string;
|
||||
allowedInquiries: string[];
|
||||
requestLimitPerMinute: number;
|
||||
requestLimitPerDay: number;
|
||||
}
|
||||
13
src/auth/interfaces/jwt-payload.interface.ts
Normal file
13
src/auth/interfaces/jwt-payload.interface.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ClientType } from '../../common/enums/client-type.enum';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
|
||||
/**
|
||||
* Claims embedded in access tokens.
|
||||
* Keep minimal — load full user from DB when needed.
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
role: Role;
|
||||
clientType: ClientType;
|
||||
appName: string;
|
||||
}
|
||||
21
src/auth/services/password.service.ts
Normal file
21
src/auth/services/password.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* Password hashing — single responsibility for bcrypt operations.
|
||||
* Future: argon2, password history, complexity rules.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async hash(plain: string): Promise<string> {
|
||||
const rounds = this.configService.get<number>('auth.bcryptSaltRounds', 12);
|
||||
return bcrypt.hash(plain, rounds);
|
||||
}
|
||||
|
||||
async compare(plain: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
}
|
||||
50
src/auth/services/token.service.ts
Normal file
50
src/auth/services/token.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT issuance — access and refresh tokens with separate secrets.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
generateAccessToken(payload: JwtPayload): string {
|
||||
return this.jwtService.sign(payload as object, {
|
||||
secret: this.configService.getOrThrow<string>('auth.jwtSecret'),
|
||||
expiresIn: this.configService.get<string>('auth.jwtAccessExpiresIn', '15m') as `${number}m`,
|
||||
});
|
||||
}
|
||||
|
||||
generateRefreshToken(payload: Pick<JwtPayload, 'sub'>): string {
|
||||
return this.jwtService.sign(
|
||||
{ sub: payload.sub },
|
||||
{
|
||||
secret: this.configService.getOrThrow<string>('auth.jwtRefreshSecret'),
|
||||
expiresIn: this.configService.get<string>('auth.jwtRefreshExpiresIn', '7d') as `${number}d`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
generateTokenPair(payload: JwtPayload): TokenPair {
|
||||
return {
|
||||
accessToken: this.generateAccessToken(payload),
|
||||
refreshToken: this.generateRefreshToken({ sub: payload.sub }),
|
||||
};
|
||||
}
|
||||
|
||||
verifyRefreshToken(token: string): Pick<JwtPayload, 'sub'> {
|
||||
return this.jwtService.verify(token, {
|
||||
secret: this.configService.getOrThrow<string>('auth.jwtRefreshSecret'),
|
||||
});
|
||||
}
|
||||
}
|
||||
48
src/auth/strategies/jwt.strategy.ts
Normal file
48
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants';
|
||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
/**
|
||||
* Validates access JWT and attaches a slim user object to the request.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, JWT_ACCESS_STRATEGY) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.getOrThrow<string>('auth.jwtSecret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
if (user.isBlocked) {
|
||||
throw new UnauthorizedException('Account is blocked');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
clientType: user.clientType,
|
||||
appName: user.appName,
|
||||
allowedInquiries: user.allowedInquiries,
|
||||
requestLimitPerMinute: user.requestLimitPerMinute,
|
||||
requestLimitPerDay: user.requestLimitPerDay,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
src/cli/cli.module.ts
Normal file
26
src/cli/cli.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppConfigModule } from '../config/config.module';
|
||||
import { PasswordService } from '../auth/services/password.service';
|
||||
import { User, UserSchema } from '../users/schemas/user.schema';
|
||||
import { CreateSuperAdminService } from './create-super-admin.service';
|
||||
|
||||
/**
|
||||
* Minimal CLI context — only DB + password hashing.
|
||||
* Does not load AuthModule (AuthService, JWT, AuditService, etc.).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
MongooseModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
uri: config.get<string>('mongodb.uri'),
|
||||
}),
|
||||
}),
|
||||
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||
],
|
||||
providers: [CreateSuperAdminService, PasswordService],
|
||||
})
|
||||
export class CliModule {}
|
||||
66
src/cli/create-super-admin.service.ts
Normal file
66
src/cli/create-super-admin.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ConflictException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { ClientType } from '../common/enums/client-type.enum';
|
||||
import { Role } from '../common/enums/role.enum';
|
||||
import { PasswordService } from '../auth/services/password.service';
|
||||
import { User, UserDocument } from '../users/schemas/user.schema';
|
||||
import { UserMapper } from '../users/mappers/user.mapper';
|
||||
import { UserResponseDto } from '../users/dto/user-response.dto';
|
||||
|
||||
export interface CreateSuperAdminInput {
|
||||
fullName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CreateSuperAdminService {
|
||||
private readonly logger = new Logger(CreateSuperAdminService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||
private readonly passwordService: PasswordService,
|
||||
) {}
|
||||
|
||||
async create(input: CreateSuperAdminInput): Promise<UserResponseDto> {
|
||||
if (input.password.length < 8) {
|
||||
throw new ConflictException('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const username = input.username.toLowerCase().trim();
|
||||
const email = input.email.toLowerCase().trim();
|
||||
|
||||
const existing = await this.userModel.findOne({ $or: [{ username }, { email }] });
|
||||
if (existing) {
|
||||
throw new ConflictException('Username or email already exists');
|
||||
}
|
||||
|
||||
const superAdminExists = await this.userModel.exists({ role: Role.SUPER_ADMIN });
|
||||
if (superAdminExists) {
|
||||
this.logger.warn('A SUPER_ADMIN already exists; creating another.');
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordService.hash(input.password);
|
||||
|
||||
const user = await this.userModel.create({
|
||||
fullName: input.fullName.trim(),
|
||||
username,
|
||||
email,
|
||||
password: passwordHash,
|
||||
role: Role.SUPER_ADMIN,
|
||||
clientType: ClientType.INTERNAL,
|
||||
appName: 'inquiry-gateway',
|
||||
clientName: 'Internal',
|
||||
isActive: true,
|
||||
isBlocked: false,
|
||||
allowedInquiries: [],
|
||||
apiKey: `esg_${randomBytes(32).toString('hex')}`,
|
||||
passwordChangedAt: new Date(),
|
||||
});
|
||||
|
||||
return UserMapper.toResponseDto(user);
|
||||
}
|
||||
}
|
||||
72
src/cli/create-super-admin.ts
Normal file
72
src/cli/create-super-admin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* CLI entry point — creates the initial SUPER_ADMIN user.
|
||||
* Run: npm run cli:create-super-admin
|
||||
*/
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import * as readline from 'readline';
|
||||
import { CliModule } from './cli.module';
|
||||
import { CreateSuperAdminService } from './create-super-admin.service';
|
||||
|
||||
function prompt(rl: readline.Interface, question: string, hidden = false): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
if (!hidden) {
|
||||
rl.question(question, resolve);
|
||||
return;
|
||||
}
|
||||
const stdin = process.stdin;
|
||||
const onData = (char: Buffer): void => {
|
||||
const c = char.toString('utf8');
|
||||
switch (c) {
|
||||
case '\n':
|
||||
case '\r':
|
||||
case '\u0004':
|
||||
stdin.pause();
|
||||
break;
|
||||
default:
|
||||
process.stdout.write('\x1B[2K\x1B[200D' + question + '*'.repeat(rl.line.length));
|
||||
break;
|
||||
}
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
rl.question(question, (answer) => {
|
||||
stdin.removeListener('data', onData);
|
||||
process.stdout.write('\n');
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.createApplicationContext(CliModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
const service = app.get(CreateSuperAdminService);
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
try {
|
||||
console.log('\n=== Create Super Admin ===\n');
|
||||
|
||||
const fullName = (await prompt(rl, 'Full name: ')).trim();
|
||||
const username = (await prompt(rl, 'Username: ')).trim();
|
||||
const email = (await prompt(rl, 'Email: ')).trim();
|
||||
const password = (await prompt(rl, 'Password (min 8 chars): ', true)).trim();
|
||||
|
||||
rl.close();
|
||||
|
||||
const user = await service.create({ fullName, username, email, password });
|
||||
console.log('\nSuper admin created successfully.');
|
||||
console.log(` ID: ${user.id}`);
|
||||
console.log(` Username: ${user.username}`);
|
||||
console.log(` Email: ${user.email}`);
|
||||
console.log(` Role: ${user.role}\n`);
|
||||
} catch (error) {
|
||||
rl.close();
|
||||
console.error('\nFailed:', error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
20
src/common/common.module.ts
Normal file
20
src/common/common.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
import { RequestIdInterceptor } from './interceptors/request-id.interceptor';
|
||||
|
||||
/**
|
||||
* Shared cross-cutting concerns: filters, interceptors, helpers.
|
||||
* Marked @Global so feature modules need not re-import utilities.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
{ provide: APP_INTERCEPTOR, useClass: RequestIdInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||
],
|
||||
exports: [],
|
||||
})
|
||||
export class CommonModule {}
|
||||
3
src/common/constants/app.constants.ts
Normal file
3
src/common/constants/app.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
export const API_KEY_HEADER = 'x-api-key';
|
||||
export const TRACKING_CODE_PREFIX = 'INQ';
|
||||
8
src/common/decorators/request-id.decorator.ts
Normal file
8
src/common/decorators/request-id.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const RequestId = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest<{ requestId?: string }>();
|
||||
return request.requestId ?? 'unknown';
|
||||
},
|
||||
);
|
||||
29
src/common/dto/base-inquiry-response.dto.ts
Normal file
29
src/common/dto/base-inquiry-response.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NormalizedErrorDto } from './normalized-error.dto';
|
||||
|
||||
/**
|
||||
* Unified inquiry response envelope.
|
||||
* Every inquiry endpoint MUST return this structure.
|
||||
*/
|
||||
export class BaseInquiryResponseDto<T = Record<string, unknown>> {
|
||||
@ApiProperty({ example: true })
|
||||
success!: boolean;
|
||||
|
||||
@ApiProperty({ example: 'HAMTA' })
|
||||
provider!: string;
|
||||
|
||||
@ApiProperty({ example: 'INQ-20250525-ABC123' })
|
||||
trackingCode!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Inquiry completed successfully' })
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
data?: T;
|
||||
|
||||
@ApiPropertyOptional({ type: NormalizedErrorDto })
|
||||
error?: NormalizedErrorDto;
|
||||
|
||||
@ApiProperty({ example: 342, description: 'Duration in milliseconds' })
|
||||
duration!: number;
|
||||
}
|
||||
19
src/common/dto/normalized-error.dto.ts
Normal file
19
src/common/dto/normalized-error.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Standardized error shape returned to API consumers
|
||||
* regardless of upstream provider error formats.
|
||||
*/
|
||||
export class NormalizedErrorDto {
|
||||
@ApiProperty({ example: 'PROVIDER_TIMEOUT' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 'Provider request timed out' })
|
||||
message!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'سرویس در دسترس نیست' })
|
||||
providerMessage?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '503' })
|
||||
providerCode?: string;
|
||||
}
|
||||
10
src/common/enums/audit-event.enum.ts
Normal file
10
src/common/enums/audit-event.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Security-relevant events written to the audit log collection. */
|
||||
export enum AuditEvent {
|
||||
LOGIN = 'LOGIN',
|
||||
LOGIN_FAILED = 'LOGIN_FAILED',
|
||||
LOGOUT = 'LOGOUT',
|
||||
USER_CREATED = 'USER_CREATED',
|
||||
PASSWORD_RESET = 'PASSWORD_RESET',
|
||||
USER_BLOCKED = 'USER_BLOCKED',
|
||||
USER_UNBLOCKED = 'USER_UNBLOCKED',
|
||||
}
|
||||
8
src/common/enums/client-type.enum.ts
Normal file
8
src/common/enums/client-type.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Distinguishes internal operators from external API consumers.
|
||||
* Used in JWT payload and for future multi-tenant / provider routing rules.
|
||||
*/
|
||||
export enum ClientType {
|
||||
INTERNAL = 'INTERNAL',
|
||||
EXTERNAL = 'EXTERNAL',
|
||||
}
|
||||
5
src/common/enums/inquiry-status.enum.ts
Normal file
5
src/common/enums/inquiry-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum InquiryStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE',
|
||||
PARTIAL = 'PARTIAL',
|
||||
}
|
||||
13
src/common/enums/inquiry-type.enum.ts
Normal file
13
src/common/enums/inquiry-type.enum.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Canonical inquiry types exposed via unified gateway endpoints.
|
||||
* Add new types here as new POST /inquiries/* routes are introduced.
|
||||
*/
|
||||
export enum InquiryType {
|
||||
PERSON = 'PERSON_INQUIRY',
|
||||
REAL_ESTATE = 'REAL_ESTATE_INQUIRY',
|
||||
SHEBA = 'SHEBA_INQUIRY',
|
||||
SHAHKAR = 'SHAHKAR_INQUIRY',
|
||||
POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
|
||||
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
|
||||
CAR_PLATE = 'CAR_PLATE_INQUIRY',
|
||||
}
|
||||
10
src/common/enums/provider-name.enum.ts
Normal file
10
src/common/enums/provider-name.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Registered provider identifiers.
|
||||
* Used by factory, configuration, and response normalization.
|
||||
*/
|
||||
export enum ProviderName {
|
||||
HAMTA = 'HAMTA',
|
||||
MOALLEM = 'MOALLEM',
|
||||
TEJARATNOU = 'TEJARATNOU',
|
||||
AMITIS = 'AMITIS',
|
||||
}
|
||||
12
src/common/enums/role.enum.ts
Normal file
12
src/common/enums/role.enum.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Role-based access control (RBAC) roles for the unified users system.
|
||||
* SUPER_ADMIN > ADMIN > USER in privilege hierarchy.
|
||||
*/
|
||||
export enum Role {
|
||||
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||
ADMIN = 'ADMIN',
|
||||
USER = 'USER',
|
||||
}
|
||||
|
||||
/** Roles allowed to manage other users (create, block, reset password). */
|
||||
export const ADMIN_ROLES: Role[] = [Role.SUPER_ADMIN, Role.ADMIN];
|
||||
12
src/common/exceptions/inquiry.exception.ts
Normal file
12
src/common/exceptions/inquiry.exception.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
|
||||
export class InquiryException extends HttpException {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly normalizedError: NormalizedErrorDto,
|
||||
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
) {
|
||||
super({ message, error: normalizedError }, status);
|
||||
}
|
||||
}
|
||||
11
src/common/exceptions/provider.exception.ts
Normal file
11
src/common/exceptions/provider.exception.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
|
||||
export class ProviderException extends HttpException {
|
||||
constructor(
|
||||
public readonly normalizedError: NormalizedErrorDto,
|
||||
status: HttpStatus = HttpStatus.BAD_GATEWAY,
|
||||
) {
|
||||
super(normalizedError, status);
|
||||
}
|
||||
}
|
||||
77
src/common/filters/all-exceptions.filter.ts
Normal file
77
src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
|
||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
import { ProviderException } from '../exceptions/provider.exception';
|
||||
|
||||
/**
|
||||
* Global exception filter — normalizes all errors into BaseInquiryResponseDto
|
||||
* when request context includes tracking metadata.
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request & { trackingCode?: string }>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const normalizedError = this.extractNormalizedError(exception);
|
||||
const trackingCode = request.trackingCode ?? 'UNKNOWN';
|
||||
|
||||
this.logger.error(
|
||||
`requestId=${request.headers['x-request-id']} | trackingCode=${trackingCode} | ${normalizedError.message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
const body: BaseInquiryResponseDto = {
|
||||
success: false,
|
||||
provider: 'GATEWAY',
|
||||
trackingCode,
|
||||
message: normalizedError.message,
|
||||
error: normalizedError,
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
response.status(status).json(body);
|
||||
}
|
||||
|
||||
private extractNormalizedError(exception: unknown): NormalizedErrorDto {
|
||||
if (exception instanceof ProviderException) {
|
||||
return exception.normalizedError;
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const res = exception.getResponse();
|
||||
if (typeof res === 'object' && res !== null && 'error' in res) {
|
||||
return (res as { error: NormalizedErrorDto }).error;
|
||||
}
|
||||
const message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (res as { message?: string | string[] }).message;
|
||||
return {
|
||||
code: 'HTTP_ERROR',
|
||||
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: exception instanceof Error ? exception.message : 'Unexpected error',
|
||||
};
|
||||
}
|
||||
}
|
||||
132
src/common/helpers/jalali-date.helper.ts
Normal file
132
src/common/helpers/jalali-date.helper.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
const JALALI_BREAKS = [
|
||||
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097,
|
||||
2192, 2262, 2324, 2394, 2456, 3178,
|
||||
];
|
||||
|
||||
export function jalaliDateToGregorianDate(jalaliDate: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(jalaliDate);
|
||||
if (!match) {
|
||||
throw new Error('birthDate must be a Jalali date in YYYY-MM-DD format');
|
||||
}
|
||||
|
||||
const jy = Number(match[1]);
|
||||
const jm = Number(match[2]);
|
||||
const jd = Number(match[3]);
|
||||
|
||||
if (!isValidJalaliDate(jy, jm, jd)) {
|
||||
throw new Error('birthDate is not a valid Jalali date');
|
||||
}
|
||||
|
||||
const { gy, gm, gd } = jalaliToGregorian(jy, jm, jd);
|
||||
return `${gy}-${pad2(gm)}-${pad2(gd)}`;
|
||||
}
|
||||
|
||||
function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
|
||||
if (jm < 1 || jm > 12 || jd < 1) return false;
|
||||
if (jm <= 6) return jd <= 31;
|
||||
if (jm <= 11) return jd <= 30;
|
||||
return jd <= (isJalaliLeapYear(jy) ? 30 : 29);
|
||||
}
|
||||
|
||||
function isJalaliLeapYear(jy: number): boolean {
|
||||
return jalCal(jy).leap === 0;
|
||||
}
|
||||
|
||||
function jalaliToGregorian(
|
||||
jy: number,
|
||||
jm: number,
|
||||
jd: number,
|
||||
): { gy: number; gm: number; gd: number } {
|
||||
const gy = jalCal(jy).gy;
|
||||
const march = jalCal(jy).march;
|
||||
const daysInGregorianMonth = [
|
||||
31,
|
||||
isGregorianLeapYear(gy) ? 29 : 28,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
|
||||
let dayOfYear = jd - 1;
|
||||
if (jm <= 6) {
|
||||
dayOfYear += (jm - 1) * 31;
|
||||
} else {
|
||||
dayOfYear += 186 + (jm - 7) * 30;
|
||||
}
|
||||
|
||||
let gd = march + dayOfYear;
|
||||
let gm = 3;
|
||||
let targetGy = gy;
|
||||
|
||||
while (gd > daysInGregorianMonth[gm - 1]) {
|
||||
gd -= daysInGregorianMonth[gm - 1];
|
||||
gm += 1;
|
||||
|
||||
if (gm > 12) {
|
||||
gm = 1;
|
||||
targetGy += 1;
|
||||
daysInGregorianMonth[1] = isGregorianLeapYear(targetGy) ? 29 : 28;
|
||||
}
|
||||
}
|
||||
|
||||
return { gy: targetGy, gm, gd };
|
||||
}
|
||||
|
||||
function jalCal(jy: number): { leap: number; gy: number; march: number } {
|
||||
const breaksLength = JALALI_BREAKS.length;
|
||||
const gy = jy + 621;
|
||||
let leapJ = -14;
|
||||
let jp = JALALI_BREAKS[0];
|
||||
let jump = 0;
|
||||
|
||||
if (jy < jp || jy >= JALALI_BREAKS[breaksLength - 1]) {
|
||||
throw new Error('Jalali year is out of supported range');
|
||||
}
|
||||
|
||||
for (let i = 1; i < breaksLength; i += 1) {
|
||||
const jm = JALALI_BREAKS[i];
|
||||
jump = jm - jp;
|
||||
if (jy < jm) break;
|
||||
leapJ += div(jump, 33) * 8 + div(mod(jump, 33), 4);
|
||||
jp = jm;
|
||||
}
|
||||
|
||||
let n = jy - jp;
|
||||
leapJ += div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
|
||||
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
|
||||
|
||||
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
|
||||
const march = 20 + leapJ - leapG;
|
||||
|
||||
if (jump - n < 6) {
|
||||
n = n - jump + div(jump + 4, 33) * 33;
|
||||
}
|
||||
|
||||
let leap = mod(mod(n + 1, 33) - 1, 4);
|
||||
if (leap === -1) leap = 4;
|
||||
|
||||
return { leap, gy, march };
|
||||
}
|
||||
|
||||
function isGregorianLeapYear(year: number): boolean {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
function div(a: number, b: number): number {
|
||||
return Math.floor(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number): number {
|
||||
return a - Math.floor(a / b) * b;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
33
src/common/helpers/mask-payload.helper.ts
Normal file
33
src/common/helpers/mask-payload.helper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const SENSITIVE_KEYS = [
|
||||
'password',
|
||||
'secret',
|
||||
'secretKey',
|
||||
'token',
|
||||
'authorization',
|
||||
'nationalCode',
|
||||
];
|
||||
|
||||
/**
|
||||
* Masks sensitive fields before persisting inquiry logs.
|
||||
*/
|
||||
export function maskPayload<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
): Record<string, unknown> {
|
||||
return maskObject({ ...payload }) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function maskObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k.toLowerCase()))) {
|
||||
result[key] = '***MASKED***';
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
result[key] = maskObject(value as Record<string, unknown>);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
51
src/common/helpers/request-logger.helper.ts
Normal file
51
src/common/helpers/request-logger.helper.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export interface RequestLogContext {
|
||||
requestId: string;
|
||||
trackingCode?: string;
|
||||
provider?: string;
|
||||
inquiryType?: string;
|
||||
durationMs?: number;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured console logging for inquiry lifecycle events.
|
||||
* Complements MongoDB persistence in LoggingModule.
|
||||
*/
|
||||
export class RequestLogger {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(context: string) {
|
||||
this.logger = new Logger(context);
|
||||
}
|
||||
|
||||
logStart(ctx: RequestLogContext, message: string): void {
|
||||
this.logger.log(this.format(ctx, message));
|
||||
}
|
||||
|
||||
logSuccess(ctx: RequestLogContext, message: string): void {
|
||||
this.logger.log(this.format({ ...ctx, success: true }, message));
|
||||
}
|
||||
|
||||
logFailure(ctx: RequestLogContext, message: string, error?: unknown): void {
|
||||
this.logger.error(
|
||||
this.format({ ...ctx, success: false }, message),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private format(ctx: RequestLogContext, message: string): string {
|
||||
const parts = [
|
||||
`requestId=${ctx.requestId}`,
|
||||
ctx.trackingCode ? `trackingCode=${ctx.trackingCode}` : null,
|
||||
ctx.provider ? `provider=${ctx.provider}` : null,
|
||||
ctx.inquiryType ? `inquiryType=${ctx.inquiryType}` : null,
|
||||
ctx.durationMs !== undefined ? `durationMs=${ctx.durationMs}` : null,
|
||||
ctx.success !== undefined ? `success=${ctx.success}` : null,
|
||||
`msg=${message}`,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
}
|
||||
66
src/common/helpers/retry.helper.ts
Normal file
66
src/common/helpers/retry.helper.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface RetryOptions {
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
backoffMultiplier?: number;
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
|
||||
if (error && typeof error === 'object') {
|
||||
// Check for network error codes
|
||||
if ('code' in error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNRESET') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTTP 500 errors (server errors that might be transient)
|
||||
if ('response' in error) {
|
||||
const response = (error as { response?: { status?: number } }).response;
|
||||
if (response?.status && response.status >= 500 && response.status < 600) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic async retry with exponential backoff.
|
||||
* Used by BaseProvider for transient network failures.
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions,
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts,
|
||||
delayMs,
|
||||
backoffMultiplier = 2,
|
||||
shouldRetry = DEFAULT_SHOULD_RETRY,
|
||||
} = options;
|
||||
|
||||
let lastError: unknown;
|
||||
let currentDelay = delayMs;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const isLastAttempt = attempt === maxAttempts;
|
||||
if (isLastAttempt || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(currentDelay);
|
||||
currentDelay *= backoffMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
25
src/common/helpers/timeout.helper.ts
Normal file
25
src/common/helpers/timeout.helper.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Races an async operation against a timeout.
|
||||
* Throws a distinguishable error for BaseProvider normalization.
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label = 'Operation',
|
||||
): Promise<T> {
|
||||
let timeoutHandle: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
||||
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle!);
|
||||
}
|
||||
}
|
||||
11
src/common/helpers/tracking-code.helper.ts
Normal file
11
src/common/helpers/tracking-code.helper.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { TRACKING_CODE_PREFIX } from '../constants/app.constants';
|
||||
|
||||
/**
|
||||
* Generates a unique, human-readable tracking code per inquiry.
|
||||
*/
|
||||
export function generateTrackingCode(): string {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = randomBytes(4).toString('hex').toUpperCase();
|
||||
return `${TRACKING_CODE_PREFIX}-${date}-${suffix}`;
|
||||
}
|
||||
43
src/common/interceptors/logging.interceptor.ts
Normal file
43
src/common/interceptors/logging.interceptor.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { RequestLogger } from '../helpers/request-logger.helper';
|
||||
|
||||
/**
|
||||
* Logs HTTP request duration at the controller boundary.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly requestLogger = new RequestLogger(LoggingInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
method: string;
|
||||
url: string;
|
||||
requestId?: string;
|
||||
}>();
|
||||
const start = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
this.requestLogger.logSuccess(
|
||||
{ requestId: req.requestId ?? 'unknown' },
|
||||
`${req.method} ${req.url} completed in ${Date.now() - start}ms`,
|
||||
);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.requestLogger.logFailure(
|
||||
{ requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start },
|
||||
`${req.method} ${req.url} failed`,
|
||||
err,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/common/interceptors/request-id.interceptor.ts
Normal file
31
src/common/interceptors/request-id.interceptor.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { REQUEST_ID_HEADER } from '../constants/app.constants';
|
||||
|
||||
/**
|
||||
* Ensures every request has a unique request ID for tracing.
|
||||
* Propagates ID via response header and request object.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestIdInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<Record<string, unknown>>();
|
||||
const response = http.getResponse<{ setHeader: (k: string, v: string) => void }>();
|
||||
|
||||
const incoming =
|
||||
(request.headers as Record<string, string | undefined>)?.[REQUEST_ID_HEADER];
|
||||
const requestId = incoming ?? uuidv4();
|
||||
|
||||
request.requestId = requestId;
|
||||
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
21
src/common/interceptors/response-transform.interceptor.ts
Normal file
21
src/common/interceptors/response-transform.interceptor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
|
||||
|
||||
/**
|
||||
* Ensures inquiry endpoints always emit BaseInquiryResponseDto shape.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResponseTransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
map((data: BaseInquiryResponseDto) => data),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/common/interfaces/inquiry-provider.interface.ts
Normal file
22
src/common/interfaces/inquiry-provider.interface.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { InquiryType } from '../enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../enums/provider-name.enum';
|
||||
|
||||
/**
|
||||
* Contract every external provider adapter must fulfill.
|
||||
* Keeps orchestration logic decoupled from provider implementations (Strategy pattern).
|
||||
*/
|
||||
export interface InquiryProvider<TRequest = unknown, TResponse = unknown> {
|
||||
readonly name: ProviderName;
|
||||
readonly supportedInquiryTypes: InquiryType[];
|
||||
isEnabled(): boolean;
|
||||
execute(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<TResponse>;
|
||||
}
|
||||
|
||||
export interface ProviderExecutionContext {
|
||||
requestId: string;
|
||||
trackingCode: string;
|
||||
}
|
||||
19
src/common/middleware/request-id.middleware.ts
Normal file
19
src/common/middleware/request-id.middleware.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { REQUEST_ID_HEADER } from '../constants/app.constants';
|
||||
|
||||
/**
|
||||
* Early middleware assignment of request ID (runs before guards).
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
const incoming = req.headers[REQUEST_ID_HEADER] as string | undefined;
|
||||
const requestId = incoming ?? uuidv4();
|
||||
|
||||
(req as Request & { requestId: string }).requestId = requestId;
|
||||
res.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
19
src/config/config.module.ts
Normal file
19
src/config/config.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule as NestConfigModule } from '@nestjs/config';
|
||||
import configuration from './configuration';
|
||||
|
||||
/**
|
||||
* Global configuration — single source of truth for env-based settings.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
NestConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
],
|
||||
exports: [NestConfigModule],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
190
src/config/configuration.ts
Normal file
190
src/config/configuration.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../common/enums/provider-name.enum';
|
||||
|
||||
export type AuthMethod = 'AMITIS' | 'SOAP' | 'OAUTH2' | 'NONE';
|
||||
|
||||
export interface InquiryConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
authMethod: AuthMethod;
|
||||
}
|
||||
|
||||
export interface ProviderEnvConfig {
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
maxRetries: number;
|
||||
inquiries: Partial<Record<InquiryType, InquiryConfig>>;
|
||||
}
|
||||
|
||||
export interface AmitisAuthServiceConfig {
|
||||
baseUrl: string;
|
||||
loginPath: string;
|
||||
refreshPath: string;
|
||||
tokenTimeZone: string;
|
||||
timeout: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TejaratNouConfig {
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
maxRetries: number;
|
||||
authUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
username: string;
|
||||
password: string;
|
||||
inquiries: Partial<Record<InquiryType, InquiryConfig>>;
|
||||
}
|
||||
|
||||
export interface InquiryRoutingConfig {
|
||||
defaultProvider: ProviderName;
|
||||
fallbackEnabled: boolean;
|
||||
fallbackProviders: ProviderName[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Central configuration factory — maps environment variables to typed config.
|
||||
* Provider routing is deployment-specific and lives under inquiryRouting.
|
||||
*/
|
||||
export default () => ({
|
||||
app: {
|
||||
port: parseInt(process.env.PORT ?? '8085', 10),
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
},
|
||||
mongodb: {
|
||||
uri: process.env.MONGODB_URI ?? 'mongodb://localhost:27017/inquiry-gateway',
|
||||
},
|
||||
auth: {
|
||||
apiKey: process.env.API_KEY ?? '',
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'change-me-in-production',
|
||||
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET ?? process.env.JWT_SECRET ?? 'change-me-refresh',
|
||||
jwtAccessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
|
||||
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
|
||||
jwtEnabled: process.env.JWT_ENABLED !== 'false',
|
||||
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS ?? '12', 10),
|
||||
},
|
||||
throttle: {
|
||||
ttl: parseInt(process.env.THROTTLE_TTL ?? '60', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT ?? '100', 10),
|
||||
},
|
||||
amitis: {
|
||||
baseUrl: process.env.AMITIS_BASE_URL ?? 'https://auth.services.centinsur.ir',
|
||||
loginPath: process.env.AMITIS_LOGIN_PATH ?? '/api/security/login',
|
||||
refreshPath: process.env.AMITIS_REFRESH_PATH ?? '/api/security/RefreshToken',
|
||||
tokenTimeZone: process.env.AMITIS_TOKEN_TIME_ZONE ?? 'Asia/Tehran',
|
||||
timeout: parseInt(process.env.AMITIS_TIMEOUT ?? '10000', 10),
|
||||
enabled: process.env.AMITIS_ENABLED !== 'false',
|
||||
} satisfies AmitisAuthServiceConfig,
|
||||
hamta: buildProviderConfig('HAMTA'),
|
||||
moallem: buildProviderConfig('MOALLEM'),
|
||||
tejaratnou: {
|
||||
enabled: process.env.TEJARATNOU_ENABLED !== 'false',
|
||||
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),
|
||||
maxRetries: parseInt(process.env.TEJARATNOU_MAX_RETRIES ?? '2', 10),
|
||||
authUrl: process.env.TEJARATNOU_AUTH_URL ?? 'https://accounts.tejaratnoins.ir',
|
||||
clientId: process.env.TEJARATNOU_CLIENT_ID ?? 'api-gateway',
|
||||
clientSecret: process.env.TEJARATNOU_CLIENT_SECRET ?? '',
|
||||
username: process.env.TEJARATNOU_USERNAME ?? '',
|
||||
password: process.env.TEJARATNOU_PASSWORD ?? '',
|
||||
inquiries: {
|
||||
[InquiryType.PERSON]: buildInquiryConfig('TEJARATNOU', 'PERSON'),
|
||||
},
|
||||
} satisfies TejaratNouConfig,
|
||||
inquiryRouting: {
|
||||
[InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.TEJARATNOU, []),
|
||||
[InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [
|
||||
ProviderName.MOALLEM,
|
||||
]),
|
||||
[InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.MOALLEM, [
|
||||
ProviderName.HAMTA,
|
||||
]),
|
||||
[InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.MOALLEM, [
|
||||
ProviderName.HAMTA,
|
||||
]),
|
||||
[InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [
|
||||
ProviderName.HAMTA,
|
||||
]),
|
||||
[InquiryType.LEGAL_PERSON]: buildInquiryRoutingConfig('LEGAL_PERSON', ProviderName.HAMTA, [
|
||||
ProviderName.MOALLEM,
|
||||
]),
|
||||
[InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []),
|
||||
} satisfies Record<InquiryType, InquiryRoutingConfig>,
|
||||
});
|
||||
|
||||
function buildProviderConfig(prefix: string): ProviderEnvConfig {
|
||||
const inquiryTypes = [
|
||||
InquiryType.PERSON,
|
||||
InquiryType.REAL_ESTATE,
|
||||
InquiryType.SHAHKAR,
|
||||
InquiryType.SHEBA,
|
||||
InquiryType.POSTAL_CODE,
|
||||
InquiryType.LEGAL_PERSON,
|
||||
InquiryType.CAR_PLATE,
|
||||
];
|
||||
|
||||
const inquiries: Partial<Record<InquiryType, InquiryConfig>> = {};
|
||||
|
||||
for (const inquiryType of inquiryTypes) {
|
||||
const inquiryConfig = buildInquiryConfig(prefix, inquiryTypeToEnvName(inquiryType));
|
||||
if (inquiryConfig.url || inquiryConfig.username) {
|
||||
inquiries[inquiryType] = inquiryConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: process.env[`${prefix}_ENABLED`] !== 'false',
|
||||
timeout: parseInt(process.env[`${prefix}_TIMEOUT`] ?? '10000', 10),
|
||||
maxRetries: parseInt(process.env[`${prefix}_MAX_RETRIES`] ?? '2', 10),
|
||||
inquiries,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInquiryConfig(providerPrefix: string, inquiryPrefix: string): InquiryConfig {
|
||||
return {
|
||||
url: process.env[`${providerPrefix}_${inquiryPrefix}_URL`] ?? '',
|
||||
username: process.env[`${providerPrefix}_${inquiryPrefix}_USERNAME`] ?? '',
|
||||
password: process.env[`${providerPrefix}_${inquiryPrefix}_PASSWORD`] ?? '',
|
||||
authMethod: (process.env[`${providerPrefix}_${inquiryPrefix}_AUTH_METHOD`] ?? 'NONE') as AuthMethod,
|
||||
};
|
||||
}
|
||||
|
||||
function inquiryTypeToEnvName(inquiryType: InquiryType): string {
|
||||
const mapping: Record<InquiryType, string> = {
|
||||
[InquiryType.PERSON]: 'PERSON',
|
||||
[InquiryType.REAL_ESTATE]: 'REAL_ESTATE',
|
||||
[InquiryType.SHAHKAR]: 'SHAHKAR',
|
||||
[InquiryType.SHEBA]: 'SHEBA',
|
||||
[InquiryType.POSTAL_CODE]: 'POSTAL_CODE',
|
||||
[InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON',
|
||||
[InquiryType.CAR_PLATE]: 'CAR_PLATE',
|
||||
};
|
||||
return mapping[inquiryType];
|
||||
}
|
||||
|
||||
function buildInquiryRoutingConfig(
|
||||
prefix: string,
|
||||
defaultProvider: ProviderName,
|
||||
fallbackProviders: ProviderName[],
|
||||
): InquiryRoutingConfig {
|
||||
return {
|
||||
defaultProvider: (process.env[`${prefix}_DEFAULT_PROVIDER`] ?? defaultProvider) as ProviderName,
|
||||
fallbackEnabled: parseBoolean(process.env[`${prefix}_FALLBACK_ENABLED`], false),
|
||||
fallbackProviders: parseProviderList(
|
||||
process.env[`${prefix}_FALLBACK_PROVIDERS`] ?? fallbackProviders.join(','),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProviderList(value: string): ProviderName[] {
|
||||
if (!value.trim()) return [];
|
||||
return value.split(',').map((p) => p.trim() as ProviderName);
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined, defaultValue: boolean): boolean {
|
||||
if (value === undefined) return defaultValue;
|
||||
return value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
98
src/console-instrumentation.ts
Normal file
98
src/console-instrumentation.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import "@opentelemetry/api-logs";
|
||||
import loggerProvider from "./logger";
|
||||
|
||||
// Get a logger instance
|
||||
const logger = loggerProvider.getLogger("default", "1.0.0");
|
||||
|
||||
// Store original console methods
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
// Map severity levels
|
||||
const SeverityNumber = {
|
||||
DEBUG: 5,
|
||||
INFO: 9,
|
||||
WARN: 13,
|
||||
ERROR: 17,
|
||||
};
|
||||
|
||||
// Override console methods
|
||||
console.log = function (...args: any[]) {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
|
||||
.join(" ");
|
||||
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.INFO,
|
||||
severityText: "INFO",
|
||||
body: message,
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
originalConsole.log.apply(console, args);
|
||||
};
|
||||
|
||||
console.info = function (...args: any[]) {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
|
||||
.join(" ");
|
||||
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.INFO,
|
||||
severityText: "INFO",
|
||||
body: message,
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
originalConsole.info.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = function (...args: any[]) {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
|
||||
.join(" ");
|
||||
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.WARN,
|
||||
severityText: "WARN",
|
||||
body: message,
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
originalConsole.warn.apply(console, args);
|
||||
};
|
||||
|
||||
console.error = function (...args: any[]) {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
|
||||
.join(" ");
|
||||
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.ERROR,
|
||||
severityText: "ERROR",
|
||||
body: message,
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
originalConsole.error.apply(console, args);
|
||||
};
|
||||
|
||||
console.debug = function (...args: any[]) {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
|
||||
.join(" ");
|
||||
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.DEBUG,
|
||||
severityText: "DEBUG",
|
||||
body: message,
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
originalConsole.debug.apply(console, args);
|
||||
};
|
||||
10
src/database/database-seeder.module.ts
Normal file
10
src/database/database-seeder.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { User, UserSchema } from '../users/schemas/user.schema';
|
||||
import { DatabaseSeederService } from './database-seeder.service';
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
||||
providers: [DatabaseSeederService],
|
||||
})
|
||||
export class DatabaseSeederModule {}
|
||||
158
src/database/database-seeder.service.ts
Normal file
158
src/database/database-seeder.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { User, UserDocument } from '../users/schemas/user.schema';
|
||||
|
||||
type ExtendedJsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| ExtendedJsonValue[]
|
||||
| { [key: string]: ExtendedJsonValue };
|
||||
|
||||
interface SeedUser {
|
||||
_id?: Types.ObjectId;
|
||||
fullName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
mobile?: string;
|
||||
password: string;
|
||||
role: string;
|
||||
clientType: string;
|
||||
appName: string;
|
||||
clientName: string;
|
||||
isActive: boolean;
|
||||
isBlocked: boolean;
|
||||
apiKey?: string;
|
||||
allowedInquiries: string[];
|
||||
requestLimitPerMinute: number;
|
||||
requestLimitPerDay: number;
|
||||
totalRequests?: number;
|
||||
passwordChangedAt?: Date;
|
||||
createdBy?: Types.ObjectId;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastLoginAt?: Date;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseSeederService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DatabaseSeederService.name);
|
||||
private readonly usersSeedFile = 'inquiry-gateway.users.json';
|
||||
|
||||
constructor(@InjectModel(User.name) private readonly userModel: Model<UserDocument>) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.seedUsers();
|
||||
}
|
||||
|
||||
private async seedUsers(): Promise<void> {
|
||||
const users = this.loadSeedUsers();
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const user of users) {
|
||||
const existing = await this.userModel.exists({
|
||||
$or: [{ _id: user._id }, { username: user.username }, { email: user.email }],
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.userModel.create(user);
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
if (inserted > 0) {
|
||||
this.logger.log(`Seeded ${inserted} baseline user(s); skipped ${skipped} existing user(s).`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Baseline users already exist; skipped ${skipped} user(s).`);
|
||||
}
|
||||
|
||||
private loadSeedUsers(): SeedUser[] {
|
||||
const seedPath = this.resolveSeedPath();
|
||||
const raw = readFileSync(seedPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as ExtendedJsonValue;
|
||||
const normalized = this.normalizeExtendedJson(parsed);
|
||||
|
||||
if (!Array.isArray(normalized)) {
|
||||
throw new Error(`${this.usersSeedFile} must contain an array of users`);
|
||||
}
|
||||
|
||||
return normalized.map((user) => {
|
||||
if (!this.isSeedUser(user)) {
|
||||
throw new Error(`${this.usersSeedFile} contains an invalid user document`);
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
username: user.username.toLowerCase().trim(),
|
||||
email: user.email.toLowerCase().trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private resolveSeedPath(): string {
|
||||
const candidates = [
|
||||
join(__dirname, '..', 'public', this.usersSeedFile),
|
||||
join(process.cwd(), 'src', 'public', this.usersSeedFile),
|
||||
];
|
||||
|
||||
const seedPath = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!seedPath) {
|
||||
throw new Error(`Unable to find seed file: ${this.usersSeedFile}`);
|
||||
}
|
||||
|
||||
return seedPath;
|
||||
}
|
||||
|
||||
private normalizeExtendedJson(value: ExtendedJsonValue): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.normalizeExtendedJson(item));
|
||||
}
|
||||
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value.$oid === 'string') {
|
||||
return new Types.ObjectId(value.$oid);
|
||||
}
|
||||
|
||||
if (typeof value.$date === 'string') {
|
||||
return new Date(value.$date);
|
||||
}
|
||||
|
||||
return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, child]) => {
|
||||
acc[key] = this.normalizeExtendedJson(child);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private isSeedUser(value: unknown): value is SeedUser {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = value as Partial<SeedUser>;
|
||||
return (
|
||||
typeof user.fullName === 'string' &&
|
||||
typeof user.username === 'string' &&
|
||||
typeof user.email === 'string' &&
|
||||
typeof user.password === 'string' &&
|
||||
typeof user.role === 'string' &&
|
||||
typeof user.clientType === 'string' &&
|
||||
Array.isArray(user.allowedInquiries) &&
|
||||
typeof user.requestLimitPerMinute === 'number' &&
|
||||
typeof user.requestLimitPerDay === 'number'
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/inquiry/dto/civil-registration-request.dto.ts
Normal file
24
src/inquiry/dto/civil-registration-request.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class CivilRegistrationRequestDto {
|
||||
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
|
||||
nationalCode!: string;
|
||||
|
||||
@ApiProperty({ example: '1370-01-01', description: 'Birth date as YYYY-MM-DD or YYYYMMDD' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^\d{4}-?\d{2}-?\d{2}$/, {
|
||||
message: 'birthDate must be in YYYY-MM-DD or YYYYMMDD format',
|
||||
})
|
||||
birthDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
dateHasPostfix?: number;
|
||||
}
|
||||
45
src/inquiry/dto/civil-registration-response.dto.ts
Normal file
45
src/inquiry/dto/civil-registration-response.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CivilRegistrationResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
NIN?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
BirthDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
FirstName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
LastName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
FatherName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
SSN?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Gender?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
DeathStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
DeathDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
PostalCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
PostalCodeDescription?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
ExceptionMessage?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Message?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Error?: string;
|
||||
}
|
||||
25
src/inquiry/dto/generic-inquiry-response.dto.ts
Normal file
25
src/inquiry/dto/generic-inquiry-response.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
|
||||
export class GenericInquiryResponseDto {
|
||||
@ApiProperty()
|
||||
success!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
provider!: string;
|
||||
|
||||
@ApiProperty()
|
||||
trackingCode!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: Object })
|
||||
data?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ type: NormalizedErrorDto })
|
||||
error?: NormalizedErrorDto;
|
||||
|
||||
@ApiProperty()
|
||||
duration!: number;
|
||||
}
|
||||
12
src/inquiry/dto/person-inquiry-data.dto.ts
Normal file
12
src/inquiry/dto/person-inquiry-data.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PersonInquiryDataDto {
|
||||
@ApiProperty({ example: '0012345678' })
|
||||
nationalCode!: string;
|
||||
|
||||
@ApiProperty({ example: '1370-05-15' })
|
||||
birthDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'علی محمدی' })
|
||||
fullName?: string;
|
||||
}
|
||||
21
src/inquiry/dto/person-inquiry-request.dto.ts
Normal file
21
src/inquiry/dto/person-inquiry-request.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class PersonInquiryRequestDto {
|
||||
@ApiProperty({ example: '0012345678', description: 'Iranian national code (10 digits)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
|
||||
nationalCode!: string;
|
||||
|
||||
@ApiProperty({ example: '1378-11-24', description: 'Jalali birth date (YYYY-MM-DD)' })
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthDate must be in YYYY-MM-DD format' })
|
||||
@IsNotEmpty()
|
||||
birthDate!: string;
|
||||
|
||||
@ApiProperty({ example: 0, required: false, description: 'Civil registration date postfix flag' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
dateHasPostfix?: number;
|
||||
}
|
||||
27
src/inquiry/dto/person-inquiry-response.dto.ts
Normal file
27
src/inquiry/dto/person-inquiry-response.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import { PersonInquiryDataDto } from './person-inquiry-data.dto';
|
||||
|
||||
/** Swagger concrete type for person inquiry responses */
|
||||
export class PersonInquiryResponseDto {
|
||||
@ApiProperty()
|
||||
success!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
provider!: string;
|
||||
|
||||
@ApiProperty()
|
||||
trackingCode!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: PersonInquiryDataDto })
|
||||
data?: PersonInquiryDataDto;
|
||||
|
||||
@ApiPropertyOptional({ type: NormalizedErrorDto })
|
||||
error?: NormalizedErrorDto;
|
||||
|
||||
@ApiProperty()
|
||||
duration!: number;
|
||||
}
|
||||
11
src/inquiry/dto/postal-code-request.dto.ts
Normal file
11
src/inquiry/dto/postal-code-request.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class PostalCodeRequestDto {
|
||||
@ApiProperty({ example: '1234567890', description: 'Postal code' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d{10}$/, { message: 'postalCode must be exactly 10 digits' })
|
||||
postalCode!: string;
|
||||
}
|
||||
57
src/inquiry/dto/postal-code-response.dto.ts
Normal file
57
src/inquiry/dto/postal-code-response.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PostalCodeResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
Province?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
TownShip?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Zone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Village?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
LocalityType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
LocalityName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
LocalityCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
SubLocality?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Street?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Street2?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
HouseNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Floor?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
SideFloor?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
BuildingName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
Description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
ErrorCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
ErrorMessage?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
TraceID?: string;
|
||||
}
|
||||
27
src/inquiry/dto/sayah-request.dto.ts
Normal file
27
src/inquiry/dto/sayah-request.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class SayahRequestDto {
|
||||
@ApiProperty({ example: '0', description: '0 for natural person' })
|
||||
@IsString()
|
||||
@IsIn(['0'])
|
||||
accountOwnerType!: string;
|
||||
|
||||
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d{10}$/, { message: 'nationalId must be exactly 10 digits' })
|
||||
nationalId!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
legalId?: string | null;
|
||||
|
||||
@ApiProperty({ example: 'IR000000000000000000000000', description: 'Iranian Sheba ID' })
|
||||
@IsString()
|
||||
@Length(26, 26)
|
||||
@Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' })
|
||||
shebaId!: string;
|
||||
}
|
||||
12
src/inquiry/dto/sayah-response.dto.ts
Normal file
12
src/inquiry/dto/sayah-response.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class SayahResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
ReturnValue?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
HasError?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Object, nullable: true })
|
||||
Errors?: Record<string, string> | null;
|
||||
}
|
||||
17
src/inquiry/dto/shahkar-inquiry-request.dto.ts
Normal file
17
src/inquiry/dto/shahkar-inquiry-request.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class ShahkarInquiryRequestDto {
|
||||
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
|
||||
nationalCode!: string;
|
||||
|
||||
@ApiProperty({ example: '09226187419', description: 'Iranian mobile number' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^09\d{9}$/, { message: 'mobileNo must be an Iranian mobile number' })
|
||||
mobileNo!: string;
|
||||
}
|
||||
17
src/inquiry/dto/shahkar-request.dto.ts
Normal file
17
src/inquiry/dto/shahkar-request.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class ShahkarRequestDto {
|
||||
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
|
||||
nationalCode!: string;
|
||||
|
||||
@ApiProperty({ example: '09226187419', description: 'Iranian mobile number' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^09\d{9}$/, { message: 'mobileNo must be an Iranian mobile number' })
|
||||
mobileNo!: string;
|
||||
}
|
||||
21
src/inquiry/dto/shahkar-response.dto.ts
Normal file
21
src/inquiry/dto/shahkar-response.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ShahkarResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
response?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
requestId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
result?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
comment?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
ErrorNams?: string;
|
||||
}
|
||||
140
src/inquiry/inquiry.controller.ts
Normal file
140
src/inquiry/inquiry.controller.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InquiryAccessGuard } from '../auth/guards/inquiry-access.guard';
|
||||
import { InquiryAccess } from '../auth/decorators/inquiry-access.decorator';
|
||||
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
||||
import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
|
||||
import { ResponseTransformInterceptor } from '../common/interceptors/response-transform.interceptor';
|
||||
import { UserRateLimitGuard } from '../rate-limit/user-rate-limit.guard';
|
||||
import { PersonInquiryRequestDto } from './dto/person-inquiry-request.dto';
|
||||
import { PersonInquiryDataDto } from './dto/person-inquiry-data.dto';
|
||||
import { PersonInquiryResponseDto } from './dto/person-inquiry-response.dto';
|
||||
import { GenericInquiryResponseDto } from './dto/generic-inquiry-response.dto';
|
||||
import { PostalCodeRequestDto } from './dto/postal-code-request.dto';
|
||||
import { ShahkarRequestDto } from './dto/shahkar-request.dto';
|
||||
import { SayahRequestDto } from './dto/sayah-request.dto';
|
||||
import { InquiryService } from './inquiry.service';
|
||||
|
||||
/**
|
||||
* Unified inquiry endpoints — protected by JWT, inquiry RBAC, and rate limits.
|
||||
*/
|
||||
@ApiTags('Inquiries')
|
||||
@ApiBearerAuth()
|
||||
@Controller('inquiry')
|
||||
@UseGuards(JwtAuthGuard, InquiryAccessGuard, UserRateLimitGuard)
|
||||
@UseInterceptors(ResponseTransformInterceptor)
|
||||
export class InquiryController {
|
||||
constructor(private readonly inquiryService: InquiryService) {}
|
||||
|
||||
@Post('person')
|
||||
@InquiryAccess(InquiryType.PERSON)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Person identity inquiry' })
|
||||
@ApiResponse({ status: 200, type: PersonInquiryResponseDto })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Inquiry access denied' })
|
||||
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||
async inquirePerson(
|
||||
@Body() dto: PersonInquiryRequestDto,
|
||||
@Req() req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> {
|
||||
const requestId = req.requestId ?? 'unknown';
|
||||
const result = await this.inquiryService.inquirePerson(dto, requestId);
|
||||
req.trackingCode = result.trackingCode;
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('realEstate')
|
||||
@InquiryAccess(InquiryType.REAL_ESTATE)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Real estate inquiry' })
|
||||
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
|
||||
async inquireRealEstate(
|
||||
@Body() payload: Record<string, unknown>,
|
||||
@Req() req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
return this.inquireGeneric(InquiryType.REAL_ESTATE, payload, req);
|
||||
}
|
||||
|
||||
@Post('sheba')
|
||||
@InquiryAccess(InquiryType.SHEBA)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Sheba inquiry' })
|
||||
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
|
||||
async inquireSheba(
|
||||
@Body() payload: SayahRequestDto,
|
||||
@Req() req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
return this.inquireGeneric(InquiryType.SHEBA, payload as unknown as Record<string, unknown>, req);
|
||||
}
|
||||
|
||||
@Post('shahkar')
|
||||
@InquiryAccess(InquiryType.SHAHKAR)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Shahkar inquiry' })
|
||||
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
|
||||
async inquireShahkar(
|
||||
@Body() payload: ShahkarRequestDto,
|
||||
@Req() req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
return this.inquireGeneric(
|
||||
InquiryType.SHAHKAR,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
req,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('postalCode')
|
||||
@InquiryAccess(InquiryType.POSTAL_CODE)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Postal code inquiry' })
|
||||
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
|
||||
async inquirePostalCode(
|
||||
@Body() payload: PostalCodeRequestDto,
|
||||
@Req() req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
return this.inquireGeneric(
|
||||
InquiryType.POSTAL_CODE,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
req,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('legalPerson')
|
||||
@InquiryAccess(InquiryType.LEGAL_PERSON)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Legal person inquiry' })
|
||||
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
|
||||
async inquireLegalPerson(
|
||||
@Body() payload: Record<string, unknown>,
|
||||
@Req() req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req);
|
||||
}
|
||||
|
||||
private async inquireGeneric(
|
||||
inquiryType: InquiryType,
|
||||
payload: Record<string, unknown>,
|
||||
req: Request & { requestId?: string; trackingCode?: string },
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
const requestId = req.requestId ?? 'unknown';
|
||||
const result = await this.inquiryService.inquire(inquiryType, payload, requestId);
|
||||
req.trackingCode = result.trackingCode;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
14
src/inquiry/inquiry.module.ts
Normal file
14
src/inquiry/inquiry.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LoggingModule } from '../logging/logging.module';
|
||||
import { ProvidersModule } from '../providers/providers.module';
|
||||
import { RateLimitModule } from '../rate-limit/rate-limit.module';
|
||||
import { InquiryController } from './inquiry.controller';
|
||||
import { InquiryService } from './inquiry.service';
|
||||
|
||||
@Module({
|
||||
imports: [ProvidersModule, LoggingModule, AuthModule, RateLimitModule],
|
||||
controllers: [InquiryController],
|
||||
providers: [InquiryService],
|
||||
})
|
||||
export class InquiryModule {}
|
||||
245
src/inquiry/inquiry.service.ts
Normal file
245
src/inquiry/inquiry.service.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
||||
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
|
||||
import { ProviderName } from '../common/enums/provider-name.enum';
|
||||
import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
|
||||
import { NormalizedErrorDto } from '../common/dto/normalized-error.dto';
|
||||
import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
|
||||
import { InquiryException } from '../common/exceptions/inquiry.exception';
|
||||
import { InquiryLogService } from '../logging/inquiry-log.service';
|
||||
import {
|
||||
LegacyInquiryPayload,
|
||||
PersonInquiryPayload,
|
||||
PersonInquiryResult,
|
||||
} from '../providers/shared/legacy-api.provider.abstract';
|
||||
import { ProviderOrchestratorService } from '../providers/strategy/provider-orchestrator.service';
|
||||
import { PersonInquiryRequestDto } from './dto/person-inquiry-request.dto';
|
||||
import { PersonInquiryDataDto } from './dto/person-inquiry-data.dto';
|
||||
|
||||
@Injectable()
|
||||
export class InquiryService {
|
||||
constructor(
|
||||
private readonly orchestrator: ProviderOrchestratorService,
|
||||
private readonly inquiryLogService: InquiryLogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Person inquiry — unified business flow with provider fallback and audit logging.
|
||||
*/
|
||||
async inquirePerson(
|
||||
dto: PersonInquiryRequestDto,
|
||||
requestId: string,
|
||||
): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> {
|
||||
const trackingCode = generateTrackingCode();
|
||||
const payload: PersonInquiryPayload = {
|
||||
nationalCode: dto.nationalCode,
|
||||
birthDate: dto.birthDate,
|
||||
dateHasPostfix: (dto as PersonInquiryRequestDto & { dateHasPostfix?: number }).dateHasPostfix,
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executeInquiry<PersonInquiryPayload, PersonInquiryResult>(
|
||||
InquiryType.PERSON,
|
||||
payload,
|
||||
requestId,
|
||||
trackingCode,
|
||||
);
|
||||
|
||||
const response = this.buildSuccessResponse(
|
||||
result.data,
|
||||
result.provider,
|
||||
trackingCode,
|
||||
result.duration,
|
||||
'Person inquiry completed successfully',
|
||||
);
|
||||
|
||||
await this.persistLog({
|
||||
inquiryType: InquiryType.PERSON,
|
||||
provider: result.provider,
|
||||
requestPayload: payload as unknown as Record<string, unknown>,
|
||||
responsePayload: response.data as unknown as Record<string, unknown>,
|
||||
status: InquiryStatus.SUCCESS,
|
||||
duration: result.duration,
|
||||
requestId,
|
||||
trackingCode,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
const normalized = this.extractError(error);
|
||||
|
||||
const response: BaseInquiryResponseDto<PersonInquiryDataDto> = {
|
||||
success: false,
|
||||
provider: 'GATEWAY',
|
||||
trackingCode,
|
||||
message: normalized.message,
|
||||
error: normalized,
|
||||
duration,
|
||||
};
|
||||
|
||||
await this.persistLog({
|
||||
inquiryType: InquiryType.PERSON,
|
||||
provider: ProviderName.HAMTA,
|
||||
requestPayload: payload as unknown as Record<string, unknown>,
|
||||
status: InquiryStatus.FAILURE,
|
||||
duration,
|
||||
requestId,
|
||||
trackingCode,
|
||||
error: normalized as unknown as Record<string, unknown>,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
async inquire(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
requestId: string,
|
||||
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
|
||||
const trackingCode = generateTrackingCode();
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executeInquiry<LegacyInquiryPayload, unknown>(
|
||||
inquiryType,
|
||||
payload,
|
||||
requestId,
|
||||
trackingCode,
|
||||
);
|
||||
|
||||
const response = this.buildGenericSuccessResponse(
|
||||
result.data,
|
||||
result.provider,
|
||||
trackingCode,
|
||||
result.duration,
|
||||
`${this.getInquiryLabel(inquiryType)} completed successfully`,
|
||||
);
|
||||
|
||||
await this.persistLog({
|
||||
inquiryType,
|
||||
provider: result.provider,
|
||||
requestPayload: payload,
|
||||
responsePayload: response.data,
|
||||
status: InquiryStatus.SUCCESS,
|
||||
duration: result.duration,
|
||||
requestId,
|
||||
trackingCode,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
const normalized = this.extractError(error);
|
||||
|
||||
const response: BaseInquiryResponseDto<Record<string, unknown>> = {
|
||||
success: false,
|
||||
provider: 'GATEWAY',
|
||||
trackingCode,
|
||||
message: normalized.message,
|
||||
error: normalized,
|
||||
duration,
|
||||
};
|
||||
|
||||
await this.persistLog({
|
||||
inquiryType,
|
||||
provider: ProviderName.HAMTA,
|
||||
requestPayload: payload,
|
||||
status: InquiryStatus.FAILURE,
|
||||
duration,
|
||||
requestId,
|
||||
trackingCode,
|
||||
error: normalized as unknown as Record<string, unknown>,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeInquiry<TRequest, TResponse>(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
requestId: string,
|
||||
trackingCode: string,
|
||||
) {
|
||||
return this.orchestrator.executeWithFallback<TRequest, TResponse>(inquiryType, payload, {
|
||||
requestId,
|
||||
trackingCode,
|
||||
});
|
||||
}
|
||||
|
||||
private buildSuccessResponse(
|
||||
result: PersonInquiryResult,
|
||||
provider: string,
|
||||
trackingCode: string,
|
||||
duration: number,
|
||||
message: string,
|
||||
): BaseInquiryResponseDto<PersonInquiryDataDto> {
|
||||
return {
|
||||
success: true,
|
||||
provider,
|
||||
trackingCode,
|
||||
message,
|
||||
duration,
|
||||
data: {
|
||||
nationalCode: result.nationalCode,
|
||||
birthDate: result.birthDate,
|
||||
fullName: result.fullName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildGenericSuccessResponse(
|
||||
result: unknown,
|
||||
provider: string,
|
||||
trackingCode: string,
|
||||
duration: number,
|
||||
message: string,
|
||||
): BaseInquiryResponseDto<Record<string, unknown>> {
|
||||
return {
|
||||
success: true,
|
||||
provider,
|
||||
trackingCode,
|
||||
message,
|
||||
duration,
|
||||
data: this.toResponseData(result),
|
||||
};
|
||||
}
|
||||
|
||||
private toResponseData(result: unknown): Record<string, unknown> {
|
||||
if (result && typeof result === 'object' && 'raw' in result) {
|
||||
const raw = (result as { raw: unknown }).raw;
|
||||
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : { raw };
|
||||
}
|
||||
|
||||
return result && typeof result === 'object'
|
||||
? (result as Record<string, unknown>)
|
||||
: { raw: result };
|
||||
}
|
||||
|
||||
private getInquiryLabel(inquiryType: InquiryType): string {
|
||||
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
private extractError(error: unknown): NormalizedErrorDto {
|
||||
if (error instanceof InquiryException) {
|
||||
return error.normalizedError;
|
||||
}
|
||||
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
||||
return (error as { normalizedError: NormalizedErrorDto }).normalizedError;
|
||||
}
|
||||
return {
|
||||
code: 'INQUIRY_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Inquiry failed',
|
||||
};
|
||||
}
|
||||
|
||||
private async persistLog(
|
||||
input: Parameters<InquiryLogService['create']>[0],
|
||||
): Promise<void> {
|
||||
await this.inquiryLogService.create(input);
|
||||
}
|
||||
}
|
||||
24
src/logger.ts
Normal file
24
src/logger.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
LoggerProvider,
|
||||
BatchLogRecordProcessor,
|
||||
} from "@opentelemetry/sdk-logs";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
|
||||
// Create a resource with your service information
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "nodejs-console-app",
|
||||
});
|
||||
|
||||
// Configure the OTLP exporter
|
||||
// It automatically reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
|
||||
const logExporter = new OTLPLogExporter({});
|
||||
|
||||
// Create and configure the logger provider
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [new BatchLogRecordProcessor(logExporter)], // Configure batch processor
|
||||
});
|
||||
|
||||
export default loggerProvider;
|
||||
79
src/logging/dto/inquiry-log-query.dto.ts
Normal file
79
src/logging/dto/inquiry-log-query.dto.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsISO8601, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { InquiryStatus } from '../../common/enums/inquiry-status.enum';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
|
||||
export enum LogTimeRangePreset {
|
||||
LAST_24_HOURS = 'LAST_24_HOURS',
|
||||
LAST_7_DAYS = 'LAST_7_DAYS',
|
||||
LAST_30_DAYS = 'LAST_30_DAYS',
|
||||
}
|
||||
|
||||
export enum LogTimelineInterval {
|
||||
HOUR = 'HOUR',
|
||||
DAY = 'DAY',
|
||||
MONTH = 'MONTH',
|
||||
}
|
||||
|
||||
export class InquiryLogQueryDto {
|
||||
@ApiPropertyOptional({ enum: InquiryType })
|
||||
@IsOptional()
|
||||
@IsEnum(InquiryType)
|
||||
inquiryType?: InquiryType;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProviderName })
|
||||
@IsOptional()
|
||||
@IsEnum(ProviderName)
|
||||
provider?: ProviderName;
|
||||
|
||||
@ApiPropertyOptional({ enum: InquiryStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(InquiryStatus)
|
||||
status?: InquiryStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: LogTimeRangePreset })
|
||||
@IsOptional()
|
||||
@IsEnum(LogTimeRangePreset)
|
||||
preset?: LogTimeRangePreset;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-01T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-08T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matches error.code' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
errorCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Case-insensitive match against error.message' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
errorMessage?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 500, default: 50 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export class InquiryLogTimelineQueryDto extends InquiryLogQueryDto {
|
||||
@ApiPropertyOptional({ enum: LogTimelineInterval, default: LogTimelineInterval.HOUR })
|
||||
@IsOptional()
|
||||
@IsEnum(LogTimelineInterval)
|
||||
interval?: LogTimelineInterval;
|
||||
}
|
||||
414
src/logging/inquiry-log.service.ts
Normal file
414
src/logging/inquiry-log.service.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model } from 'mongoose';
|
||||
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
|
||||
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../common/enums/provider-name.enum';
|
||||
import { maskPayload } from '../common/helpers/mask-payload.helper';
|
||||
import {
|
||||
InquiryLogQueryDto,
|
||||
InquiryLogTimelineQueryDto,
|
||||
LogTimelineInterval,
|
||||
LogTimeRangePreset,
|
||||
} from './dto/inquiry-log-query.dto';
|
||||
import { InquiryLog, InquiryLogDocument } from './schemas/inquiry-log.schema';
|
||||
|
||||
export interface CreateInquiryLogInput {
|
||||
inquiryType: InquiryType;
|
||||
provider: ProviderName;
|
||||
requestPayload: Record<string, unknown>;
|
||||
responsePayload?: Record<string, unknown>;
|
||||
status: InquiryStatus;
|
||||
duration: number;
|
||||
requestId: string;
|
||||
trackingCode: string;
|
||||
error?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InquiryLogListItem {
|
||||
id: string;
|
||||
inquiryType: InquiryType;
|
||||
provider: ProviderName;
|
||||
status: InquiryStatus;
|
||||
duration: number;
|
||||
requestId: string;
|
||||
trackingCode: string;
|
||||
maskedRequest: Record<string, unknown>;
|
||||
responsePayload?: Record<string, unknown>;
|
||||
error?: Record<string, unknown>;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface InquiryLogSummary {
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
partial: number;
|
||||
successRate: number;
|
||||
failureRate: number;
|
||||
averageDuration: number;
|
||||
minDuration: number;
|
||||
maxDuration: number;
|
||||
}
|
||||
|
||||
export interface CommonErrorItem {
|
||||
code: string;
|
||||
message: string;
|
||||
count: number;
|
||||
lastSeenAt: Date;
|
||||
}
|
||||
|
||||
export interface DurationExtremeItem {
|
||||
id: string;
|
||||
inquiryType: InquiryType;
|
||||
provider: ProviderName;
|
||||
status: InquiryStatus;
|
||||
duration: number;
|
||||
requestId: string;
|
||||
trackingCode: string;
|
||||
error?: Record<string, unknown>;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface TimelineBucket {
|
||||
bucket: string;
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
partial: number;
|
||||
averageDuration: number;
|
||||
maxDuration: number;
|
||||
commonErrorCode?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InquiryLogService {
|
||||
constructor(
|
||||
@InjectModel(InquiryLog.name)
|
||||
private readonly inquiryLogModel: Model<InquiryLogDocument>,
|
||||
) {}
|
||||
|
||||
async create(input: CreateInquiryLogInput): Promise<InquiryLogDocument> {
|
||||
return this.inquiryLogModel.create({
|
||||
...input,
|
||||
maskedRequest: maskPayload(input.requestPayload),
|
||||
});
|
||||
}
|
||||
|
||||
async findLogs(query: InquiryLogQueryDto): Promise<{
|
||||
data: InquiryLogListItem[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}> {
|
||||
const filter = this.buildFilter(query);
|
||||
const limit = query.limit ?? 50;
|
||||
const page = query.page ?? 1;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
this.inquiryLogModel
|
||||
.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select('inquiryType provider status duration requestId trackingCode maskedRequest responsePayload error createdAt')
|
||||
.lean()
|
||||
.exec(),
|
||||
this.inquiryLogModel.countDocuments(filter),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: logs.map((log) => ({
|
||||
id: log._id.toString(),
|
||||
inquiryType: log.inquiryType,
|
||||
provider: log.provider,
|
||||
status: log.status,
|
||||
duration: log.duration,
|
||||
requestId: log.requestId,
|
||||
trackingCode: log.trackingCode,
|
||||
maskedRequest: log.maskedRequest,
|
||||
responsePayload: log.responsePayload,
|
||||
error: log.error,
|
||||
createdAt: log.createdAt,
|
||||
})),
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async getSummary(query: InquiryLogQueryDto): Promise<InquiryLogSummary> {
|
||||
const [summary] = await this.inquiryLogModel.aggregate<InquiryLogSummary>([
|
||||
{ $match: this.buildFilter(query) },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
total: { $sum: 1 },
|
||||
success: {
|
||||
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.SUCCESS] }, 1, 0] },
|
||||
},
|
||||
failure: {
|
||||
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.FAILURE] }, 1, 0] },
|
||||
},
|
||||
partial: {
|
||||
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.PARTIAL] }, 1, 0] },
|
||||
},
|
||||
averageDuration: { $avg: '$duration' },
|
||||
minDuration: { $min: '$duration' },
|
||||
maxDuration: { $max: '$duration' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
total: 1,
|
||||
success: 1,
|
||||
failure: 1,
|
||||
partial: 1,
|
||||
successRate: {
|
||||
$cond: [{ $eq: ['$total', 0] }, 0, { $divide: ['$success', '$total'] }],
|
||||
},
|
||||
failureRate: {
|
||||
$cond: [{ $eq: ['$total', 0] }, 0, { $divide: ['$failure', '$total'] }],
|
||||
},
|
||||
averageDuration: { $ifNull: ['$averageDuration', 0] },
|
||||
minDuration: { $ifNull: ['$minDuration', 0] },
|
||||
maxDuration: { $ifNull: ['$maxDuration', 0] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return summary ?? this.emptySummary();
|
||||
}
|
||||
|
||||
async getCommonErrors(query: InquiryLogQueryDto): Promise<CommonErrorItem[]> {
|
||||
const limit = query.limit ?? 10;
|
||||
|
||||
return this.inquiryLogModel.aggregate<CommonErrorItem>([
|
||||
{
|
||||
$match: {
|
||||
...this.buildFilter(query),
|
||||
error: { $exists: true, $ne: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
code: { $ifNull: ['$error.code', 'UNKNOWN_ERROR'] },
|
||||
message: { $ifNull: ['$error.message', 'Unknown error'] },
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
lastSeenAt: { $max: '$createdAt' },
|
||||
},
|
||||
},
|
||||
{ $sort: { count: -1, lastSeenAt: -1 } },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
code: '$_id.code',
|
||||
message: '$_id.message',
|
||||
count: 1,
|
||||
lastSeenAt: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async getDurationExtremes(query: InquiryLogQueryDto): Promise<{
|
||||
fastest: DurationExtremeItem[];
|
||||
slowest: DurationExtremeItem[];
|
||||
}> {
|
||||
const filter = this.buildFilter(query);
|
||||
const limit = query.limit ?? 10;
|
||||
const projection =
|
||||
'inquiryType provider status duration requestId trackingCode error createdAt';
|
||||
|
||||
const [fastest, slowest] = await Promise.all([
|
||||
this.inquiryLogModel.find(filter).sort({ duration: 1 }).limit(limit).select(projection).lean(),
|
||||
this.inquiryLogModel.find(filter).sort({ duration: -1 }).limit(limit).select(projection).lean(),
|
||||
]);
|
||||
|
||||
return {
|
||||
fastest: fastest.map((log) => this.toDurationExtremeItem(log)),
|
||||
slowest: slowest.map((log) => this.toDurationExtremeItem(log)),
|
||||
};
|
||||
}
|
||||
|
||||
async getTimeline(query: InquiryLogTimelineQueryDto): Promise<TimelineBucket[]> {
|
||||
const interval = query.interval ?? LogTimelineInterval.HOUR;
|
||||
|
||||
return this.inquiryLogModel.aggregate<TimelineBucket>([
|
||||
{ $match: this.buildFilter(query) },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
bucket: {
|
||||
$dateToString: {
|
||||
format: this.timelineFormat(interval),
|
||||
date: '$createdAt',
|
||||
timezone: 'Asia/Tehran',
|
||||
},
|
||||
},
|
||||
errorCode: { $ifNull: ['$error.code', null] },
|
||||
},
|
||||
total: { $sum: 1 },
|
||||
success: {
|
||||
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.SUCCESS] }, 1, 0] },
|
||||
},
|
||||
failure: {
|
||||
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.FAILURE] }, 1, 0] },
|
||||
},
|
||||
partial: {
|
||||
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.PARTIAL] }, 1, 0] },
|
||||
},
|
||||
durationSum: { $sum: '$duration' },
|
||||
maxDuration: { $max: '$duration' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
errorCount: {
|
||||
$cond: [{ $ne: ['$_id.errorCode', null] }, '$total', 0],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { '_id.bucket': 1, errorCount: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$_id.bucket',
|
||||
total: { $sum: '$total' },
|
||||
success: { $sum: '$success' },
|
||||
failure: { $sum: '$failure' },
|
||||
partial: { $sum: '$partial' },
|
||||
durationSum: { $sum: '$durationSum' },
|
||||
maxDuration: { $max: '$maxDuration' },
|
||||
commonErrorCode: { $first: '$_id.errorCode' },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
bucket: '$_id',
|
||||
total: 1,
|
||||
success: 1,
|
||||
failure: 1,
|
||||
partial: 1,
|
||||
averageDuration: {
|
||||
$cond: [{ $eq: ['$total', 0] }, 0, { $divide: ['$durationSum', '$total'] }],
|
||||
},
|
||||
maxDuration: { $ifNull: ['$maxDuration', 0] },
|
||||
commonErrorCode: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private buildFilter(query: InquiryLogQueryDto): FilterQuery<InquiryLogDocument> {
|
||||
const filter: FilterQuery<InquiryLogDocument> = {};
|
||||
const createdAt = this.buildCreatedAtFilter(query);
|
||||
|
||||
if (createdAt) {
|
||||
filter.createdAt = createdAt;
|
||||
}
|
||||
|
||||
if (query.inquiryType) {
|
||||
filter.inquiryType = query.inquiryType;
|
||||
}
|
||||
if (query.provider) {
|
||||
filter.provider = query.provider;
|
||||
}
|
||||
if (query.status) {
|
||||
filter.status = query.status;
|
||||
}
|
||||
if (query.errorCode) {
|
||||
filter['error.code'] = query.errorCode;
|
||||
}
|
||||
if (query.errorMessage) {
|
||||
filter['error.message'] = { $regex: query.errorMessage, $options: 'i' };
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private buildCreatedAtFilter(query: InquiryLogQueryDto): { $gte?: Date; $lte?: Date } | undefined {
|
||||
if (!query.from && !query.to && !query.preset) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const to = query.to ? new Date(query.to) : undefined;
|
||||
const from = query.from ? new Date(query.from) : this.defaultFromDate(query.preset);
|
||||
|
||||
return {
|
||||
...(from && { $gte: from }),
|
||||
...(to && { $lte: to }),
|
||||
};
|
||||
}
|
||||
|
||||
private defaultFromDate(preset?: LogTimeRangePreset): Date {
|
||||
const now = new Date();
|
||||
const selectedPreset = preset ?? LogTimeRangePreset.LAST_24_HOURS;
|
||||
|
||||
switch (selectedPreset) {
|
||||
case LogTimeRangePreset.LAST_30_DAYS:
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
case LogTimeRangePreset.LAST_7_DAYS:
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case LogTimeRangePreset.LAST_24_HOURS:
|
||||
default:
|
||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private timelineFormat(interval: LogTimelineInterval): string {
|
||||
switch (interval) {
|
||||
case LogTimelineInterval.MONTH:
|
||||
return '%Y-%m';
|
||||
case LogTimelineInterval.DAY:
|
||||
return '%Y-%m-%d';
|
||||
case LogTimelineInterval.HOUR:
|
||||
default:
|
||||
return '%Y-%m-%d %H:00';
|
||||
}
|
||||
}
|
||||
|
||||
private emptySummary(): InquiryLogSummary {
|
||||
return {
|
||||
total: 0,
|
||||
success: 0,
|
||||
failure: 0,
|
||||
partial: 0,
|
||||
successRate: 0,
|
||||
failureRate: 0,
|
||||
averageDuration: 0,
|
||||
minDuration: 0,
|
||||
maxDuration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private toDurationExtremeItem(log: {
|
||||
_id: unknown;
|
||||
inquiryType: InquiryType;
|
||||
provider: ProviderName;
|
||||
status: InquiryStatus;
|
||||
duration: number;
|
||||
requestId: string;
|
||||
trackingCode: string;
|
||||
error?: Record<string, unknown>;
|
||||
createdAt?: Date;
|
||||
}): DurationExtremeItem {
|
||||
return {
|
||||
id: String(log._id),
|
||||
inquiryType: log.inquiryType,
|
||||
provider: log.provider,
|
||||
status: log.status,
|
||||
duration: log.duration,
|
||||
requestId: log.requestId,
|
||||
trackingCode: log.trackingCode,
|
||||
error: log.error,
|
||||
createdAt: log.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/logging/inquiry-logs.controller.ts
Normal file
47
src/logging/inquiry-logs.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ADMIN_ROLES } from '../common/enums/role.enum';
|
||||
import { InquiryLogQueryDto, InquiryLogTimelineQueryDto } from './dto/inquiry-log-query.dto';
|
||||
import { InquiryLogService } from './inquiry-log.service';
|
||||
|
||||
@ApiTags('Inquiry Logs')
|
||||
@ApiBearerAuth()
|
||||
@Controller('inquiry-logs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(...ADMIN_ROLES)
|
||||
export class InquiryLogsController {
|
||||
constructor(private readonly inquiryLogService: InquiryLogService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Search inquiry request logs' })
|
||||
async findLogs(@Query() query: InquiryLogQueryDto) {
|
||||
return this.inquiryLogService.findLogs(query);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: 'Get request totals, success/failure counts, and duration metrics' })
|
||||
async getSummary(@Query() query: InquiryLogQueryDto) {
|
||||
return this.inquiryLogService.getSummary(query);
|
||||
}
|
||||
|
||||
@Get('errors/common')
|
||||
@ApiOperation({ summary: 'Get most common errors for a provider, inquiry type, and time range' })
|
||||
async getCommonErrors(@Query() query: InquiryLogQueryDto) {
|
||||
return this.inquiryLogService.getCommonErrors(query);
|
||||
}
|
||||
|
||||
@Get('durations/extremes')
|
||||
@ApiOperation({ summary: 'Get fastest and slowest inquiry requests' })
|
||||
async getDurationExtremes(@Query() query: InquiryLogQueryDto) {
|
||||
return this.inquiryLogService.getDurationExtremes(query);
|
||||
}
|
||||
|
||||
@Get('timeline')
|
||||
@ApiOperation({ summary: 'Get request volume, status, error, and duration trend buckets' })
|
||||
async getTimeline(@Query() query: InquiryLogTimelineQueryDto) {
|
||||
return this.inquiryLogService.getTimeline(query);
|
||||
}
|
||||
}
|
||||
15
src/logging/logging.module.ts
Normal file
15
src/logging/logging.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { InquiryLogsController } from './inquiry-logs.controller';
|
||||
import { InquiryLogService } from './inquiry-log.service';
|
||||
import { InquiryLog, InquiryLogSchema } from './schemas/inquiry-log.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: InquiryLog.name, schema: InquiryLogSchema }]),
|
||||
],
|
||||
controllers: [InquiryLogsController],
|
||||
providers: [InquiryLogService],
|
||||
exports: [InquiryLogService],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
51
src/logging/schemas/inquiry-log.schema.ts
Normal file
51
src/logging/schemas/inquiry-log.schema.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
import { InquiryStatus } from '../../common/enums/inquiry-status.enum';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
|
||||
export type InquiryLogDocument = HydratedDocument<InquiryLog>;
|
||||
|
||||
/**
|
||||
* MongoDB audit trail for every inquiry attempt.
|
||||
*/
|
||||
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'inquiry_logs' })
|
||||
export class InquiryLog {
|
||||
@Prop({ required: true, enum: InquiryType })
|
||||
inquiryType!: InquiryType;
|
||||
|
||||
@Prop({ required: true, enum: ProviderName })
|
||||
provider!: ProviderName;
|
||||
|
||||
@Prop({ type: Object, required: true })
|
||||
requestPayload!: Record<string, unknown>;
|
||||
|
||||
@Prop({ type: Object, required: true })
|
||||
maskedRequest!: Record<string, unknown>;
|
||||
|
||||
@Prop({ type: Object })
|
||||
responsePayload?: Record<string, unknown>;
|
||||
|
||||
@Prop({ required: true, enum: InquiryStatus })
|
||||
status!: InquiryStatus;
|
||||
|
||||
@Prop({ required: true })
|
||||
duration!: number;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
requestId!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
trackingCode!: string;
|
||||
|
||||
@Prop({ type: Object })
|
||||
error?: Record<string, unknown>;
|
||||
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export const InquiryLogSchema = SchemaFactory.createForClass(InquiryLog);
|
||||
|
||||
InquiryLogSchema.index({ createdAt: -1, provider: 1, inquiryType: 1, status: 1 });
|
||||
InquiryLogSchema.index({ provider: 1, inquiryType: 1, createdAt: -1 });
|
||||
InquiryLogSchema.index({ 'error.code': 1, createdAt: -1 });
|
||||
88
src/main.ts
Normal file
88
src/main.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import './telemetry';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { API_KEY_HEADER } from './common/constants/app.constants';
|
||||
import { OpenTelemetryNestLogger } from './otel-nest-logger';
|
||||
import {
|
||||
recordHttpRequestEnd,
|
||||
recordHttpRequestStart,
|
||||
shutdownTelemetry,
|
||||
} from './telemetry';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new OpenTelemetryNestLogger();
|
||||
Logger.overrideLogger(logger);
|
||||
|
||||
const app = await NestFactory.create(AppModule, { logger });
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('app.port', 8085);
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const start = Date.now();
|
||||
const attributes = {
|
||||
'http.request.method': req.method,
|
||||
'http.route': req.path,
|
||||
};
|
||||
|
||||
recordHttpRequestStart(attributes);
|
||||
|
||||
res.on('finish', () => {
|
||||
recordHttpRequestEnd(Date.now() - start, {
|
||||
...attributes,
|
||||
'http.response.status_code': res.statusCode,
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('External Services Gateway')
|
||||
.setDescription(
|
||||
'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' +
|
||||
'per-user inquiry access, audit logging, and normalized inquiry responses.',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth(
|
||||
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
|
||||
'bearer',
|
||||
)
|
||||
.addApiKey({ type: 'apiKey', name: API_KEY_HEADER, in: 'header' }, 'api-key')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
await app.listen(port);
|
||||
Logger.log(`Inquiry Gateway running on http://localhost:${port}`);
|
||||
Logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
Logger.log(`Received ${signal}, shutting down`);
|
||||
await app.close();
|
||||
await shutdownTelemetry();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
bootstrap().catch(async (error: unknown) => {
|
||||
Logger.error('Application failed to start', error instanceof Error ? error.stack : String(error));
|
||||
await shutdownTelemetry();
|
||||
process.exit(1);
|
||||
});
|
||||
87
src/otel-nest-logger.ts
Normal file
87
src/otel-nest-logger.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ConsoleLogger, LogLevel } from '@nestjs/common';
|
||||
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
|
||||
|
||||
const nestLogger = logs.getLogger('nestjs', '1.0.0');
|
||||
|
||||
function stringifyLogValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value instanceof Error) return value.stack ?? value.message;
|
||||
|
||||
try {
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function getContext(optionalParams: unknown[]): string | undefined {
|
||||
const lastParam = optionalParams[optionalParams.length - 1];
|
||||
return typeof lastParam === 'string' ? lastParam : undefined;
|
||||
}
|
||||
|
||||
function emitNestLog(
|
||||
severityNumber: SeverityNumber,
|
||||
severityText: string,
|
||||
message: unknown,
|
||||
optionalParams: unknown[],
|
||||
): void {
|
||||
const context = getContext(optionalParams);
|
||||
const attributes: Record<string, string> = {
|
||||
'log.source': 'nestjs',
|
||||
};
|
||||
|
||||
if (context) {
|
||||
attributes['nestjs.context'] = context;
|
||||
}
|
||||
|
||||
const stack = optionalParams.find(
|
||||
(param) => typeof param === 'string' && param.includes('\n'),
|
||||
);
|
||||
|
||||
if (typeof stack === 'string') {
|
||||
attributes['exception.stacktrace'] = stack;
|
||||
}
|
||||
|
||||
nestLogger.emit({
|
||||
severityNumber,
|
||||
severityText,
|
||||
body: stringifyLogValue(message),
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
export class OpenTelemetryNestLogger extends ConsoleLogger {
|
||||
log(message: unknown, ...optionalParams: unknown[]): void {
|
||||
emitNestLog(SeverityNumber.INFO, 'INFO', message, optionalParams);
|
||||
super.log(message, ...optionalParams);
|
||||
}
|
||||
|
||||
error(message: unknown, ...optionalParams: unknown[]): void {
|
||||
emitNestLog(SeverityNumber.ERROR, 'ERROR', message, optionalParams);
|
||||
super.error(message, ...optionalParams);
|
||||
}
|
||||
|
||||
warn(message: unknown, ...optionalParams: unknown[]): void {
|
||||
emitNestLog(SeverityNumber.WARN, 'WARN', message, optionalParams);
|
||||
super.warn(message, ...optionalParams);
|
||||
}
|
||||
|
||||
debug(message: unknown, ...optionalParams: unknown[]): void {
|
||||
emitNestLog(SeverityNumber.DEBUG, 'DEBUG', message, optionalParams);
|
||||
super.debug(message, ...optionalParams);
|
||||
}
|
||||
|
||||
verbose(message: unknown, ...optionalParams: unknown[]): void {
|
||||
emitNestLog(SeverityNumber.TRACE, 'VERBOSE', message, optionalParams);
|
||||
super.verbose(message, ...optionalParams);
|
||||
}
|
||||
|
||||
fatal(message: unknown, ...optionalParams: unknown[]): void {
|
||||
emitNestLog(SeverityNumber.FATAL, 'FATAL', message, optionalParams);
|
||||
super.fatal(message, ...optionalParams);
|
||||
}
|
||||
|
||||
setLogLevels(levels: LogLevel[]): void {
|
||||
super.setLogLevels(levels);
|
||||
}
|
||||
}
|
||||
152
src/providers/base/base-provider.abstract.ts
Normal file
152
src/providers/base/base-provider.abstract.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import {
|
||||
InquiryProvider,
|
||||
ProviderExecutionContext,
|
||||
} from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { withRetry } from '../../common/helpers/retry.helper';
|
||||
import { withTimeout } from '../../common/helpers/timeout.helper';
|
||||
import { RequestLogger } from '../../common/helpers/request-logger.helper';
|
||||
import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
|
||||
|
||||
/**
|
||||
* Abstract base for all provider adapters.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Retry + timeout orchestration
|
||||
* - Standardized error normalization
|
||||
* - Response formatting hooks
|
||||
* - Structured logging
|
||||
*
|
||||
* Subclasses implement provider-specific HTTP/business logic only.
|
||||
*/
|
||||
export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
||||
implements InquiryProvider<TRequest, TResponse>
|
||||
{
|
||||
abstract readonly name: ProviderName;
|
||||
abstract readonly supportedInquiryTypes: InquiryType[];
|
||||
|
||||
protected readonly logger: RequestLogger;
|
||||
protected readonly nestLogger: Logger;
|
||||
|
||||
constructor(protected readonly config: ProviderConfigSlice) {
|
||||
this.logger = new RequestLogger(this.constructor.name);
|
||||
this.nestLogger = new Logger(this.constructor.name);
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
async execute(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<TResponse> {
|
||||
if (!this.supportedInquiryTypes.includes(inquiryType)) {
|
||||
throw this.normalizeError({
|
||||
code: 'UNSUPPORTED_INQUIRY',
|
||||
message: `Provider ${this.name} does not support ${inquiryType}`,
|
||||
});
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
this.logger.logStart(
|
||||
{
|
||||
requestId: context.requestId,
|
||||
trackingCode: context.trackingCode,
|
||||
provider: this.name,
|
||||
inquiryType,
|
||||
},
|
||||
'Provider execution started',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await this.executeWithResilience(
|
||||
() => this.callProvider(inquiryType, payload, context),
|
||||
context,
|
||||
);
|
||||
|
||||
this.logger.logSuccess(
|
||||
{
|
||||
requestId: context.requestId,
|
||||
trackingCode: context.trackingCode,
|
||||
provider: this.name,
|
||||
inquiryType,
|
||||
durationMs: Date.now() - start,
|
||||
},
|
||||
'Provider execution succeeded',
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.logFailure(
|
||||
{
|
||||
requestId: context.requestId,
|
||||
trackingCode: context.trackingCode,
|
||||
provider: this.name,
|
||||
inquiryType,
|
||||
durationMs: Date.now() - start,
|
||||
},
|
||||
'Provider execution failed',
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeWithResilience<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<T> {
|
||||
const operation = () =>
|
||||
withTimeout(fn(), this.config.timeout, `${this.name} request`);
|
||||
|
||||
return withRetry(operation, {
|
||||
maxAttempts: this.config.maxRetries,
|
||||
delayMs: 300,
|
||||
shouldRetry: (error) => this.isRetryable(error),
|
||||
});
|
||||
}
|
||||
|
||||
protected isRetryable(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
|
||||
return {
|
||||
code: partial.code ?? 'PROVIDER_ERROR',
|
||||
message: partial.message ?? 'Provider request failed',
|
||||
providerMessage: partial.providerMessage,
|
||||
providerCode: partial.providerCode,
|
||||
};
|
||||
}
|
||||
|
||||
protected formatProviderError(
|
||||
providerMessage?: string,
|
||||
providerCode?: string,
|
||||
fallbackMessage = 'Provider returned an error',
|
||||
): Error {
|
||||
const normalized = this.normalizeError({
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: fallbackMessage,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
});
|
||||
const err = new Error(normalized.message);
|
||||
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
|
||||
return err;
|
||||
}
|
||||
|
||||
protected abstract callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<TResponse>;
|
||||
}
|
||||
6
src/providers/dto/auth-token.dto.ts
Normal file
6
src/providers/dto/auth-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class AuthTokenDto {
|
||||
accessToken!: string;
|
||||
refreshToken?: string;
|
||||
tokenType!: string;
|
||||
expiresIn!: number;
|
||||
}
|
||||
76
src/providers/factory/provider.factory.ts
Normal file
76
src/providers/factory/provider.factory.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { InquiryProvider } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { InquiryRoutingConfig } from '../../config/configuration';
|
||||
import { HamtaProvider } from '../implementations/hamta.provider';
|
||||
import { MoallemProvider } from '../implementations/moallem.provider';
|
||||
import { TejaratNouProvider } from '../implementations/tejaratnou.provider';
|
||||
|
||||
/**
|
||||
* Factory + registry for provider instances.
|
||||
* Resolves providers by name and builds ordered execution chains (default + fallbacks).
|
||||
* Note: AMITIS is not a provider, it's an authentication service used by other providers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProviderFactory {
|
||||
private readonly logger = new Logger(ProviderFactory.name);
|
||||
private readonly registry: Map<ProviderName, InquiryProvider>;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
hamta: HamtaProvider,
|
||||
moallem: MoallemProvider,
|
||||
tejaratnou: TejaratNouProvider,
|
||||
) {
|
||||
this.registry = new Map<ProviderName, InquiryProvider>([
|
||||
[ProviderName.HAMTA, hamta],
|
||||
[ProviderName.MOALLEM, moallem],
|
||||
[ProviderName.TEJARATNOU, tejaratnou],
|
||||
]);
|
||||
}
|
||||
|
||||
getProvider(name: ProviderName): InquiryProvider | undefined {
|
||||
const provider = this.registry.get(name);
|
||||
if (!provider?.isEnabled()) {
|
||||
this.logger.warn(`Provider ${name} is disabled or not configured`);
|
||||
return undefined;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns providers in execution order: default first, then fallbacks.
|
||||
*/
|
||||
getProvidersForInquiry(inquiryType: InquiryType): InquiryProvider[] {
|
||||
const routing = this.configService.get<InquiryRoutingConfig>(
|
||||
`inquiryRouting.${inquiryType}`,
|
||||
);
|
||||
|
||||
if (!routing) {
|
||||
this.logger.error(`No routing config for inquiry type ${inquiryType}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const orderedNames = [
|
||||
routing.defaultProvider,
|
||||
...(routing.fallbackEnabled ? routing.fallbackProviders : []),
|
||||
];
|
||||
|
||||
const seen = new Set<ProviderName>();
|
||||
const providers: InquiryProvider[] = [];
|
||||
|
||||
for (const name of orderedNames) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
|
||||
const provider = this.getProvider(name);
|
||||
if (provider?.supportedInquiryTypes.includes(inquiryType)) {
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
}
|
||||
240
src/providers/implementations/amitis.provider.ts
Normal file
240
src/providers/implementations/amitis.provider.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { AmitisAuthServiceConfig } from '../../config/configuration';
|
||||
import { GeneralTokenDocument } from '../schemas/general-token.schema';
|
||||
import { GeneralTokenService } from '../services/general-token.service';
|
||||
|
||||
interface CentInsurTokenResponse {
|
||||
Token?: string;
|
||||
token?: string;
|
||||
AccessToken?: string;
|
||||
accessToken?: string;
|
||||
access_token?: string;
|
||||
RefreshToken?: string;
|
||||
refreshToken?: string;
|
||||
refresh_token?: string;
|
||||
TokenType?: string;
|
||||
tokenType?: string;
|
||||
token_type?: string;
|
||||
ExpiresIn?: number | string;
|
||||
expiresIn?: number | string;
|
||||
expires_in?: number | string;
|
||||
data?: CentInsurTokenResponse;
|
||||
}
|
||||
|
||||
interface AmitisAuthToken {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AMITIS Authentication Service
|
||||
* Provides token-based authentication for Hamta and Moallem providers
|
||||
* This is NOT a provider itself, but an authentication helper service
|
||||
*/
|
||||
@Injectable()
|
||||
export class AmitisProvider {
|
||||
readonly name = ProviderName.AMITIS;
|
||||
private readonly logger = new Logger(AmitisProvider.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly config: AmitisAuthServiceConfig;
|
||||
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly generalTokenService: GeneralTokenService,
|
||||
) {
|
||||
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.config.baseUrl,
|
||||
timeout: this.config.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a specific provider and inquiry type
|
||||
* Handles token caching, refresh, and daily expiration
|
||||
*/
|
||||
async getAccessToken(
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const tokenKey = this.getTokenKey(providerName, inquiryType);
|
||||
const latestToken = await this.generalTokenService.getLatestToken(tokenKey);
|
||||
|
||||
// Use cached token if valid and from same day
|
||||
if (latestToken && !this.isExpired(latestToken) && this.isSameTokenDay(latestToken)) {
|
||||
return latestToken.accessToken;
|
||||
}
|
||||
|
||||
// Try to refresh if we have a refresh token and it's from the same day
|
||||
if (latestToken?.refreshToken && this.isSameTokenDay(latestToken)) {
|
||||
try {
|
||||
return await this.refreshAccessToken(latestToken, providerName, inquiryType);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`AMITIS token refresh failed for ${providerName}/${inquiryType}, requesting a new token: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Login to get new token
|
||||
return this.login(providerName, inquiryType, username, password);
|
||||
}
|
||||
|
||||
private async login(
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
// Use URLSearchParams for application/x-www-form-urlencoded format
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<CentInsurTokenResponse>(
|
||||
this.config.loginPath,
|
||||
formData.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
);
|
||||
const token = this.extractToken(response.data);
|
||||
|
||||
await this.saveToken(token, providerName, inquiryType, username, 'login');
|
||||
return token.accessToken;
|
||||
} catch (error) {
|
||||
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshAccessToken(
|
||||
latestToken: GeneralTokenDocument,
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
|
||||
Token: latestToken.accessToken,
|
||||
RefreshToken: latestToken.refreshToken,
|
||||
});
|
||||
const token = this.extractToken(response.data, latestToken.refreshToken);
|
||||
|
||||
await this.saveToken(
|
||||
token,
|
||||
providerName,
|
||||
inquiryType,
|
||||
latestToken.username,
|
||||
'refresh',
|
||||
);
|
||||
return token.accessToken;
|
||||
} catch (error) {
|
||||
throw this.toAuthError(
|
||||
`AMITIS refresh failed for ${providerName}/${inquiryType}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveToken(
|
||||
token: AmitisAuthToken,
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
scope: string,
|
||||
): Promise<void> {
|
||||
await this.generalTokenService.create({
|
||||
serviceProvider: this.getTokenKey(providerName, inquiryType),
|
||||
tokenType: token.tokenType,
|
||||
url: this.config.baseUrl,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
username,
|
||||
scope,
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken,
|
||||
expiresIn: token.expiresIn,
|
||||
expiresAt: new Date(Date.now() + token.expiresIn * 1000),
|
||||
});
|
||||
}
|
||||
|
||||
private extractToken(
|
||||
responseBody: CentInsurTokenResponse,
|
||||
fallbackRefreshToken?: string,
|
||||
): AmitisAuthToken {
|
||||
const body = responseBody.data ?? responseBody;
|
||||
const accessToken =
|
||||
body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('AMITIS auth response did not include an access token');
|
||||
}
|
||||
|
||||
const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken:
|
||||
body.RefreshToken ?? body.refreshToken ?? body.refresh_token ?? fallbackRefreshToken,
|
||||
tokenType: body.TokenType ?? body.tokenType ?? body.token_type ?? 'Bearer',
|
||||
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 20 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
private isExpired(token: GeneralTokenDocument): boolean {
|
||||
const refreshBufferMs = 60 * 1000;
|
||||
return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime();
|
||||
}
|
||||
|
||||
private isSameTokenDay(token: GeneralTokenDocument): boolean {
|
||||
if (!token.createdAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getDateKey(token.createdAt) === this.getDateKey(new Date());
|
||||
}
|
||||
|
||||
private getDateKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: this.config.tokenTimeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string {
|
||||
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
|
||||
}
|
||||
|
||||
private toAuthError(message: string, error: unknown): Error {
|
||||
if (error instanceof AxiosError) {
|
||||
const status = error.response?.status ?? 'NETWORK_ERROR';
|
||||
return new Error(`${message}: ${status} ${error.message}`);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new Error(`${message}: ${error.message}`);
|
||||
}
|
||||
|
||||
return new Error(`${message}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
165
src/providers/implementations/hamta.provider.ts
Normal file
165
src/providers/implementations/hamta.provider.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
import { AmitisProvider } from './amitis.provider';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import {
|
||||
LegacyInquiryPayload,
|
||||
LegacyInquiryResult,
|
||||
LegacyApiProvider,
|
||||
} from '../shared/legacy-api.provider.abstract';
|
||||
|
||||
interface ShahkarInquiryPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
nationalCod?: string;
|
||||
NationalCod?: string;
|
||||
mobileNo?: string;
|
||||
MobileNo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamta provider — uses shared LegacyApiProvider base.
|
||||
* Supports multiple inquiry types with different authentication methods.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HamtaProvider extends LegacyApiProvider {
|
||||
readonly name = ProviderName.HAMTA;
|
||||
readonly supportedInquiryTypes = [
|
||||
InquiryType.PERSON,
|
||||
InquiryType.REAL_ESTATE,
|
||||
InquiryType.SHEBA,
|
||||
InquiryType.SHAHKAR,
|
||||
InquiryType.POSTAL_CODE,
|
||||
InquiryType.LEGAL_PERSON,
|
||||
];
|
||||
|
||||
private readonly hamtaConfig: ProviderEnvConfig;
|
||||
|
||||
constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) {
|
||||
const config = configService.get<ProviderEnvConfig>('hamta')!;
|
||||
super(
|
||||
config,
|
||||
(providerName, inquiryType, username, password) =>
|
||||
amitisProvider.getAccessToken(providerName, inquiryType, username, password),
|
||||
);
|
||||
this.hamtaConfig = config;
|
||||
}
|
||||
|
||||
protected async callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<LegacyInquiryResult> {
|
||||
// Shahkar uses SOAP authentication, handled separately
|
||||
if (inquiryType === InquiryType.SHAHKAR) {
|
||||
return this.inquireShahkar(payload as ShahkarInquiryPayload);
|
||||
}
|
||||
|
||||
return super.callProvider(inquiryType, payload, context);
|
||||
}
|
||||
|
||||
private async inquireShahkar(payload: ShahkarInquiryPayload): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(
|
||||
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
|
||||
'nationalCode',
|
||||
);
|
||||
const mobileNo = this.getRequiredString(payload.mobileNo ?? payload.MobileNo, 'mobileNo');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.SHAHKAR);
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildShahkarEnvelope(
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
|
||||
},
|
||||
timeout: this.hamtaConfig.timeout,
|
||||
responseType: 'text',
|
||||
},
|
||||
);
|
||||
|
||||
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
result,
|
||||
soap: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildShahkarEnvelope(
|
||||
nationalCode: string,
|
||||
mobileNo: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<ShahkarInquery xmlns="http://tempuri.org/">
|
||||
<NationalCod>${this.escapeXml(nationalCode)}</NationalCod>
|
||||
<MobileNo>${this.escapeXml(mobileNo)}</MobileNo>
|
||||
<Username>${this.escapeXml(username)}</Username>
|
||||
<Password>${this.escapeXml(password)}</Password>
|
||||
</ShahkarInquery>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private extractSoapValue(xml: string, tagName: string): string | null {
|
||||
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
|
||||
return match ? this.decodeXml(match[1].trim()) : null;
|
||||
}
|
||||
|
||||
private getRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
}
|
||||
360
src/providers/implementations/moallem.provider.ts
Normal file
360
src/providers/implementations/moallem.provider.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { AmitisProvider } from './amitis.provider';
|
||||
import {
|
||||
LegacyApiProvider,
|
||||
LegacyInquiryPayload,
|
||||
LegacyInquiryResult,
|
||||
} from '../shared/legacy-api.provider.abstract';
|
||||
|
||||
interface CivilRegistrationPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
NIN?: string;
|
||||
birthDate?: string;
|
||||
BirthDate?: string;
|
||||
dateHasPostfix?: number;
|
||||
}
|
||||
|
||||
interface PostalCodePayload extends LegacyInquiryPayload {
|
||||
postalCode?: string;
|
||||
PostalCode?: string;
|
||||
}
|
||||
|
||||
interface ShahkarPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
nationalCod?: string;
|
||||
NationalCod?: string;
|
||||
mobileNo?: string;
|
||||
MobileNo?: string;
|
||||
}
|
||||
|
||||
interface SayahPayload extends LegacyInquiryPayload {
|
||||
accountOwnerType?: string;
|
||||
nationalId?: string;
|
||||
legalId?: string | null;
|
||||
shebaId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moallem provider — implements Moallem/CentInsur REST and SOAP inquiry services.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MoallemProvider extends LegacyApiProvider {
|
||||
readonly name = ProviderName.MOALLEM;
|
||||
readonly supportedInquiryTypes = [
|
||||
InquiryType.PERSON,
|
||||
InquiryType.SHEBA,
|
||||
InquiryType.SHAHKAR,
|
||||
InquiryType.POSTAL_CODE,
|
||||
];
|
||||
|
||||
private readonly moallemConfig: ProviderEnvConfig;
|
||||
|
||||
constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) {
|
||||
const config = configService.get<ProviderEnvConfig>('moallem')!;
|
||||
super(
|
||||
config,
|
||||
(providerName, inquiryType, username, password) =>
|
||||
amitisProvider.getAccessToken(providerName, inquiryType, username, password),
|
||||
);
|
||||
this.moallemConfig = config;
|
||||
}
|
||||
|
||||
protected async callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<LegacyInquiryResult> {
|
||||
if (inquiryType === InquiryType.PERSON) {
|
||||
return this.inquireCivilRegistration(payload as CivilRegistrationPayload);
|
||||
}
|
||||
|
||||
if (inquiryType === InquiryType.POSTAL_CODE) {
|
||||
return this.inquirePostalCode(payload as PostalCodePayload);
|
||||
}
|
||||
|
||||
if (inquiryType === InquiryType.SHAHKAR) {
|
||||
return this.inquireShahkar(payload as ShahkarPayload);
|
||||
}
|
||||
|
||||
if (inquiryType === InquiryType.SHEBA) {
|
||||
return this.inquireSayah(payload as SayahPayload);
|
||||
}
|
||||
|
||||
return super.callProvider(inquiryType, payload, context);
|
||||
}
|
||||
|
||||
private async inquirePostalCode(payload: PostalCodePayload): Promise<{ raw: unknown }> {
|
||||
const postalCode = this.getRequiredString(
|
||||
payload.postalCode ?? payload.PostalCode,
|
||||
'postalCode',
|
||||
);
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE);
|
||||
const token = await this.amitisProvider.getAccessToken(
|
||||
ProviderName.MOALLEM,
|
||||
InquiryType.POSTAL_CODE,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await axios.get<Record<string, unknown>>(
|
||||
`${inquiryConfig.url}/AddressByPostcode`,
|
||||
{
|
||||
params: { PostalCode: postalCode },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
timeout: this.moallemConfig.timeout,
|
||||
},
|
||||
);
|
||||
|
||||
return { raw: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async inquireCivilRegistration(
|
||||
payload: CivilRegistrationPayload,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(
|
||||
payload.nationalCode ?? payload.NIN,
|
||||
'nationalCode',
|
||||
);
|
||||
const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.PERSON);
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildCivilRegistrationEnvelope(
|
||||
nationalCode,
|
||||
birthDate,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: '"http://tempuri.org/ISabtV3/SabtInquery"',
|
||||
},
|
||||
timeout: this.moallemConfig.timeout,
|
||||
responseType: 'text',
|
||||
},
|
||||
);
|
||||
|
||||
const result = this.extractSoapValue(response.data, 'SabtInqueryResult');
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
birthDate,
|
||||
result,
|
||||
soap: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async inquireShahkar(payload: ShahkarPayload): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(
|
||||
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
|
||||
'nationalCode',
|
||||
);
|
||||
const mobileNo = this.getRequiredString(payload.mobileNo ?? payload.MobileNo, 'mobileNo');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.SHAHKAR);
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildShahkarEnvelope(
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
|
||||
},
|
||||
timeout: this.moallemConfig.timeout,
|
||||
responseType: 'text',
|
||||
},
|
||||
);
|
||||
|
||||
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
result,
|
||||
soap: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
|
||||
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType');
|
||||
const nationalId = payload.nationalId ?? '';
|
||||
const legalId = payload.legalId ?? null;
|
||||
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
|
||||
const token = await this.amitisProvider.getAccessToken(
|
||||
ProviderName.MOALLEM,
|
||||
InquiryType.SHEBA,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await axios.post<{
|
||||
IsSucceed?: boolean;
|
||||
Errors?: Array<{ Code?: string; Message?: string }>;
|
||||
Result?: unknown;
|
||||
}>(
|
||||
inquiryConfig.url,
|
||||
{
|
||||
AccountOwnerType: accountOwnerType,
|
||||
NationalId: nationalId,
|
||||
LegalId: legalId,
|
||||
ShebaId: shebaId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: this.moallemConfig.timeout,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.data.IsSucceed && response.data.Errors) {
|
||||
throw this.formatProviderError(
|
||||
this.formatErrors(response.data.Errors),
|
||||
'SAYAH_HAS_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
return { raw: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildCivilRegistrationEnvelope(
|
||||
nationalCode: string,
|
||||
birthDate: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<SabtInquery xmlns="http://tempuri.org/">
|
||||
<NIN>${this.escapeXml(nationalCode)}</NIN>
|
||||
<BirthDate>${this.escapeXml(birthDate)}</BirthDate>
|
||||
<Username>${this.escapeXml(username)}</Username>
|
||||
<Password>${this.escapeXml(password)}</Password>
|
||||
</SabtInquery>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private buildShahkarEnvelope(
|
||||
nationalCode: string,
|
||||
mobileNo: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<ShahkarInquery xmlns="http://tempuri.org/">
|
||||
<NationalCod>${this.escapeXml(nationalCode)}</NationalCod>
|
||||
<MobileNo>${this.escapeXml(mobileNo)}</MobileNo>
|
||||
<Username>${this.escapeXml(username)}</Username>
|
||||
<Password>${this.escapeXml(password)}</Password>
|
||||
</ShahkarInquery>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private extractSoapValue(xml: string, tagName: string): string | null {
|
||||
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
|
||||
return match ? this.decodeXml(match[1].trim()) : null;
|
||||
}
|
||||
|
||||
private getRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private formatErrors(errors: Array<{ Code?: string; Message?: string }>): string {
|
||||
return errors.map((e) => `${e.Code}: ${e.Message}`).join(', ');
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
262
src/providers/implementations/tejaratnou.provider.ts
Normal file
262
src/providers/implementations/tejaratnou.provider.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
|
||||
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
|
||||
import { GeneralTokenService } from '../services/general-token.service';
|
||||
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
|
||||
import { PersonInquiryPayload, PersonInquiryResult } from '../shared/legacy-api.provider.abstract';
|
||||
|
||||
interface TejaratNouPersonInquiryResponse {
|
||||
data?: TejaratNouPersonInquiryData;
|
||||
isSuccess?: boolean;
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TejaratNouPersonInquiryData {
|
||||
nationalCode?: number | string;
|
||||
nationalCodeString?: string;
|
||||
name?: string;
|
||||
family?: string;
|
||||
fatherName?: string;
|
||||
shenasnameSeri?: string;
|
||||
shenasnameSerial?: number | string;
|
||||
shenasnameNo?: number | string;
|
||||
birthDate?: number | string;
|
||||
birthDateGregorian?: string;
|
||||
gender?: unknown;
|
||||
deathStatus?: unknown;
|
||||
deathDate?: string;
|
||||
zipcode?: string;
|
||||
zipcodeDesc?: string;
|
||||
exceptionMessage?: string;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload, PersonInquiryResult> {
|
||||
readonly name = ProviderName.TEJARATNOU;
|
||||
readonly supportedInquiryTypes = [InquiryType.PERSON];
|
||||
private readonly logger = new Logger(TejaratNouProvider.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly config: TejaratNouConfig;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly generalTokenService: GeneralTokenService,
|
||||
private readonly cachedInquiryResultService: CachedInquiryResultService,
|
||||
) {
|
||||
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.config.inquiryBaseUrl,
|
||||
timeout: this.config.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.baseUrl) && Boolean(this.config.inquiryBaseUrl);
|
||||
}
|
||||
|
||||
async execute(
|
||||
inquiryType: InquiryType,
|
||||
payload: PersonInquiryPayload,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<PersonInquiryResult> {
|
||||
if (inquiryType !== InquiryType.PERSON) {
|
||||
throw new Error(`Unsupported inquiry type: ${inquiryType}`);
|
||||
}
|
||||
|
||||
return this.inquirePerson(payload, context);
|
||||
}
|
||||
|
||||
private async inquirePerson(
|
||||
payload: PersonInquiryPayload,
|
||||
_context: ProviderExecutionContext,
|
||||
): Promise<PersonInquiryResult> {
|
||||
const cachedResult = await this.cachedInquiryResultService.findByNationalCodeAndBirthDate(
|
||||
payload.nationalCode,
|
||||
payload.birthDate,
|
||||
);
|
||||
|
||||
if (cachedResult) {
|
||||
this.logger.log(`Cache hit for nationalCode: ${payload.nationalCode}`);
|
||||
return {
|
||||
nationalCode: cachedResult.nationalCode,
|
||||
birthDate: cachedResult.birthDate,
|
||||
fullName: `${cachedResult.name} ${cachedResult.family}`,
|
||||
raw: cachedResult as any,
|
||||
};
|
||||
}
|
||||
|
||||
const token = await this.getTniToken();
|
||||
if (!token) {
|
||||
throw new Error('Failed to retrieve TNI token');
|
||||
}
|
||||
|
||||
try {
|
||||
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
|
||||
const response = await this.httpClient.post<TejaratNouPersonInquiryResponse>(
|
||||
`/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.data.isSuccess || !response.data.data) {
|
||||
throw this.createProviderError(
|
||||
response.data.message ?? 'TejaratNou person inquiry failed',
|
||||
String(response.data.statusCode ?? 'PROVIDER_ERROR'),
|
||||
);
|
||||
}
|
||||
|
||||
const inquiryData = response.data.data;
|
||||
const nationalCode = this.getNationalCode(inquiryData);
|
||||
|
||||
await this.cacheInquiryResult(inquiryData, payload.birthDate);
|
||||
|
||||
return {
|
||||
nationalCode,
|
||||
birthDate: payload.birthDate,
|
||||
fullName: this.getFullName(inquiryData),
|
||||
raw: inquiryData,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`TejaratNou person inquiry failed: ${error.response?.status ?? 'NETWORK_ERROR'} ${error.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`TejaratNou person inquiry failed: ${error}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheInquiryResult(
|
||||
inquiryData: TejaratNouPersonInquiryData,
|
||||
gatewayBirthDate: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.cachedInquiryResultService.create({
|
||||
nationalCode: this.getNationalCode(inquiryData),
|
||||
name: String(inquiryData.name ?? ''),
|
||||
family: String(inquiryData.family ?? ''),
|
||||
fatherName: String(inquiryData.fatherName ?? ''),
|
||||
shenasnameSeri: String(inquiryData.shenasnameSeri ?? ''),
|
||||
shenasnameSerial: String(inquiryData.shenasnameSerial ?? ''),
|
||||
shenasnameNo: String(inquiryData.shenasnameNo ?? ''),
|
||||
birthDate: gatewayBirthDate,
|
||||
birthDateGregorian: inquiryData.birthDateGregorian,
|
||||
gender: inquiryData.gender,
|
||||
deathStatus: inquiryData.deathStatus,
|
||||
deathDate:
|
||||
inquiryData.deathDate === undefined || inquiryData.deathDate === null
|
||||
? undefined
|
||||
: String(inquiryData.deathDate),
|
||||
zipcode: String(inquiryData.zipcode ?? ''),
|
||||
zipcodeDesc: inquiryData.zipcodeDesc,
|
||||
exceptionMessage:
|
||||
inquiryData.exceptionMessage === undefined || inquiryData.exceptionMessage === null
|
||||
? undefined
|
||||
: String(inquiryData.exceptionMessage),
|
||||
message: inquiryData.message,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`TejaratNou inquiry succeeded, but cache write failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getNationalCode(inquiryData: TejaratNouPersonInquiryData): string {
|
||||
return String(inquiryData.nationalCodeString ?? inquiryData.nationalCode ?? '');
|
||||
}
|
||||
|
||||
private getFullName(inquiryData: TejaratNouPersonInquiryData): string {
|
||||
return [inquiryData.name, inquiryData.family].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
private createProviderError(providerMessage: string, providerCode: string): Error {
|
||||
const error = new Error(providerMessage);
|
||||
(
|
||||
error as Error & {
|
||||
normalizedError: {
|
||||
code: string;
|
||||
message: string;
|
||||
providerMessage: string;
|
||||
providerCode: string;
|
||||
};
|
||||
}
|
||||
).normalizedError = {
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: providerMessage,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
private async getTniToken(): Promise<string | null> {
|
||||
const latestToken = await this.generalTokenService.getLatestToken();
|
||||
if (latestToken && !(await this.generalTokenService.isTokenExpired(latestToken))) {
|
||||
this.logger.log(`Using existing token: ${latestToken._id}`);
|
||||
return latestToken.accessToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.config.baseUrl}/connect/token`,
|
||||
new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
username: this.config.username,
|
||||
password: this.config.password,
|
||||
scope: 'api-gateway access-management',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C',
|
||||
},
|
||||
timeout: this.config.timeout,
|
||||
},
|
||||
);
|
||||
|
||||
const jsonData = response.data;
|
||||
const expiresIn = jsonData.expires_in;
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
await this.generalTokenService.create({
|
||||
serviceProvider: 'TejaratNou',
|
||||
tokenType: jsonData.token_type,
|
||||
url: this.config.baseUrl,
|
||||
clientId: this.config.clientId,
|
||||
clientSecret: this.config.clientSecret,
|
||||
username: this.config.username,
|
||||
scope: jsonData.scope,
|
||||
accessToken: jsonData.access_token,
|
||||
expiresIn: expiresIn,
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
|
||||
return jsonData.access_token;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to get TNI token: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/providers/interfaces/provider-config.interface.ts
Normal file
19
src/providers/interfaces/provider-config.interface.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
|
||||
export interface LegacyProviderApiResponse {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
code?: string;
|
||||
data?: Record<string, unknown>;
|
||||
// CentInsur API format
|
||||
IsSucceed?: boolean;
|
||||
Result?: {
|
||||
Result?: boolean;
|
||||
ErrorMessage?: string | null;
|
||||
ExternalServiceResponseDuration?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
TrackingCode?: string;
|
||||
}
|
||||
|
||||
export type ProviderConfigSlice = ProviderEnvConfig;
|
||||
10
src/providers/interfaces/tejaratnou-config.interface.ts
Normal file
10
src/providers/interfaces/tejaratnou-config.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface TejaratNouConfig {
|
||||
baseUrl: string;
|
||||
inquiryBaseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
username: string;
|
||||
password: string;
|
||||
timeout: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
36
src/providers/providers.module.ts
Normal file
36
src/providers/providers.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { ProviderFactory } from './factory/provider.factory';
|
||||
import { HamtaProvider } from './implementations/hamta.provider';
|
||||
import { MoallemProvider } from './implementations/moallem.provider';
|
||||
import { TejaratNouProvider } from './implementations/tejaratnou.provider';
|
||||
import { AmitisProvider } from './implementations/amitis.provider';
|
||||
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
|
||||
import { GeneralToken, GeneralTokenSchema } from './schemas/general-token.schema';
|
||||
import { CachedInquiryResult, CachedInquiryResultSchema } from './schemas/cached-inquiry-result.schema';
|
||||
import { GeneralTokenService } from './services/general-token.service';
|
||||
import { CachedInquiryResultService } from './services/cached-inquiry-result.service';
|
||||
|
||||
/**
|
||||
* Provider adapters, factory, and orchestration strategy.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: GeneralToken.name, schema: GeneralTokenSchema },
|
||||
{ name: CachedInquiryResult.name, schema: CachedInquiryResultSchema },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
HamtaProvider,
|
||||
MoallemProvider,
|
||||
TejaratNouProvider,
|
||||
AmitisProvider,
|
||||
GeneralTokenService,
|
||||
CachedInquiryResultService,
|
||||
ProviderFactory,
|
||||
ProviderOrchestratorService,
|
||||
],
|
||||
exports: [ProviderFactory, ProviderOrchestratorService, GeneralTokenService, CachedInquiryResultService],
|
||||
})
|
||||
export class ProvidersModule {}
|
||||
59
src/providers/schemas/cached-inquiry-result.schema.ts
Normal file
59
src/providers/schemas/cached-inquiry-result.schema.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Schema as MongooseSchema } from 'mongoose';
|
||||
|
||||
export type CachedInquiryResultDocument = HydratedDocument<CachedInquiryResult>;
|
||||
|
||||
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'cached_inquiry_results' })
|
||||
export class CachedInquiryResult {
|
||||
@Prop({ required: true, index: true })
|
||||
nationalCode!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
birthDate!: string;
|
||||
|
||||
@Prop()
|
||||
birthDateGregorian?: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
name!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
family!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
fatherName!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
shenasnameSeri!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
shenasnameSerial!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
shenasnameNo!: string;
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
gender?: unknown;
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
deathStatus?: unknown;
|
||||
|
||||
@Prop()
|
||||
deathDate?: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
zipcode!: string;
|
||||
|
||||
@Prop()
|
||||
zipcodeDesc?: string;
|
||||
|
||||
@Prop()
|
||||
exceptionMessage?: string;
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
message?: unknown;
|
||||
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export const CachedInquiryResultSchema = SchemaFactory.createForClass(CachedInquiryResult);
|
||||
44
src/providers/schemas/general-token.schema.ts
Normal file
44
src/providers/schemas/general-token.schema.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type GeneralTokenDocument = HydratedDocument<GeneralToken>;
|
||||
|
||||
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'general_tokens' })
|
||||
export class GeneralToken {
|
||||
@Prop({ required: true })
|
||||
serviceProvider!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
tokenType!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
url!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
clientId!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
clientSecret!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
username!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
scope!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
accessToken!: string;
|
||||
|
||||
@Prop()
|
||||
refreshToken?: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresIn!: number;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export const GeneralTokenSchema = SchemaFactory.createForClass(GeneralToken);
|
||||
27
src/providers/services/cached-inquiry-result.service.ts
Normal file
27
src/providers/services/cached-inquiry-result.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { CachedInquiryResult, CachedInquiryResultDocument } from '../schemas/cached-inquiry-result.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CachedInquiryResultService {
|
||||
constructor(
|
||||
@InjectModel(CachedInquiryResult.name)
|
||||
private readonly cachedInquiryResultModel: Model<CachedInquiryResultDocument>,
|
||||
) {}
|
||||
|
||||
async findByNationalCodeAndBirthDate(
|
||||
nationalCode: string,
|
||||
birthDate: string,
|
||||
): Promise<CachedInquiryResultDocument | null> {
|
||||
return this.cachedInquiryResultModel
|
||||
.findOne({ nationalCode, birthDate })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<CachedInquiryResult, 'createdAt'>,
|
||||
): Promise<CachedInquiryResultDocument> {
|
||||
return this.cachedInquiryResultModel.create(data);
|
||||
}
|
||||
}
|
||||
33
src/providers/services/general-token.service.ts
Normal file
33
src/providers/services/general-token.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { GeneralToken, GeneralTokenDocument } from '../schemas/general-token.schema';
|
||||
|
||||
@Injectable()
|
||||
export class GeneralTokenService {
|
||||
constructor(
|
||||
@InjectModel(GeneralToken.name)
|
||||
private readonly generalTokenModel: Model<GeneralTokenDocument>,
|
||||
) {}
|
||||
|
||||
async getLatestToken(serviceProvider = 'TejaratNou'): Promise<GeneralTokenDocument | null> {
|
||||
return this.generalTokenModel
|
||||
.findOne({ serviceProvider })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<GeneralToken, 'createdAt'>,
|
||||
): Promise<GeneralTokenDocument> {
|
||||
return this.generalTokenModel.create(data);
|
||||
}
|
||||
|
||||
async isTokenExpired(token: GeneralTokenDocument): Promise<boolean> {
|
||||
if (!token.expiresAt) {
|
||||
return true;
|
||||
}
|
||||
const now = new Date();
|
||||
return now >= token.expiresAt;
|
||||
}
|
||||
}
|
||||
239
src/providers/shared/legacy-api.provider.abstract.ts
Normal file
239
src/providers/shared/legacy-api.provider.abstract.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { BaseProvider } from '../base/base-provider.abstract';
|
||||
import {
|
||||
LegacyProviderApiResponse,
|
||||
} from '../interfaces/provider-config.interface';
|
||||
import { ProviderEnvConfig, InquiryConfig } from '../../config/configuration';
|
||||
|
||||
export interface PersonInquiryPayload {
|
||||
nationalCode: string;
|
||||
birthDate: string;
|
||||
dateHasPostfix?: number;
|
||||
}
|
||||
|
||||
export interface PersonInquiryResult {
|
||||
fullName?: string;
|
||||
nationalCode: string;
|
||||
birthDate: string;
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
export type LegacyInquiryPayload = Record<string, unknown>;
|
||||
export type LegacyInquiryResult = PersonInquiryResult | { raw: unknown };
|
||||
export type LegacyAuthTokenResolver = (
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Shared implementation for providers with REST API contracts (Hamta, Moallem).
|
||||
*
|
||||
* Supports multiple authentication methods:
|
||||
* - AMITIS: Token-based authentication via AMITIS service
|
||||
* - SOAP: Direct SOAP authentication (handled by subclasses)
|
||||
* - NONE: No authentication required
|
||||
*/
|
||||
export abstract class LegacyApiProvider extends BaseProvider<
|
||||
LegacyInquiryPayload,
|
||||
LegacyInquiryResult
|
||||
> {
|
||||
protected readonly httpClient: AxiosInstance;
|
||||
protected readonly providerConfig: ProviderEnvConfig;
|
||||
|
||||
constructor(
|
||||
config: ProviderEnvConfig,
|
||||
private readonly authTokenResolver?: LegacyAuthTokenResolver,
|
||||
) {
|
||||
super(config);
|
||||
this.providerConfig = config;
|
||||
this.httpClient = axios.create({
|
||||
timeout: config.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
_context: ProviderExecutionContext,
|
||||
): Promise<LegacyInquiryResult> {
|
||||
if (inquiryType === InquiryType.PERSON) {
|
||||
return this.inquirePerson(payload as unknown as PersonInquiryPayload, inquiryType);
|
||||
}
|
||||
|
||||
return this.inquireGeneric(inquiryType, payload);
|
||||
}
|
||||
|
||||
protected async inquirePerson(
|
||||
payload: PersonInquiryPayload,
|
||||
inquiryType: InquiryType,
|
||||
): Promise<PersonInquiryResult> {
|
||||
const inquiryConfig = this.getInquiryConfig(inquiryType);
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<LegacyProviderApiResponse>(
|
||||
inquiryConfig.url,
|
||||
{
|
||||
nationalCode: payload.nationalCode,
|
||||
birthDate: payload.birthDate,
|
||||
},
|
||||
await this.buildRequestConfig(inquiryType, inquiryConfig),
|
||||
);
|
||||
|
||||
const body = response.data;
|
||||
|
||||
if (!body.success) {
|
||||
throw this.formatProviderError(body.message, body.code);
|
||||
}
|
||||
|
||||
return {
|
||||
nationalCode: payload.nationalCode,
|
||||
birthDate: payload.birthDate,
|
||||
fullName: body.data?.fullName as string | undefined,
|
||||
raw: body,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const data = error.response?.data as LegacyProviderApiResponse | undefined;
|
||||
throw this.formatProviderError(
|
||||
data?.message ?? error.message,
|
||||
data?.code ?? String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async inquireGeneric(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const inquiryConfig = this.getInquiryConfig(inquiryType);
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<LegacyProviderApiResponse>(
|
||||
inquiryConfig.url,
|
||||
payload,
|
||||
await this.buildRequestConfig(inquiryType, inquiryConfig),
|
||||
);
|
||||
|
||||
const body = response.data;
|
||||
|
||||
// Handle CentInsur API format (IsSucceed/Result)
|
||||
if ('IsSucceed' in body) {
|
||||
if (!body.IsSucceed) {
|
||||
throw this.formatProviderError(
|
||||
body.Result?.ErrorMessage ?? 'Request failed',
|
||||
'API_ERROR',
|
||||
);
|
||||
}
|
||||
return { raw: body };
|
||||
}
|
||||
|
||||
// Handle legacy format (success)
|
||||
if (!body.success) {
|
||||
throw this.formatProviderError(body.message, body.code);
|
||||
}
|
||||
|
||||
return {
|
||||
raw: body,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const data = error.response?.data as LegacyProviderApiResponse | undefined;
|
||||
|
||||
// Check for CentInsur format error
|
||||
if (data && 'IsSucceed' in data && !data.IsSucceed) {
|
||||
throw this.formatProviderError(
|
||||
data.Result?.ErrorMessage ?? error.message,
|
||||
String(error.response?.status ?? 'API_ERROR'),
|
||||
);
|
||||
}
|
||||
|
||||
throw this.formatProviderError(
|
||||
data?.message ?? error.message,
|
||||
data?.code ?? String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected getInquiryConfig(inquiryType: InquiryType): InquiryConfig {
|
||||
const inquiryConfig = this.providerConfig.inquiries[inquiryType];
|
||||
|
||||
if (!inquiryConfig || !inquiryConfig.url) {
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`Inquiry type ${inquiryType} is not configured for ${this.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return inquiryConfig;
|
||||
}
|
||||
|
||||
private async buildRequestConfig(
|
||||
inquiryType: InquiryType,
|
||||
inquiryConfig: InquiryConfig,
|
||||
): Promise<{
|
||||
headers?: Record<string, string>;
|
||||
}> {
|
||||
// No authentication required
|
||||
if (inquiryConfig.authMethod === 'NONE') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// SOAP authentication is handled by subclasses (e.g., Shahkar)
|
||||
if (inquiryConfig.authMethod === 'SOAP') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// AMITIS token-based authentication
|
||||
if (inquiryConfig.authMethod === 'AMITIS') {
|
||||
if (!this.authTokenResolver) {
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`No auth token resolver configured for ${this.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!inquiryConfig.username || !inquiryConfig.password) {
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`Credentials not configured for ${this.name}/${inquiryType}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = await this.authTokenResolver(
|
||||
this.name,
|
||||
inquiryType,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
);
|
||||
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`Unsupported auth method ${inquiryConfig.authMethod} for ${this.name}/${inquiryType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
93
src/providers/strategy/provider-orchestrator.service.ts
Normal file
93
src/providers/strategy/provider-orchestrator.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { InquiryException } from '../../common/exceptions/inquiry.exception';
|
||||
import { ProviderFactory } from '../factory/provider.factory';
|
||||
|
||||
export interface OrchestrationResult<T> {
|
||||
data: T;
|
||||
provider: ProviderName;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy orchestrator — tries default provider, then fallbacks.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProviderOrchestratorService {
|
||||
private readonly logger = new Logger(ProviderOrchestratorService.name);
|
||||
|
||||
constructor(private readonly providerFactory: ProviderFactory) {}
|
||||
|
||||
async executeWithFallback<TRequest, TResponse>(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<OrchestrationResult<TResponse>> {
|
||||
const providers = this.providerFactory.getProvidersForInquiry(inquiryType);
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new InquiryException('No providers available for inquiry', {
|
||||
code: 'NO_PROVIDERS',
|
||||
message: `No enabled providers configured for ${inquiryType}`,
|
||||
});
|
||||
}
|
||||
|
||||
const errors: NormalizedErrorDto[] = [];
|
||||
const start = Date.now();
|
||||
|
||||
for (const provider of providers) {
|
||||
const attemptStart = Date.now();
|
||||
try {
|
||||
const data = (await provider.execute(
|
||||
inquiryType,
|
||||
payload,
|
||||
context,
|
||||
)) as TResponse;
|
||||
|
||||
return {
|
||||
data,
|
||||
provider: provider.name,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
const normalized = this.extractError(error);
|
||||
errors.push(normalized);
|
||||
const hasNextProvider = providers.indexOf(provider) < providers.length - 1;
|
||||
this.logger.warn(
|
||||
hasNextProvider
|
||||
? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback`
|
||||
: `Provider ${provider.name} failed for ${inquiryType}, no fallback available`,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Attempt duration: ${Date.now() - attemptStart}ms | error: ${normalized.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InquiryException('All providers failed', {
|
||||
code: providers.length === 1 ? 'PROVIDER_SERVICE_NOT_AVAILABLE' : 'ALL_PROVIDERS_FAILED',
|
||||
message:
|
||||
providers.length === 1
|
||||
? `${providers[0].name} service is not available`
|
||||
: errors.map((e) => e.message).join('; '),
|
||||
providerMessage: errors[0]?.providerMessage,
|
||||
providerCode: errors[0]?.providerCode,
|
||||
});
|
||||
}
|
||||
|
||||
private extractError(error: unknown): NormalizedErrorDto {
|
||||
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
||||
return (error as { normalizedError: NormalizedErrorDto }).normalizedError;
|
||||
}
|
||||
if (error instanceof InquiryException) {
|
||||
return error.normalizedError;
|
||||
}
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Unknown provider error',
|
||||
};
|
||||
}
|
||||
}
|
||||
119
src/public/inquiry-gateway.users.json
Normal file
119
src/public/inquiry-gateway.users.json
Normal file
@@ -0,0 +1,119 @@
|
||||
[{
|
||||
"_id": {
|
||||
"$oid": "6a14565497877e39cf0bd2c6"
|
||||
},
|
||||
"fullName": "Soheil Dev",
|
||||
"username": "superadmin",
|
||||
"email": "supadmin@esg.com",
|
||||
"password": "$2b$12$Cgq3Am2nkqWS7mP5.c.KfuOO25e4/MsXe9TYlg1rze7wupJBZvEK.",
|
||||
"role": "SUPER_ADMIN",
|
||||
"clientType": "INTERNAL",
|
||||
"appName": "inquiry-gateway",
|
||||
"clientName": "Internal",
|
||||
"isActive": true,
|
||||
"isBlocked": false,
|
||||
"apiKey": "esg_4abbdf6e7a2b1981eede7e1971e85bf6613f7bedac74df4ff598d4b90b1ccac8",
|
||||
"allowedInquiries": [],
|
||||
"requestLimitPerMinute": 60,
|
||||
"requestLimitPerDay": 10000,
|
||||
"totalRequests": 0,
|
||||
"passwordChangedAt": {
|
||||
"$date": "2026-06-07T12:30:24.507Z"
|
||||
},
|
||||
"createdAt": {
|
||||
"$date": "2026-05-25T14:01:56.946Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2026-06-07T12:30:34.133Z"
|
||||
},
|
||||
"__v": 0,
|
||||
"lastLoginAt": {
|
||||
"$date": "2026-06-07T12:30:34.133Z"
|
||||
},
|
||||
"refreshToken": "$2b$12$DdaCLkcbyihUA3JNLF2bB.9MvFDg/i1759S23CE38K4hFk8ST3L4O"
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6a145802fd0ee991759157bf"
|
||||
},
|
||||
"fullName": "Tejarat Nou",
|
||||
"username": "tejaratnou",
|
||||
"email": "tejaratnou@esg.com",
|
||||
"mobile": "09999985840",
|
||||
"password": "$2b$12$bpvwOsUD9WBBfIDq46PJRuXXRQTcUF7d3EW98PvnxS08qLLCeKk52",
|
||||
"role": "USER",
|
||||
"clientType": "EXTERNAL",
|
||||
"appName": "Beno",
|
||||
"clientName": "Tejarat Nou Insurance Company Ltd",
|
||||
"isActive": true,
|
||||
"isBlocked": false,
|
||||
"apiKey": "esg_2fbab4a8fbef69427f214d0c2a37b4644bb455ff8d4c79a4985d90ce4461ff9a",
|
||||
"allowedInquiries": [
|
||||
"PERSON_INQUIRY"
|
||||
],
|
||||
"requestLimitPerMinute": 60,
|
||||
"requestLimitPerDay": 10000,
|
||||
"totalRequests": 4,
|
||||
"passwordChangedAt": {
|
||||
"$date": "2026-05-25T14:09:06.257Z"
|
||||
},
|
||||
"createdBy": {
|
||||
"$oid": "6a14565497877e39cf0bd2c6"
|
||||
},
|
||||
"createdAt": {
|
||||
"$date": "2026-05-25T14:09:06.259Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2026-06-03T09:51:00.311Z"
|
||||
},
|
||||
"__v": 0,
|
||||
"lastLoginAt": {
|
||||
"$date": "2026-06-03T09:46:17.215Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6a256575c028f57a8b3f57a5"
|
||||
},
|
||||
"fullName": "Moallem INS",
|
||||
"username": "moallem",
|
||||
"email": "moallem@esg.com",
|
||||
"mobile": "09226187419",
|
||||
"password": "$2b$12$HlGWxFk3dLufl1t.ofTW9O5jWRaV0b./pOakjLDCKuJyvAnvMHsKq",
|
||||
"role": "USER",
|
||||
"clientType": "EXTERNAL",
|
||||
"appName": "Moallem",
|
||||
"clientName": "Moallem Insurance Company Ltd",
|
||||
"isActive": true,
|
||||
"isBlocked": false,
|
||||
"apiKey": "esg_33bd4f811e04476e8fd871230e025829717e274415de4b1b9939a11deb459cd3",
|
||||
"allowedInquiries": [
|
||||
"PERSON_INQUIRY",
|
||||
"POSTAL_CODE_INQUIRY",
|
||||
"REAL_ESTATE_INQUIRY",
|
||||
"SHEBA_INQUIRY",
|
||||
"SHAHKAR_INQUIRY",
|
||||
"LEGAL_PERSON_INQUIRY",
|
||||
"CAR_PLATE_INQUIRY"
|
||||
],
|
||||
"requestLimitPerMinute": 60,
|
||||
"requestLimitPerDay": 10000,
|
||||
"totalRequests": 1,
|
||||
"passwordChangedAt": {
|
||||
"$date": "2026-06-07T12:35:01.128Z"
|
||||
},
|
||||
"createdBy": {
|
||||
"$oid": "6a14565497877e39cf0bd2c6"
|
||||
},
|
||||
"createdAt": {
|
||||
"$date": "2026-06-07T12:35:01.131Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2026-06-07T12:35:24.717Z"
|
||||
},
|
||||
"__v": 0,
|
||||
"refreshToken": "$2b$12$OpA7uoyG3ACfeVVweOBCL.8R7laFx0tkU1vRdI9DEUJE0eFIEI5Cu",
|
||||
"lastLoginAt": {
|
||||
"$date": "2026-06-07T12:35:13.319Z"
|
||||
}
|
||||
}]
|
||||
21
src/public/shahkar.html
Normal file
21
src/public/shahkar.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<HTML lang="en"><HEAD><link rel="alternate" type="text/xml" href="http://reinsure.centinsur.ir/shahkarinqOut?disco"/><STYLE type="text/css">#content{ FONT-SIZE: 0.7em; PADDING-BOTTOM: 2em; MARGIN-LEFT: 30px}BODY{MARGIN-TOP: 0px; MARGIN-LEFT: 0px; COLOR: #000000; FONT-FAMILY: Verdana; BACKGROUND-COLOR: white}P{MARGIN-TOP: 0px; MARGIN-BOTTOM: 12px; COLOR: #000000; FONT-FAMILY: Verdana}PRE{BORDER-RIGHT: #f0f0e0 1px solid; PADDING-RIGHT: 5px; BORDER-TOP: #f0f0e0 1px solid; MARGIN-TOP: -5px; PADDING-LEFT: 5px; FONT-SIZE: 1.2em; PADDING-BOTTOM: 5px; BORDER-LEFT: #f0f0e0 1px solid; PADDING-TOP: 5px; BORDER-BOTTOM: #f0f0e0 1px solid; FONT-FAMILY: Courier New; BACKGROUND-COLOR: #e5e5cc}.heading1{MARGIN-TOP: 0px; PADDING-LEFT: 15px; FONT-WEIGHT: normal; FONT-SIZE: 26px; MARGIN-BOTTOM: 0px; PADDING-BOTTOM: 3px; MARGIN-LEFT: -30px; WIDTH: 100%; COLOR: #ffffff; PADDING-TOP: 10px; FONT-FAMILY: Tahoma; BACKGROUND-COLOR: #003366}.intro{display: block; font-size: 1em;}</STYLE><TITLE>ShahkarInq Service</TITLE></HEAD><BODY><DIV id="content" role="main"><h1 class="heading1">ShahkarInq Service</h1><BR/><P class="intro">You have created a service.<P class='intro'>To test this service, you will need to create a client and use it to call the service. You can do this using the svcutil.exe tool from the command line with the following syntax:</P> <BR/><PRE>svcutil.exe <A HREF="http://reinsure.centinsur.ir/shahkarinqOut?wsdl">http://reinsure.centinsur.ir/shahkarinqOut?wsdl</A></PRE><P>You can also access the service description as a single file:<BR/><PRE><A HREF="http://reinsure.centinsur.ir/shahkarinqOut?singleWsdl">http://reinsure.centinsur.ir/shahkarinqOut?singleWsdl</A></PRE></P></P><P class="intro">This will generate a configuration file and a code file that contains the client class. Add the two files to your client application and use the generated client class to call the Service. For example:<BR/></P><h2 class='intro'>C#</h2><br /><PRE><font color="blue">class </font><font color="black">Test
|
||||
</font>{
|
||||
<font color="blue"> static void </font>Main()
|
||||
{
|
||||
<font color="black">ShahkarInqClient</font> client = <font color="blue">new </font><font color="black">ShahkarInqClient</font>();
|
||||
|
||||
<font color="darkgreen"> // Use the 'client' variable to call operations on the service.
|
||||
|
||||
</font><font color="darkgreen"> // Always close the client.
|
||||
</font> client.Close();
|
||||
}
|
||||
}
|
||||
</PRE><BR/><h2 class='intro'>Visual Basic</h2><br /><PRE><font color="blue">Class </font><font color="black">Test
|
||||
</font><font color="blue"> Shared Sub </font>Main()
|
||||
<font color="blue"> Dim </font>client As <font color="black">ShahkarInqClient</font> = <font color="blue">New </font><font color="black">ShahkarInqClient</font>()
|
||||
<font color="darkgreen"> ' Use the 'client' variable to call operations on the service.
|
||||
|
||||
</font><font color="darkgreen"> ' Always close the client.
|
||||
</font> client.Close()
|
||||
<font color="blue"> End Sub
|
||||
</font><font color="blue">End Class</font></PRE></DIV></BODY></HTML>
|
||||
36
src/rate-limit/rate-limit.module.ts
Normal file
36
src/rate-limit/rate-limit.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { UserRateLimitService } from './user-rate-limit.service';
|
||||
import { UserRateLimitGuard } from './user-rate-limit.guard';
|
||||
|
||||
/**
|
||||
* Global rate limiting via @nestjs/throttler plus per-user limits for inquiry routes.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => [
|
||||
{
|
||||
ttl: config.get<number>('throttle.ttl', 60) * 1000,
|
||||
limit: config.get<number>('throttle.limit', 100),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
UserRateLimitService,
|
||||
UserRateLimitGuard,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
/** Re-export UsersModule so importers (e.g. InquiryModule) can resolve UserRateLimitGuard deps. */
|
||||
exports: [UserRateLimitService, UserRateLimitGuard, UsersModule],
|
||||
})
|
||||
export class RateLimitModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user