2026-09-26 14:59:48 +03:30
|
|
|
import {
|
|
|
|
|
CallHandler,
|
|
|
|
|
ExecutionContext,
|
|
|
|
|
Injectable,
|
|
|
|
|
NestInterceptor,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import type { Request } from 'express';
|
|
|
|
|
import { Observable, tap } from 'rxjs';
|
2026-10-14 19:47:32 +03:30
|
|
|
import { auditStory } from './audit-detail';
|
2026-09-26 14:59:48 +03:30
|
|
|
import { AuditLogsService } from './audit-logs.service';
|
|
|
|
|
|
|
|
|
|
const SKIP_PREFIXES = [
|
|
|
|
|
'/api/health',
|
|
|
|
|
'/api/auth/login',
|
|
|
|
|
'/api/auth/register',
|
|
|
|
|
'/api/auth/otp',
|
|
|
|
|
'/api/auth/refresh',
|
|
|
|
|
'/api/portal',
|
|
|
|
|
'/api/public',
|
|
|
|
|
'/api/platform',
|
|
|
|
|
'/api/audit-logs',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
type AuthedRequest = Request & {
|
|
|
|
|
user?: {
|
|
|
|
|
userId?: string;
|
|
|
|
|
id?: string;
|
|
|
|
|
organizationId?: string;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function inferEntity(path: string): { entityType: string | null; entityId: string | null } {
|
|
|
|
|
const clean = path.replace(/^\/api\/?/, '').split('?')[0] ?? '';
|
|
|
|
|
const parts = clean.split('/').filter(Boolean);
|
|
|
|
|
if (!parts.length) return { entityType: null, entityId: null };
|
|
|
|
|
const entityType = parts[0] ?? null;
|
|
|
|
|
const maybeId = parts[1];
|
|
|
|
|
const entityId =
|
|
|
|
|
maybeId && !['new', 'me', 'summary', 'dashboard', 'board'].includes(maybeId)
|
|
|
|
|
? maybeId
|
|
|
|
|
: null;
|
|
|
|
|
return { entityType, entityId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function actionLabel(method: string, path: string): string {
|
|
|
|
|
const m = method.toUpperCase();
|
|
|
|
|
if (m === 'POST') return 'ایجاد';
|
|
|
|
|
if (m === 'PATCH' || m === 'PUT') return 'ویرایش';
|
|
|
|
|
if (m === 'DELETE') return 'حذف';
|
|
|
|
|
return m;
|
|
|
|
|
void path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuditLogsInterceptor implements NestInterceptor {
|
|
|
|
|
constructor(private readonly audit: AuditLogsService) {}
|
|
|
|
|
|
|
|
|
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
|
|
|
const http = context.switchToHttp();
|
|
|
|
|
const req = http.getRequest<AuthedRequest>();
|
|
|
|
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
|
|
|
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
|
|
|
|
return next.handle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const path = req.originalUrl || req.url || '';
|
|
|
|
|
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
|
|
|
|
|
return next.handle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const organizationId = req.user?.organizationId;
|
|
|
|
|
if (!organizationId) return next.handle();
|
|
|
|
|
|
|
|
|
|
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
|
|
|
|
|
const { entityType, entityId } = inferEntity(path);
|
|
|
|
|
const action = actionLabel(method, path);
|
|
|
|
|
const ip =
|
|
|
|
|
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
|
|
|
|
|
req.ip ||
|
|
|
|
|
null;
|
|
|
|
|
|
|
|
|
|
return next.handle().pipe(
|
|
|
|
|
tap({
|
2026-10-14 19:47:32 +03:30
|
|
|
next: (response) => {
|
|
|
|
|
const story = auditStory(method, req.body, response);
|
|
|
|
|
const summary =
|
|
|
|
|
story.summary ||
|
|
|
|
|
`${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
|
2026-09-26 14:59:48 +03:30
|
|
|
void this.audit.write({
|
|
|
|
|
organizationId,
|
|
|
|
|
actorUserId,
|
|
|
|
|
action,
|
|
|
|
|
method,
|
|
|
|
|
path: path.split('?')[0] ?? path,
|
|
|
|
|
entityType,
|
|
|
|
|
entityId,
|
|
|
|
|
summary,
|
2026-10-14 19:47:32 +03:30
|
|
|
meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null,
|
2026-09-26 14:59:48 +03:30
|
|
|
ip,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|