initial commit

This commit is contained in:
2026-06-09 14:07:37 +03:30
parent 30ac533800
commit 996a4fcda7
121 changed files with 20557 additions and 3 deletions

View 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;
}

View 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,
};
}
}

View 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);
}
}

View 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 {}

View 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 });