first commit v3 initialiazed a temporary repository for darmanet client

This commit is contained in:
2026-09-08 16:04:01 +03:30
commit 5c7fd95052
216 changed files with 30050 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentIdentity = createParamDecorator(
(data: keyof any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
// If a specific property of the user is requested (e.g., user ID or role)
// Return just that property; otherwise, return the full user object
return data ? user?.[data] : user;
},
);
export const AdminIdentity = createParamDecorator(
(data: keyof any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request['admin'];
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
import { Permission } from 'src/common/types/permissions.catalog';
export const PERMISSIONS_KEY = 'permissions';
/** Require ANY of the listed permissions (OR). Owner always passes. */
export const Permissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_ADMIN_RATE_LIMIT_KEY = 'skipAdminRateLimit';
export const SkipAdminRateLimit = () => SetMetadata(SKIP_ADMIN_RATE_LIMIT_KEY, true);

View File

@@ -0,0 +1,77 @@
import { HttpStatus } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
export class PageOptionsDto {
@ApiPropertyOptional({
minimum: 1,
default: 1,
})
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
readonly page?: number = 1;
@ApiPropertyOptional({
minimum: 1,
// maximum: 50,
default: 10,
})
@Type(() => Number)
@IsInt()
@Min(1)
// @Max(50)
@IsOptional()
readonly take?: number = 10;
get skip(): number {
return (this.page - 1) * this.take;
}
}
export interface PageMetaDtoParameters {
pageOptionsDto: PageOptionsDto;
itemCount: number;
}
export class PageMetaDto {
@ApiProperty()
readonly page: number;
@ApiProperty()
readonly take: number;
@ApiProperty()
readonly itemCount: number;
@ApiProperty()
readonly pageCount: number;
constructor({ pageOptionsDto, itemCount }: PageMetaDtoParameters) {
this.page = pageOptionsDto.page;
this.take = pageOptionsDto.take;
this.itemCount = itemCount;
this.pageCount = Math.ceil(this.itemCount / this.take);
}
}
export class BaseResponseDTO {
statusCode: HttpStatus;
message: string;
data: any;
@ApiProperty({ type: () => PageMetaDto })
readonly meta: PageMetaDto;
constructor(statusCode: HttpStatus, message, data: any, meta?: PageMetaDto) {
this.statusCode = statusCode;
this.message = message;
this.data = data;
this.meta = meta;
}
mapDataIfArray<T>(mapper: (item: any) => T): T[] {
if (Array.isArray(this.data)) {
return this.data.map(mapper);
}
return { ...this.data };
}
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
export class ForgetPasswordDTO {
@IsEmail()
@ApiProperty()
username: string;
}

View File

@@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, ValidateIf } from 'class-validator';
export class UserLoginDTO {
@ApiProperty({
required: true,
type: 'string',
description: 'mobile of user',
example: '09226187419',
})
mobile: string;
@ApiProperty({
required: false,
type: 'string',
description: 'nationalCode of user',
example: '4311402422',
})
@ValidateIf((o) => o.twoFactor === true)
nationalCode: string | null;
@ApiProperty({
required: true,
type: 'string',
default: false,
description:
'if true , both nationalCode and mobile and if false , just mobile should be sent.',
example: false,
})
twoFactor: boolean;
}
export class UserVerifyOtpDTO {
@ApiProperty({
required: true,
type: 'string',
description: 'mobile of user',
example: '09226187419',
})
mobile: string;
@ApiProperty({
required: true,
type: 'string',
description: 'otp send to the mobile of user',
example: '27246',
})
otp: string;
}
export class AdminLoginDTO {
@ApiProperty({
required: true,
type: 'string',
description: 'username of admin',
example: 'admin@chatbot.com',
})
@IsEmail()
username: string;
@ApiProperty({
required: true,
type: 'string',
description: 'password of admin',
example: 'p@$$word123321',
})
password: string;
// @ApiProperty({ examples: Role, required: true, enum: Role })
// role: Role;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsStrongPassword } from 'class-validator';
export class ResetPasswordDTO {
@ApiProperty()
@IsString()
resetToken: string;
@ApiProperty()
@IsStrongPassword()
newPassword: string;
}

View File

@@ -0,0 +1,30 @@
import { Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core';
import { Request, Response } from 'express';
import { INestApplication } from '@nestjs/common'; // Keep this import for app.close()
@Catch()
export class AllExceptionsFilter<T> extends BaseExceptionFilter {
private readonly nestApplication: INestApplication; // Rename to avoid conflict with super constructor
constructor(httpAdapterHost: HttpAdapterHost, nestApplication: INestApplication) {
super(httpAdapterHost.httpAdapter);
this.nestApplication = nestApplication;
}
async catch(exception: T, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
if (exception instanceof HttpException || (exception && typeof exception === 'object' && 'statusCode' in exception && 'message' in exception)) {
super.catch(exception, host);
} else {
const status = HttpStatus.INTERNAL_SERVER_ERROR;
console.error(`CRITICAL ERROR - HTTP Status: ${status} - Path: ${request.url}`);
console.error('Caught by AllExceptionsFilter (forcing exit):', exception);
await this.nestApplication.close();
process.exit(1);
}
}
}

View File

@@ -0,0 +1,54 @@
import { ArgumentsHost, Catch, HttpException, HttpStatus } from '@nestjs/common';
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
@Catch()
export class WsAllExceptionsFilter extends BaseWsExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const client: any = host.switchToWs().getClient();
let message = 'Unknown error';
let status = HttpStatus.BAD_REQUEST;
let details: any = undefined;
if (exception instanceof HttpException) {
status = exception.getStatus?.() ?? HttpStatus.BAD_REQUEST;
const res: any = exception.getResponse?.();
if (typeof res === 'string') message = res;
else if (res && typeof res === 'object') {
message = res.message || exception.message;
details = res;
} else {
message = exception.message;
}
} else if (exception instanceof WsException) {
const err = exception.getError();
if (typeof err === 'string') message = err;
else if (err && typeof err === 'object') {
message = (err as any).message || 'Websocket error';
details = err;
} else {
message = exception.message;
}
} else if (exception && typeof exception === 'object') {
message = (exception as any).message || message;
}
try {
client.emit('response', {
event: 'error',
statusCode: status,
message,
data: {},
meta: details,
});
} catch {
// ignore emit errors
}
super.catch(exception, host);
}
}

View File

@@ -0,0 +1,66 @@
// redis.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { Redis } from 'ioredis';
@Injectable()
export class RedisService {
constructor(@InjectRedis() private readonly redis: Redis) {}
//* Returns the raw ioredis client
getClient(): Redis {
return this.redis;
}
//* Sets a key-value pair with optional expiration (in seconds)
async set(key: string, value: any, ttlSeconds?: number): Promise<void> {
const stringValue = JSON.stringify(value);
if (ttlSeconds) {
await this.redis.set(key, stringValue, 'EX', ttlSeconds);
} else {
await this.redis.set(key, stringValue);
}
}
//* Gets a parsed JSON value from Redis
async get<T = any>(key: string): Promise<T | null> {
const result = await this.redis.get(key);
return result ? JSON.parse(result) : null;
}
//* Deletes a key
async del(key: string): Promise<void> {
await this.redis.del(key);
}
//* Push a value to a list (for queue operations)
async enqueue(queueName: string, value: any): Promise<void> {
await this.redis.rpush(queueName, JSON.stringify(value));
}
//* Pops a value from the left of a list (FIFO queue)
async dequeue<T = any>(queueName: string): Promise<T | null> {
const result = await this.redis.lpop(queueName);
return result ? JSON.parse(result) : null;
}
//* Returns the queue length
async queueLength(queueName: string): Promise<number> {
return await this.redis.llen(queueName);
}
// * Gets all values from a queue
async getAllFromQueue<T = any>(queueName: string): Promise<T[]> {
const items = await this.redis.lrange(queueName, 0, -1);
return items.map((x) => JSON.parse(x));
}
async blacklistToken(token: string, ttlInSeconds: number): Promise<void> {
const ttlInMs = ttlInSeconds * 1000;
await this.redis.set(`blacklist:${token}`, 'revoked', 'PX', ttlInMs);
}
async isTokenBlacklisted(token: string): Promise<boolean> {
const result = await this.redis.get(`blacklist:${token}`);
return result === 'revoked';
}
}

View File

@@ -0,0 +1,204 @@
import { Logger, LoggerService } from '@nestjs/common';
/**
* Log categories for better organization
*/
export enum LogCategory {
// Connection lifecycle
CONNECTION = 'CONNECTION',
DISCONNECT = 'DISCONNECT',
// Room management
ROOM = 'ROOM',
JOIN = 'JOIN',
LEAVE = 'LEAVE',
// Expert management
EXPERT = 'EXPERT',
EXPERT_ONLINE = 'EXPERT_ONLINE',
EXPERT_OFFLINE = 'EXPERT_OFFLINE',
EXPERT_REASSIGN = 'EXPERT_REASSIGN',
// User management
USER = 'USER',
USER_QUEUE = 'USER_QUEUE',
// Messaging
MESSAGE = 'MESSAGE',
MESSAGE_SEND = 'MESSAGE_SEND',
MESSAGE_EDIT = 'MESSAGE_EDIT',
MESSAGE_SEEN = 'MESSAGE_SEEN',
// Chat lifecycle
CHAT = 'CHAT',
CHAT_START = 'CHAT_START',
CHAT_END = 'CHAT_END',
CHAT_CLOSE = 'CHAT_CLOSE',
CHAT_AUTO_CLOSE = 'CHAT_AUTO_CLOSE',
// Redis operations
REDIS = 'REDIS',
REDIS_SET = 'REDIS_SET',
REDIS_HASH = 'REDIS_HASH',
REDIS_QUEUE = 'REDIS_QUEUE',
REDIS_REPAIR = 'REDIS_REPAIR',
// Validation
VALIDATION = 'VALIDATION',
// Background tasks
BACKGROUND = 'BACKGROUND',
WATCHER = 'WATCHER',
SCHEDULER = 'SCHEDULER',
// Errors
ERROR = 'ERROR',
// General
INFO = 'INFO',
DEBUG = 'DEBUG',
}
/**
* Structured Logger Service
* Provides organized logging with categories and context
*/
export class StructuredLogger implements LoggerService {
private readonly logger: Logger;
private readonly context: string;
constructor(context: string) {
this.context = context;
this.logger = new Logger(context);
}
/**
* Format log message with category and context
*/
private formatMessage(category: LogCategory, message: string, context?: Record<string, any>): string {
const categoryTag = `[${category}]`;
const contextStr = context ? ` ${JSON.stringify(context)}` : '';
return `${categoryTag} ${message}${contextStr}`;
}
/**
* Log info message
*/
log(category: LogCategory, message: string, context?: Record<string, any>): void;
log(message: string, context?: Record<string, any>): void;
log(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, context?: Record<string, any>): void {
if (typeof categoryOrMessage === 'string') {
// Legacy format: just message
this.logger.log(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage);
} else {
// Structured format: category + message + context
const category = categoryOrMessage as LogCategory;
const message = messageOrContext as string;
const ctx = context || {};
this.logger.log(this.formatMessage(category, message, ctx));
}
}
/**
* Log warning message
*/
warn(category: LogCategory, message: string, context?: Record<string, any>, error?: any): void;
warn(message: string, context?: Record<string, any>): void;
warn(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, contextOrError?: Record<string, any> | any, error?: any): void {
if (typeof categoryOrMessage === 'string') {
// Legacy format
this.logger.warn(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage);
} else {
// Structured format
const category = categoryOrMessage as LogCategory;
const message = messageOrContext as string;
// Check if contextOrError is an error object or context
let ctx: Record<string, any> = {};
let err: any = undefined;
if (contextOrError) {
if (contextOrError instanceof Error || (typeof contextOrError === 'object' && contextOrError !== null && 'stack' in contextOrError)) {
err = contextOrError;
} else {
ctx = contextOrError as Record<string, any>;
}
}
if (error) {
err = error;
}
const formattedMessage = this.formatMessage(category, message, ctx);
if (err) {
this.logger.warn(formattedMessage, err);
} else {
this.logger.warn(formattedMessage);
}
}
}
/**
* Log error message
*/
error(category: LogCategory, message: string, error?: any, context?: Record<string, any>): void;
error(message: string, error?: any, context?: Record<string, any>): void;
error(categoryOrMessage: LogCategory | string, messageOrError?: string | any, errorOrContext?: any, context?: Record<string, any>): void {
if (typeof categoryOrMessage === 'string') {
// Legacy format
const message = categoryOrMessage;
const error = messageOrError;
const ctx = errorOrContext;
if (error) {
this.logger.error(`${message} ${ctx ? JSON.stringify(ctx) : ''}`, error);
} else {
this.logger.error(message, ctx);
}
} else {
// Structured format
const category = categoryOrMessage as LogCategory;
const message = messageOrError as string;
const error = errorOrContext;
const ctx = context || {};
const formattedMessage = this.formatMessage(category, message, ctx);
if (error) {
this.logger.error(formattedMessage, error);
} else {
this.logger.error(formattedMessage);
}
}
}
/**
* Log debug message
*/
debug(category: LogCategory, message: string, context?: Record<string, any>): void;
debug(message: string, context?: Record<string, any>): void;
debug(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, context?: Record<string, any>): void {
if (typeof categoryOrMessage === 'string') {
// Legacy format
this.logger.debug(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage);
} else {
// Structured format
const category = categoryOrMessage as LogCategory;
const message = messageOrContext as string;
const ctx = context || {};
this.logger.debug(this.formatMessage(category, message, ctx));
}
}
/**
* Log verbose message
*/
verbose(category: LogCategory, message: string, context?: Record<string, any>): void;
verbose(message: string, context?: Record<string, any>): void;
verbose(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, context?: Record<string, any>): void {
if (typeof categoryOrMessage === 'string') {
// Legacy format
this.logger.verbose(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage);
} else {
// Structured format
const category = categoryOrMessage as LogCategory;
const message = messageOrContext as string;
const ctx = context || {};
this.logger.verbose(this.formatMessage(category, message, ctx));
}
}
}

View File

@@ -0,0 +1,46 @@
import { AiServiceModule } from 'src/ai-service/ai-service.module';
import { AiV2Module } from 'src/ai-v2/ai-v2.module';
import { AdminModule } from 'src/api/admin/admin.module';
import { UserModule } from 'src/api/user/user.module';
import { AuthModule } from 'src/auth/auth.module';
import { AclModule } from 'src/acl/acl.module';
import { CategoriesModule } from 'src/categories/categories.module';
import { ClientManagementModule } from 'src/client-management/client-management.module';
import { ConversationModule } from 'src/conversation/conversation.module';
import { DatabaseModule } from 'src/database/database.module';
import { StorageModule } from 'src/storage/storage.module';
import { DictionariesModule } from 'src/dictionaries/dictionaries.module';
import { InquiriesModule } from 'src/externals/inquiries/inquiries.module';
import { SmsModule } from 'src/externals/sms/sms.module';
import { SsoModule } from 'src/externals/sso/sso.module';
import { ChatAttachmentsModule } from 'src/chat-attachments/chat-attachments.module';
import { SupportManagementModule } from 'src/socket/support-management/support-management.module';
import { UploadModule } from 'src/upload/upload.module';
export function getAppImports(): any[] {
const modules = [
DatabaseModule,
StorageModule,
AuthModule,
AclModule,
AiServiceModule,
AiV2Module,
SmsModule,
SsoModule,
InquiriesModule,
UserModule,
AdminModule,
ClientManagementModule,
UploadModule,
DictionariesModule,
CategoriesModule,
];
if (process.env.SUPPORT_MANAGEMENT === 'true') {
modules.push(SupportManagementModule);
modules.push(ConversationModule);
modules.push(ChatAttachmentsModule);
}
return modules;
}

View File

@@ -0,0 +1,18 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class SecurityHeadersMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Clickjacking protection
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
// Other recommended security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
}
}

View File

@@ -0,0 +1,53 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class SwaggerAuthMiddleware implements NestMiddleware {
constructor(private readonly configService: ConfigService) {}
use(req: Request, res: Response, next: NextFunction) {
// Only protect Swagger endpoints
if (req.path.startsWith('/docs')) {
const swaggerUsername = this.configService.get<string>('SWAGGER_USERNAME');
const swaggerPassword = this.configService.get<string>('SWAGGER_PASSWORD');
// If no credentials are set, allow access (optional protection)
if (!swaggerUsername && !swaggerPassword) {
return next();
}
// If only one is set, require both
if (!swaggerUsername || !swaggerPassword) {
return this.sendAuthRequired(res);
}
// Extract Basic Auth credentials
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return this.sendAuthRequired(res);
}
// Decode Basic Auth
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
// Validate both username and password
if (username === swaggerUsername && password === swaggerPassword) {
return next();
}
return this.sendAuthRequired(res);
}
next();
}
private sendAuthRequired(res: Response) {
res.setHeader('WWW-Authenticate', 'Basic realm="Swagger API Documentation"');
res.status(401).send('Authentication required to access Swagger documentation');
}
}

View File

@@ -0,0 +1,201 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { AuditLogModel } from 'src/database/model/audit-log.model';
import { Socket } from 'socket.io';
export interface AuditLogData {
userId?: string;
action: string;
resource?: string;
resourceId?: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
changes?: Record<string, { from: any; to: any }>;
ipAddress?: string;
userAgent?: string;
method?: string;
endpoint?: string;
statusCode?: number;
metadata?: Record<string, any>;
}
@Injectable()
export class AuditLogService {
constructor(
@InjectModel(AuditLogModel.name)
private readonly auditLogModel: Model<AuditLogModel>,
) {}
/**
* Extract IP address and user agent from Socket.io client
*/
private extractClientInfo(client: Socket): { ipAddress?: string; userAgent?: string } {
const ipAddress =
(client.handshake.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
(client.handshake.headers['x-real-ip'] as string) ||
client.handshake.address ||
client.conn?.remoteAddress ||
undefined;
const userAgent =
(client.handshake.headers['user-agent'] as string) || undefined;
return { ipAddress, userAgent };
}
/**
* Calculate changes between old and new values
*/
private calculateChanges(
oldValues?: Record<string, any>,
newValues?: Record<string, any>,
): Record<string, { from: any; to: any }> | undefined {
if (!oldValues || !newValues) {
return undefined;
}
const changes: Record<string, { from: any; to: any }> = {};
const allKeys = new Set([...Object.keys(oldValues), ...Object.keys(newValues)]);
for (const key of allKeys) {
const oldVal = oldValues[key];
const newVal = newValues[key];
// Only include if values actually changed
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes[key] = { from: oldVal, to: newVal };
}
}
return Object.keys(changes).length > 0 ? changes : undefined;
}
/**
* Log an audit event
*/
async log(data: AuditLogData, client?: Socket): Promise<void> {
try {
// Extract client info if socket is provided
const clientInfo = client ? this.extractClientInfo(client) : {};
// Calculate changes if both old and new values are provided
const changes = data.changes || this.calculateChanges(data.oldValues, data.newValues);
const auditLog = new this.auditLogModel({
userId: data.userId ? (data.userId as any) : undefined,
action: data.action,
resource: data.resource,
resourceId: data.resourceId,
oldValues: data.oldValues,
newValues: data.newValues,
changes,
ipAddress: data.ipAddress || clientInfo.ipAddress,
userAgent: data.userAgent || clientInfo.userAgent,
method: data.method,
endpoint: data.endpoint,
statusCode: data.statusCode,
metadata: data.metadata,
createdAt: new Date(),
});
await auditLog.save();
} catch (error) {
// Don't throw errors - audit logging should not break the main flow
console.error('Failed to save audit log:', error);
}
}
/**
* Log a WebSocket event
*/
async logWebSocketEvent(
action: string,
client: Socket,
options: {
userId?: string;
resource?: string;
resourceId?: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
statusCode?: number;
metadata?: Record<string, any>;
} = {},
): Promise<void> {
const clientInfo = this.extractClientInfo(client);
await this.log(
{
action,
resource: options.resource,
resourceId: options.resourceId,
oldValues: options.oldValues,
newValues: options.newValues,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
method: action, // For WebSocket, method is the event name
endpoint: options.resourceId ? `room:${options.resourceId}` : undefined,
statusCode: options.statusCode || 200,
metadata: options.metadata,
userId: options.userId,
},
client,
);
}
/**
* Log an HTTP request (for future use)
*/
async logHttpRequest(
action: string,
req: any,
options: {
userId?: string;
resource?: string;
resourceId?: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
statusCode?: number;
metadata?: Record<string, any>;
} = {},
): Promise<void> {
const ipAddress =
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.ip ||
req.connection?.remoteAddress ||
undefined;
const userAgent = req.headers['user-agent'] || undefined;
await this.log({
action,
resource: options.resource,
resourceId: options.resourceId,
oldValues: options.oldValues,
newValues: options.newValues,
ipAddress,
userAgent,
method: req.method,
endpoint: req.path || req.url,
statusCode: options.statusCode || 200,
metadata: options.metadata,
userId: options.userId,
});
}
}

View File

@@ -0,0 +1,39 @@
import * as crypto from 'node:crypto';
export class EncryptionHelper {
private static enc_algorithm = 'aes-256-cbc';
private static enc_key = Buffer.from(
'8l4VcgZ7b2pysZznW025123kjhUIOHGF',
'utf8',
);
private static enc_iv = Buffer.from('-y/B?D(R+R;lQeTh', 'utf8');
public static initialize(algorithm: string, key: string, iv: string): void {
EncryptionHelper.enc_algorithm = algorithm;
EncryptionHelper.enc_key = Buffer.from(key, 'utf8');
EncryptionHelper.enc_iv = Buffer.from(iv, 'utf8');
}
public static encrypt(text): string {
const cipher = crypto.createCipheriv(
this.enc_algorithm,
Buffer.from(this.enc_key),
this.enc_iv,
);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return encrypted.toString('hex');
}
public static decrypt(text): string {
const encryptedText = Buffer.from(text, 'hex');
const decipher = crypto.createDecipheriv(
this.enc_algorithm,
Buffer.from(this.enc_key),
this.enc_iv,
);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
}

View File

@@ -0,0 +1,95 @@
import * as jalaliMoment from 'moment-jalaali';
export class TimeHelper {
public static unix2Date(unix, time = false) {
let date = new Date(unix * 1000);
if (time)
return `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`;
return date;
}
public static isValid(date, type = 'miladi') {
if (type === 'miladi') {
const valid = Date.parse(date);
return !isNaN(valid) && valid > 0;
}
return true;
}
public static miladi2jalali(date, format = 'jYYYY-jMM-jDD') {
if (!this.isValid(date)) return date;
date = jalaliMoment(date);
return date.format(format);
}
public static unix2PersianTimeAndDate(date) {
date = this.unix2Date(date);
// Get time in HH:mm format
let time = `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`;
// Convert to Jalali date with double-digit months and days
let jalaliDate = this.miladi2jalali(date, 'jYYYY-jMM-jDD');
return [time, jalaliDate];
}
public static iso2PersianTimeAndDate(isoDate: string | Date): [string, string] {
const date = typeof isoDate === 'string' ? new Date(isoDate) : isoDate;
// Get time in HH:mm format
let time = `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`;
// Convert to Jalali date with double-digit months and days
let jalaliDate = this.miladi2jalali(date, 'jYYYY-jMM-jDD');
return [time, jalaliDate];
}
public static readonly normalizeDate = (date: string) => {
const [year, month, day] = date.split('/');
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
};
public static jalaliToISO(jalaliDate: string): string {
// Convert Jalali date to Gregorian
const [year, month, day] = jalaliDate.split('/');
const gregorianDate = jalaliMoment(
`${year}/${month}/${day}`,
'jYYYY/jMM/jDD',
).format('YYYY-MM-DD');
// Create a new date object with the converted date
const date = new Date(gregorianDate);
// Format to ISO string
return date.toISOString();
}
public static divideDateRange(
startDate: string,
endDate: string,
): { start: string; end: string }[] {
// Convert dates to moment objects
const start = jalaliMoment(startDate, 'jYYYY/jMM/jDD');
const end = jalaliMoment(endDate, 'jYYYY/jMM/jDD');
// Calculate total days
const totalDays = end.diff(start, 'days');
const segmentDays = Math.floor(totalDays / 5);
const segments: { start: string; end: string }[] = [];
let currentStart = start.clone();
for (let i = 0; i < 5; i++) {
const segmentEnd =
i === 4 ? end : currentStart.clone().add(segmentDays, 'days');
segments.push({
start: currentStart.format('jYYYY/jMM/jDD'),
end: segmentEnd.format('jYYYY/jMM/jDD'),
});
currentStart = segmentEnd.clone().add(1, 'days');
}
return segments;
}
}

View File

@@ -0,0 +1,12 @@
export enum CategoriesEnum {
carInsurance = 'carInsurance',
liabilityInsurance = 'liabilityInsurance',
healthInsurance = 'healthInsurance',
lifeInsurance = 'lifeInsurance',
motorcycleInsurance = 'motorcycleInsurance',
fireInsurance = 'fireInsurance',
travelInsurance = 'travelInsurance',
transportInsurance = 'transportInsurance',
incidentsInsurance = 'incidentsInsurance',
general = 'general',
}

View File

@@ -0,0 +1,24 @@
export enum ExpertPreparedMessageCategory {
Greetings = 'greetings',
QuickAnswers = 'quick_answers',
Closing = 'closing',
FollowUp = 'follow_up',
Apology = 'apology',
Other = 'other',
}
export const EXPERT_PREPARED_MESSAGE_CATEGORIES = Object.values(
ExpertPreparedMessageCategory,
);
export const EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS: Record<
ExpertPreparedMessageCategory,
string
> = {
[ExpertPreparedMessageCategory.Greetings]: 'Greetings',
[ExpertPreparedMessageCategory.QuickAnswers]: 'Quick answers',
[ExpertPreparedMessageCategory.Closing]: 'Closing',
[ExpertPreparedMessageCategory.FollowUp]: 'Follow-up',
[ExpertPreparedMessageCategory.Apology]: 'Apology',
[ExpertPreparedMessageCategory.Other]: 'Other',
};

View File

@@ -0,0 +1,4 @@
export interface ExpertData {
activeSessions: string;
maxSessions: string;
}

View File

@@ -0,0 +1,6 @@
export enum funcsEnum {
create = 'create',
update = 'update',
replace = 'replace',
}

View File

@@ -0,0 +1,153 @@
import { BadRequestException } from '@nestjs/common';
import { Role } from './role.type';
/**
* Code-defined permission catalog (ADR 0001).
* Hybrid: coarse modules + fine keys where defaults differ.
*/
export enum Permission {
ProfileRead = 'profile.read',
ProfileWrite = 'profile.write',
StaffCreateExpert = 'staff.create_expert',
StaffCreateSupervisor = 'staff.create_supervisor',
StaffCreateAdmin = 'staff.create_admin',
DashboardView = 'dashboard.view',
ConversationsOwn = 'conversations.own',
ConversationsAll = 'conversations.all',
ConversationsExport = 'conversations.export',
ConversationsRate = 'conversations.rate',
UsersList = 'users.list',
UsersExport = 'users.export',
ExpertsList = 'experts.list',
ExpertsReport = 'experts.report',
ReportsView = 'reports.view',
ReportsExpertTiming = 'reports.expert_timing',
BusinessHoursManage = 'business_hours.manage',
ClientsManage = 'clients.manage',
CategoriesManage = 'categories.manage',
PreparedMessagesManage = 'prepared_messages.manage',
PreparedMessagesRead = 'prepared_messages.read',
DictionariesRead = 'dictionaries.read',
DictionariesWrite = 'dictionaries.write',
DictionariesCreate = 'dictionaries.create',
DictionariesApprove = 'dictionaries.approve',
DictionariesAssets = 'dictionaries.assets',
/** Admin-style direct question edit without modification request. */
DictionariesDirectEdit = 'dictionaries.direct_edit',
AttachmentsExpert = 'attachments.expert',
}
export const ALL_ASSIGNABLE_PERMISSIONS: readonly Permission[] =
Object.values(Permission);
/** Permissions that may never appear on role templates or overrides. */
export const OWNER_ONLY_META = {
RolesManage: 'acl.roles.manage',
OverridesManage: 'acl.overrides.manage',
} as const;
export function isAssignablePermission(key: string): key is Permission {
return (ALL_ASSIGNABLE_PERMISSIONS as readonly string[]).includes(key);
}
export function assertAssignablePermissions(keys: string[]): Permission[] {
const invalid = keys.filter((k) => !isAssignablePermission(k));
if (invalid.length) {
throw new BadRequestException(
`Unknown or non-assignable permissions: ${invalid.join(', ')}`,
);
}
return keys as Permission[];
}
const ADMIN_DEFAULT: Permission[] = [
Permission.ProfileRead,
Permission.ProfileWrite,
Permission.StaffCreateExpert,
Permission.DashboardView,
Permission.ConversationsAll,
Permission.ConversationsExport,
Permission.ConversationsRate,
Permission.UsersList,
Permission.UsersExport,
Permission.ExpertsList,
Permission.ExpertsReport,
Permission.ReportsView,
Permission.ReportsExpertTiming,
Permission.BusinessHoursManage,
Permission.ClientsManage,
Permission.CategoriesManage,
Permission.PreparedMessagesManage,
Permission.DictionariesRead,
Permission.DictionariesWrite,
Permission.DictionariesCreate,
Permission.DictionariesApprove,
Permission.DictionariesAssets,
Permission.DictionariesDirectEdit,
];
const SUPERVISOR_DEFAULT: Permission[] = [
Permission.ProfileRead,
Permission.ProfileWrite,
Permission.StaffCreateExpert,
Permission.DashboardView,
Permission.ConversationsAll,
Permission.ConversationsExport,
Permission.ConversationsRate,
Permission.UsersList,
Permission.UsersExport,
Permission.ExpertsList,
Permission.ExpertsReport,
Permission.ReportsView,
Permission.ReportsExpertTiming,
Permission.BusinessHoursManage,
Permission.ClientsManage,
Permission.CategoriesManage,
Permission.PreparedMessagesManage,
];
const EXPERT_DEFAULT: Permission[] = [
Permission.ProfileRead,
Permission.ProfileWrite,
Permission.ConversationsOwn,
Permission.UsersList,
Permission.UsersExport,
Permission.ReportsExpertTiming,
Permission.PreparedMessagesRead,
Permission.DictionariesRead,
Permission.DictionariesWrite,
Permission.AttachmentsExpert,
];
export const DEFAULT_ROLE_PERMISSIONS: Record<
Role.Admin | Role.Supervisor | Role.Expert,
Permission[]
> = {
[Role.Admin]: ADMIN_DEFAULT,
[Role.Supervisor]: SUPERVISOR_DEFAULT,
[Role.Expert]: EXPERT_DEFAULT,
};
export function createPermissionForRole(
role: string,
): Permission | null {
switch (role) {
case Role.Expert:
return Permission.StaffCreateExpert;
case Role.Supervisor:
return Permission.StaffCreateSupervisor;
case Role.Admin:
return Permission.StaffCreateAdmin;
default:
return null;
}
}

View File

@@ -0,0 +1,5 @@
export enum ReactEnum {
like = 'Like',
dislike = 'Dislike',
nothing = 'Nothing',
}

View File

@@ -0,0 +1,22 @@
export enum Role {
User = 'user',
Admin = 'admin',
Expert = 'expert',
Supervisor = 'supervisor',
Owner = 'owner',
}
/** System staff roles that cannot be deleted/renamed. */
export const SYSTEM_STAFF_ROLES: readonly Role[] = [
Role.Admin,
Role.Expert,
Role.Supervisor,
] as const;
export function isOwnerRole(role: string | Role | undefined | null): boolean {
return role === Role.Owner;
}
export function isSystemStaffRole(role: string): boolean {
return (SYSTEM_STAFF_ROLES as readonly string[]).includes(role);
}

View File

@@ -0,0 +1,5 @@
export enum Sender {
User = 'User',
Bot = 'Bot',
Expert = 'Expert',
}