2026-09-26 14:59:48 +03:30
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
|
import type { AuditLogDto, AuditLogListResponse } from '@mento/shared';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
2026-10-06 15:04:46 +03:30
|
|
|
import { buildSearchWhere, ci } from '../common/search';
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
|
|
|
export type AuditWriteInput = {
|
|
|
|
|
organizationId: string;
|
|
|
|
|
actorUserId?: string | null;
|
|
|
|
|
action: string;
|
|
|
|
|
method: string;
|
|
|
|
|
path: string;
|
|
|
|
|
entityType?: string | null;
|
|
|
|
|
entityId?: string | null;
|
|
|
|
|
summary: string;
|
|
|
|
|
meta?: Record<string, unknown> | null;
|
|
|
|
|
ip?: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuditLogsService {
|
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
|
|
|
|
|
|
async write(input: AuditWriteInput): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId: input.organizationId,
|
|
|
|
|
actorUserId: input.actorUserId ?? null,
|
|
|
|
|
action: input.action,
|
|
|
|
|
method: input.method.slice(0, 16),
|
|
|
|
|
path: input.path.slice(0, 400),
|
|
|
|
|
entityType: input.entityType ?? null,
|
|
|
|
|
entityId: input.entityId ?? null,
|
|
|
|
|
summary: input.summary.slice(0, 500),
|
|
|
|
|
meta:
|
|
|
|
|
input.meta == null
|
|
|
|
|
? undefined
|
|
|
|
|
: (input.meta as Prisma.InputJsonValue),
|
|
|
|
|
ip: input.ip ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// never break the main request because of audit write failures
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async list(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
opts: {
|
|
|
|
|
q?: string;
|
|
|
|
|
actorUserId?: string;
|
|
|
|
|
entityType?: string;
|
|
|
|
|
page?: number;
|
|
|
|
|
limit?: number;
|
|
|
|
|
},
|
|
|
|
|
): Promise<AuditLogListResponse> {
|
|
|
|
|
const page = Math.max(1, opts.page ?? 1);
|
|
|
|
|
const limit = Math.min(100, Math.max(1, opts.limit ?? 30));
|
|
|
|
|
const where: Prisma.AuditLogWhereInput = {
|
|
|
|
|
organizationId,
|
|
|
|
|
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
|
|
|
|
|
...(opts.entityType ? { entityType: opts.entityType } : {}),
|
|
|
|
|
};
|
2026-10-06 15:04:46 +03:30
|
|
|
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
|
|
|
|
|
{ summary: ci(t) },
|
|
|
|
|
{ path: ci(t) },
|
|
|
|
|
{ action: ci(t) },
|
|
|
|
|
{ actor: { fullName: ci(t) } },
|
|
|
|
|
]);
|
|
|
|
|
if (search) where.AND = [search];
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
|
|
|
const [total, rows] = await Promise.all([
|
|
|
|
|
this.prisma.auditLog.count({ where }),
|
|
|
|
|
this.prisma.auditLog.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: { actor: { select: { id: true, fullName: true } } },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
skip: (page - 1) * limit,
|
|
|
|
|
take: limit,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
items: rows.map((row) => this.toDto(row)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toDto(row: {
|
|
|
|
|
id: string;
|
|
|
|
|
action: string;
|
|
|
|
|
method: string;
|
|
|
|
|
path: string;
|
|
|
|
|
entityType: string | null;
|
|
|
|
|
entityId: string | null;
|
|
|
|
|
summary: string;
|
|
|
|
|
ip: string | null;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
actor: { id: string; fullName: string } | null;
|
|
|
|
|
}): AuditLogDto {
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
action: row.action,
|
|
|
|
|
method: row.method,
|
|
|
|
|
path: row.path,
|
|
|
|
|
entityType: row.entityType,
|
|
|
|
|
entityId: row.entityId,
|
|
|
|
|
summary: row.summary,
|
|
|
|
|
actor: row.actor,
|
|
|
|
|
ip: row.ip,
|
|
|
|
|
createdAt: row.createdAt.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|