using MongoDB.Bson.IO; using MongoDB.Bson.Serialization; using JsonConvert = Newtonsoft.Json.JsonConvert; namespace audit_logs.Services; using System.Security.Claims; using System.Text.RegularExpressions; using audit_logs.Models; using audit_logs.Models.AppSettings; using audit_logs.Models.DTO; using audit_logs.Services.Interfaces; using Microsoft.Extensions.Options; using MongoDB.Bson; using audit_logs.Repositories; using MongoDB.Driver; using audit_logs.Utils; using audit.Model; using audit_logs.Repositories.Interfaces; using Serilog; using System; using System.Collections.Generic; using System.Threading.Tasks; public class AuditService : IAuditService { private readonly IAuditRecordRepository _auditLogRepository; public AuditService(IAuditRecordRepository auditLogRepository) { _auditLogRepository = auditLogRepository; } public AuditService(IMongoDatabase database) { _auditLogRepository = new AuditRecordRepository(database); } public AuditService(IOptions dbSettings) { _auditLogRepository = new AuditRecordRepository(dbSettings); } public IAuditRecordRepository AuditLogRepository => _auditLogRepository; /// /// Records a detailed audit log entry. /// /// The data required to create an audit record. public async Task RecordAuditAsync(AuditRecord auditRecord) { if (auditRecord == null) throw new ArgumentNullException(nameof(auditRecord)); if (string.IsNullOrWhiteSpace(auditRecord.EntityType)) throw new ArgumentNullException(nameof(auditRecord.EntityType)); if (string.IsNullOrWhiteSpace(auditRecord.RecordId)) throw new ArgumentNullException(nameof(auditRecord.RecordId)); if (string.IsNullOrWhiteSpace(auditRecord.UserId)) throw new ArgumentNullException(nameof(auditRecord.UserId)); if (string.IsNullOrWhiteSpace(auditRecord.ActionType)) throw new ArgumentNullException(nameof(auditRecord.ActionType)); if (auditRecord.Changes == null) throw new ArgumentNullException(nameof(auditRecord.Changes)); if (string.IsNullOrWhiteSpace(auditRecord.UserIpAddress)) throw new ArgumentNullException(nameof(auditRecord.UserIpAddress)); await _auditLogRepository.InsertOneAsync(auditRecord); } public Task JsonDeepCopyAsync(T source) { return Task.Run(() => { var serialized = JsonConvert.SerializeObject(source); var deserialized = JsonConvert.DeserializeObject(serialized); if (deserialized == null) throw new InvalidOperationException("Deserialization resulted in null"); return deserialized; }); } public Task DeepCopyAsync(T source) { T copy; using (var stream = new MemoryStream()) { // Serializa el objeto al stream en formato BSON using (var writer = new BsonBinaryWriter(stream)) { BsonSerializer.Serialize(writer, source); } // Reinicia la posición del stream para leer desde el inicio stream.Seek(0, SeekOrigin.Begin); // Deserializa el objeto desde el stream using (var reader = new BsonBinaryReader(stream)) { copy = BsonSerializer.Deserialize(reader); } } return Task.FromResult(copy); } #region Documentacioòn para el metodo CreateAuditLogAsync /// /// Creates an audit log record based on the provided original and modified data. /// This method generates an object to encapsulate all necessary details for the audit log entry. /// /// /// The representing the user performing the action. /// This parameter is required and is used to populate the UserId and optionally other user-related fields. /// /// /// The original object before the change. This parameter can be null for new records (when ActionType is "create"). /// /// /// The modified object after the change. This parameter can be null for deleted records (when ActionType is "delete"). /// /// /// The reason for the change. This parameter is optional; if null or empty, it defaults to "Not specified". /// /// /// Thrown if is null. /// /// /// This method constructs and populates an object that contains the following fields: /// /// /// EntityType: The type of entity being audited (e.g., "Patient"). /// /// /// RecordId: The unique identifier of the record being modified within the entity (e.g. patient.id, nhc,etc) EntityType. /// /// /// UserId: The ID of the user who performed the action that triggered the audit entry. /// /// /// ActionType: The type of action performed, which can be one of the following: /// /// "create": Indicates a new record was created. /// "update": Indicates an existing record was updated. /// "delete": Indicates a record was removed. /// /// /// /// /// Reason: (Optional) A description or justification for the action being logged. /// /// /// ActionTime: The timestamp of when the action occurred, represented in UTC. /// /// /// Changes: A list detailing the differences between the original and modified data. Each change includes: /// /// Field: The name of the field that was changed. /// OldValue: The value of the field before the change. /// NewValue: The value of the field after the change. /// ValueType: The data type of the field (e.g., "string", "int", "bool"). /// /// /// /// /// UserIpAddress: (Optional) The IP address of the user performing the action. Useful for tracking location-based information. /// /// /// Once the is constructed, it is asynchronously saved using RecordAuditAsync. /// /// /// A task representing the asynchronous operation of recording the audit log. The task completes when the audit record is successfully stored. /// /// /// Here is an example of how to use this method: /// /// await CreateAuditLogAsync( /// user: HttpContext.User, /// dataOriginal: oldData, /// dataModified: newData, /// reason: "Updated user details" /// ); /// /// #endregion public async Task CreateAuditLogAsync(ClaimsPrincipal? user, Object? dataOriginal, Object? dataModified, string? reason) { if (user == null) { Log.Error("An error occurred: User is null"); return; } AuditRecord auditLogData = new AuditRecord(); JsonComparer jsonComparer = new(); if (dataOriginal != null) { auditLogData.EntityType = IsAnonymousType(dataOriginal.GetType()) ? "AnonymousType" : dataOriginal.GetType().Name; var idProperty = dataOriginal.GetType().GetProperty("Id"); if (idProperty != null && idProperty.PropertyType == typeof(ObjectId)) { ObjectId id = (ObjectId)idProperty.GetValue(dataOriginal); auditLogData.RecordId = id.ToString(); }else if (idProperty != null && idProperty.PropertyType == typeof(string)) { auditLogData.RecordId = idProperty.GetValue(dataOriginal).ToString(); } } else if (dataModified != null) { auditLogData.EntityType = IsAnonymousType(dataModified.GetType()) ? "AnonymousType" : dataModified.GetType().Name; var idProperty = dataModified.GetType().GetProperty("Id"); if (idProperty != null && idProperty.PropertyType == typeof(ObjectId)) { ObjectId id = (ObjectId)idProperty.GetValue(dataModified); //TODO verificar si esta bien obtener el valor asi: auditLogData.RecordId = id.ToString(); }else if (idProperty != null && idProperty.PropertyType == typeof(string)) { auditLogData.RecordId = idProperty.GetValue(dataModified).ToString(); } } auditLogData.ActionTime = DateTime.Now; auditLogData.Reason = string.IsNullOrEmpty(reason) ? "Not specified" : reason; auditLogData.UserIpAddress = string.IsNullOrEmpty(user.FindFirst("IpAddress")?.Value) ? "Not specified" : user.FindFirst("IpAddress")?.Value ?? "Not specified"; auditLogData.UserId = user.FindFirst(ClaimTypes.Name)?.Value ?? "Not specified"; // Asignar ActionType segun las condiciones if (dataOriginal == null || string.IsNullOrEmpty(dataOriginal.ToString())) { auditLogData.ActionType = "create"; } else if (dataModified == null || string.IsNullOrEmpty(dataModified.ToString())) { auditLogData.ActionType = "delete"; } else { auditLogData.ActionType = "update"; } // dataOriginal="{\n \"Id\": \"PV8\",\n \"Items\": [\n {\n \"Code\": \"Temperature\",\n \"CodingSystem\": \"SENSOR\",\n \"OriginalName\": \"Room_Temperature\",\n \"ParentCode\": \"T\",\n \"ParentCodingSystem\": \"Room_Temperature\",\n \"ParentName\": null,\n \"Name\": \"Room_Temperature\",\n \"Units\": \"T\",\n \"ArrowType\": 1,\n \"ShowArrow\": true,\n \"ForceUnits\": false,\n \"MinAlert\": null,\n \"MaxAlert\": null,\n \"MaxWarn\": null,\n \"MinWarn\": null,\n \"WarnColor\": null,\n \"AlertColor\": null,\n \"AlertValues\": null,\n \"WarningValues\": null,\n \"ForceWarn\": false,\n \"ForceAlert\": false,\n \"Alert\": null,\n \"Expires\": null,\n \"ShowOnExpired\": false,\n \"Persist\": null,\n \"ColorOnExpired\": null,\n \"RetentionPolicy\": null,\n \"RetentionPolicyValue\": null,\n \"LevelCondition\": null,\n \"UiConfiguration\": {\n \"screenLabel\": \"screenLabel\"\n },\n \"Alarm\": null,\n \"Description\": null,\n \"RequiredValue\": null,\n \"Preconditions\": null,\n \"CheckObservations\": false,\n \"CreateObservation\": null,\n \"TimeFromMessageTime\": false\n }\n ]\n}" //dataModified = "{\n \"Id\": \"PV8\",\n \"Items\": [\n {\n \"Code\": \"Temperature\",\n \"CodingSystem\": \"SENSOR\",\n \"OriginalName\": \"Room_Temperature\",\n \"ParentCode\": \"T\",\n \"ParentCodingSystem\": \"Room_Temperature\",\n \"ParentName\": null,\n \"Name\": \"Room_Temperature\",\n \"Units\": \"T\",\n \"ArrowType\": 1,\n \"ShowArrow\": true,\n \"ForceUnits\": false,\n \"MinAlert\": null,\n \"MaxAlert\": null,\n \"MaxWarn\": null,\n \"MinWarn\": null,\n \"WarnColor\": null,\n \"AlertColor\": null,\n \"AlertValues\": null,\n \"WarningValues\": null,\n \"ForceWarn\": false,\n \"ForceAlert\": false,\n \"Alert\": null,\n \"Expires\": null,\n \"ShowOnExpired\": false,\n \"Persist\": null,\n \"ColorOnExpired\": null,\n \"RetentionPolicy\": null,\n \"RetentionPolicyValue\": null,\n \"LevelCondition\": null,\n \"UiConfiguration\": {\n \"screenLabel\": \"screenLabel\"\n },\n \"Alarm\": null,\n \"Description\": null,\n \"RequiredValue\": null,\n \"Preconditions\": null,\n \"CheckObservations\": false,\n \"CreateObservation\": null,\n \"TimeFromMessageTime\": false\n },\n {\n \"Code\": \"88\",\n \"CodingSystem\": \"88\",\n \"OriginalName\": \"88\",\n \"ParentCode\": \"88\",\n \"ParentCodingSystem\": \"88\",\n \"ParentName\": null,\n \"Name\": \"88\",\n \"Units\": \"88\",\n \"ArrowType\": 1,\n \"ShowArrow\": true,\n \"ForceUnits\": false,\n \"MinAlert\": null,\n \"MaxAlert\": null,\n \"MaxWarn\": null,\n \"MinWarn\": null,\n \"WarnColor\": null,\n \"AlertColor\": null,\n \"AlertValues\": null,\n \"WarningValues\": null,\n \"ForceWarn\": false,\n \"ForceAlert\": false,\n \"Alert\": null,\n \"Expires\": null,\n \"ShowOnExpired\": false,\n \"Persist\": null,\n \"ColorOnExpired\": null,\n \"RetentionPolicy\": null,\n \"RetentionPolicyValue\": null,\n \"LevelCondition\": null,\n \"UiConfiguration\": {\n \"screenLabel\": \"88\"\n },\n \"Alarm\": null,\n \"Description\": null,\n \"RequiredValue\": null,\n \"Preconditions\": null,\n \"CheckObservations\": false,\n \"CreateObservation\": null,\n \"TimeFromMessageTime\": false\n }\n ]\n}"; auditLogData.Changes = jsonComparer.GetDifferences(dataOriginal, dataModified); await RecordAuditAsync(auditLogData); } // Método auxiliar para verificar si un tipo es anonimo, solo es necesario para pruebas private bool IsAnonymousType(Type type) { return type.IsGenericType && type.Name.StartsWith("<>f__AnonymousType"); } /// /// Creates and records an audit log entry based on the differences between the original and modified data provided in an object. /// /// An object containing all necessary details for the audit log entry, including: /// The type of entity being modified. /// The unique ID of the record being changed. /// The ID of the user performing the action. /// The type of action performed. /// The reason for the action. /// The timestamp of the action. /// The original JSON data. /// The modified JSON data. /// The IP address of the user. public async Task CreateAuditLogAsync(AuditLogData auditLogData) { if (auditLogData == null) throw new ArgumentNullException(nameof(auditLogData)); AuditRecord auditRecord = new AuditRecord(); auditRecord.EntityType = auditLogData.EntityType; auditRecord.RecordId = auditLogData.RecordId; auditRecord.UserId = auditLogData.UserId; auditRecord.ActionTime = auditLogData.ActionTime; auditRecord.ActionType = auditLogData.ActionType; auditRecord.Reason = auditLogData.Reason; auditRecord.UserIpAddress = auditLogData.UserIpAddress; JsonComparer jsonComparer = new(); auditRecord.Changes = jsonComparer.GetDifferences(auditLogData.OriginalJson, auditLogData.ModifiedJson); await RecordAuditAsync(auditRecord); } /// /// Retrieves paginated audit logs. /// /// The page number to retrieve (1-based). /// The number of records per page. /// filter taking into account the user who made the modification. /// filter by the date the modification was made. /// filter by the date the modification was made. /// filter by modified record id in the collection. /// A paginated list of audit records. public async Task> GetAuditLogsBySpecificFilterAsync( SpecificAuditLogFilterDto filterDto) { if (filterDto.pageNumber <= 0) throw new ArgumentException("Page number must be greater than 0.", nameof(filterDto.pageNumber)); if (filterDto.pageSize <= 0) throw new ArgumentException("Page size must be greater than 0.", nameof(filterDto.pageSize)); // Construir filtro dinámico var filter = BuildAuditLogFilter(filterDto.userId, filterDto.recordId, filterDto.entityType, filterDto.startDate, filterDto.endDate, filterDto.actionTime); var totalRecords = await _auditLogRepository.CountDocumentsAsync(filter); var totalPages = (int)Math.Ceiling((double)totalRecords / filterDto.pageSize); var sort = Builders.Sort.Descending(x => x.ActionTime); // Ordenar por fecha más reciente var records = await _auditLogRepository.GetPaginatedAsync(filterDto.pageNumber, filterDto.pageSize, filter, sort); // var records = await _auditLogRepository.GetPaginatedAsync(filterDto.pageNumber, filterDto.pageSize, filter); var recordsDto = records.Select(record => AuditRecordMapper.ToDto(record)).ToList(); return new PaginatedLogsResultDto { CurrentPage = filterDto.pageNumber, PageSize = filterDto.pageSize, TotalRecords = totalRecords, TotalPages = totalPages, Records = recordsDto }; } /// /// Retrieves paginated audit logs with optional full-text search across multiple fields. /// /// The page number to retrieve (1-based). /// The number of records per page. /// Text to search in any field. /// /// filter by the date the modification was made. /// filter by the date the modification was made. /// A paginated list of audit records. public async Task> GetAuditLogsMatchingTextOrDateAsync( AuditLogTextOrDateSearchDTO filtertTextOrDateDto) { if (filtertTextOrDateDto.pageNumber <= 0) throw new ArgumentException("Page number must be greater than 0.", nameof(filtertTextOrDateDto.pageNumber)); if (filtertTextOrDateDto.pageSize <= 0) throw new ArgumentException("Page size must be greater than 0.", nameof(filtertTextOrDateDto.pageSize)); // Construct the text search filter if text_to_search is provided FilterDefinition filter; if (!string.IsNullOrWhiteSpace(filtertTextOrDateDto.searchText) || filtertTextOrDateDto.startDate.HasValue || filtertTextOrDateDto.endDate.HasValue || filtertTextOrDateDto.actionTime.HasValue) { filter = BuildAuditLogFilterByText(filtertTextOrDateDto.searchText, filtertTextOrDateDto.startDate, filtertTextOrDateDto.endDate, filtertTextOrDateDto.actionTime); } else { filter = Builders.Filter.Empty; } var totalRecords = await _auditLogRepository.CountDocumentsAsync(filter); var totalPages = (int)Math.Ceiling((double)totalRecords / filtertTextOrDateDto.pageSize); var sort = Builders.Sort.Descending(x => x.ActionTime); // Ordenar por fecha más reciente var records = await _auditLogRepository.GetPaginatedAsync(filtertTextOrDateDto.pageNumber, filtertTextOrDateDto.pageSize, filter,sort); var recordsDto = records.Select(record => AuditRecordMapper.ToDto(record)).ToList(); return new PaginatedLogsResultDto { CurrentPage = filtertTextOrDateDto.pageNumber, PageSize = filtertTextOrDateDto.pageSize, TotalRecords = totalRecords, TotalPages = totalPages, Records = recordsDto }; } private static FilterDefinition BuildAuditLogFilter( string? userId, string? recordId, string? entityType, DateTime? startDate, DateTime? endDate, DateTime? actionTime) { var builder = Builders.Filter; var filters = new List>(); if (!string.IsNullOrEmpty(userId)) { filters.Add(builder.Eq(x => x.UserId, userId)); } if (!string.IsNullOrEmpty(recordId)) { filters.Add(builder.Eq(x => x.RecordId, recordId)); } if (startDate.HasValue) { filters.Add(builder.Gte(x => x.ActionTime, startDate.Value)); } if (endDate.HasValue) { filters.Add(builder.Lte(x => x.ActionTime, endDate.Value)); } if (actionTime.HasValue) { filters.Add(builder.Eq(x => x.ActionTime, actionTime)); } if (!string.IsNullOrEmpty(entityType)) { filters.Add(builder.Eq(x => x.EntityType, entityType)); } return filters.Count > 0 ? builder.And(filters) : builder.Empty; } private static FilterDefinition BuildAuditLogFilterByText( string? searchText, DateTime? startDate = null, DateTime? endDate = null, DateTime? actionTime = null) { var builder = Builders.Filter; var filters = new List>(); // Filtro por rango de fechas if (startDate.HasValue && endDate.HasValue) { filters.Add(builder.And( builder.Gte(x => x.ActionTime, startDate.Value), builder.Lte(x => x.ActionTime, endDate.Value) )); } else if (startDate.HasValue) { filters.Add(builder.Gte(x => x.ActionTime, startDate.Value)); } else if (endDate.HasValue) { filters.Add(builder.Lte(x => x.ActionTime, endDate.Value)); } else if (actionTime.HasValue) { filters.Add(builder.Eq(x => x.ActionTime, actionTime)); } // Filtro por texto si se proporciona if (!string.IsNullOrEmpty(searchText)) { var regexFilter = new BsonRegularExpression(new Regex(searchText, RegexOptions.IgnoreCase)); filters.Add(builder.Or( builder.Regex(x => x.UserId, regexFilter), builder.Regex(x => x.RecordId, regexFilter), builder.Regex(x => x.EntityType, regexFilter), builder.Regex(x => x.ActionType, regexFilter) )); } // Combina todos los filtros con AND if (filters.Any()) { return builder.And(filters); } return builder.Empty; // Sin filtros, devuelve todos los documentos } }