import { Injectable, Inject } from "@nestjs/common"; import { Prisma, AuditAction } from "@prisma/client"; import { PrismaService } from "../database/prisma.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { Logger } from "nestjs-pino"; import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js"; // Re-export AuditAction from Prisma for consumers export { AuditAction } from "@prisma/client"; export interface AuditLogData { userId?: string | undefined; action: AuditAction; resource?: string | undefined; details?: Record | string | number | boolean | null | undefined; ipAddress?: string | undefined; userAgent?: string | undefined; success?: boolean | undefined; error?: string | undefined; } /** * Minimal request shape for audit logging. * Compatible with Express Request but only requires the fields needed for IP/UA extraction. * Must be compatible with RequestContextLike from request-context.util.ts. */ export type AuditRequest = { headers?: Record | undefined; ip?: string | undefined; connection?: { remoteAddress?: string | undefined } | undefined; socket?: { remoteAddress?: string | undefined } | undefined; }; @Injectable() export class AuditService { constructor( private readonly prisma: PrismaService, @Inject(Logger) private readonly logger: Logger ) {} async log(data: AuditLogData): Promise { try { const createData: Parameters[0]["data"] = { action: data.action, success: data.success ?? true, }; if (data.userId !== undefined) createData.userId = data.userId; if (data.resource !== undefined) createData.resource = data.resource; if (data.ipAddress !== undefined) createData.ipAddress = data.ipAddress; if (data.userAgent !== undefined) createData.userAgent = data.userAgent; if (data.error !== undefined) createData.error = data.error; if (data.details !== undefined) { createData.details = data.details === null ? Prisma.JsonNull : (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue); } await this.prisma.auditLog.create({ data: createData }); } catch (error) { this.logger.error("Audit logging failed", { errorType: error instanceof Error ? error.constructor.name : "Unknown", message: extractErrorMessage(error), }); } } async logAuthEvent( action: AuditAction, userId?: string, details?: Record | string | number | boolean | null, request?: AuditRequest, success: boolean = true, error?: string ): Promise { const ipAddress = extractClientIp(request); const userAgent = extractUserAgent(request); await this.log({ userId, action, resource: "auth", details, ipAddress, userAgent, success, error, }); } async getAuditLogs({ page, limit, action, userId, }: { page: number; limit: number; action?: AuditAction; userId?: string; }) { const skip = (page - 1) * limit; const where: Prisma.AuditLogWhereInput = {}; if (action) where.action = action; if (userId) where.userId = userId; const [logs, total] = await Promise.all([ this.prisma.auditLog.findMany({ where, include: { user: { select: { id: true, email: true, }, }, }, orderBy: { createdAt: "desc" }, skip, take: limit, }), this.prisma.auditLog.count({ where }), ]); return { logs, total }; } async getSecurityStats() { const today = new Date(new Date().setHours(0, 0, 0, 0)); const [totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday] = await Promise.all([ this.prisma.user.count(), this.prisma.user.count({ where: { lockedUntil: { gt: new Date(), }, }, }), this.prisma.auditLog.count({ where: { action: AuditAction.LOGIN_FAILED, createdAt: { gte: today, }, }, }), this.prisma.auditLog.count({ where: { action: AuditAction.LOGIN_SUCCESS, createdAt: { gte: today, }, }, }), ]); return { totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday, securityEventsToday: failedLoginsToday + successfulLoginsToday, }; } }