diff --git a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Repositories/NotifyAuditRepository.cs b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Repositories/NotifyAuditRepository.cs index 2a861daba..f04e7e6f8 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Repositories/NotifyAuditRepository.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Repositories/NotifyAuditRepository.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using StellaOps.Audit.Emission; using StellaOps.Notify.Persistence.EfCore.Context; using StellaOps.Notify.Persistence.Postgres.Models; @@ -10,11 +11,13 @@ public sealed class NotifyAuditRepository : INotifyAuditRepository private const int CommandTimeoutSeconds = 30; private readonly NotifyDataSource _dataSource; private readonly ILogger _logger; + private readonly IAuditEventEmitter? _timelineEmitter; - public NotifyAuditRepository(NotifyDataSource dataSource, ILogger logger) + public NotifyAuditRepository(NotifyDataSource dataSource, ILogger logger, IAuditEventEmitter? timelineEmitter = null) { _dataSource = dataSource; _logger = logger; + _timelineEmitter = timelineEmitter; } public async Task CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default) @@ -23,9 +26,60 @@ public sealed class NotifyAuditRepository : INotifyAuditRepository await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName()); dbContext.Audit.Add(audit); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // DEPRECATE-001: dual-write to Timeline. Fire-and-forget; local write remains authoritative. + if (_timelineEmitter is not null) + { + try + { + await _timelineEmitter.EmitAsync(MapToTimelinePayload(audit), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to emit notify audit event to Timeline (local write succeeded, id={AuditId})", audit.Id); + } + } + return audit.Id; } + private static AuditEventPayload MapToTimelinePayload(NotifyAuditEntity audit) + { + var details = new Dictionary(StringComparer.Ordinal) + { + ["localAuditId"] = audit.Id + }; + if (!string.IsNullOrWhiteSpace(audit.Details)) + { + details["details"] = audit.Details; + } + + return new AuditEventPayload + { + Id = $"notify-{audit.Id}", + Timestamp = audit.CreatedAt, + Module = "notify", + Action = audit.Action ?? "unknown", + Severity = "info", + Actor = new AuditActorPayload + { + Id = audit.UserId?.ToString() ?? "notify-system", + Name = audit.UserId?.ToString() ?? "notify-system", + Type = audit.UserId.HasValue ? "user" : "system" + }, + Resource = new AuditResourcePayload + { + Type = audit.ResourceType ?? "notify_resource", + Id = audit.ResourceId ?? audit.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) + }, + Description = audit.Action ?? "notify audit event", + Details = details, + CorrelationId = audit.CorrelationId, + TenantId = audit.TenantId, + Tags = new[] { "notify", audit.Action ?? "unknown" } + }; + } + public async Task> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default) { await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false); diff --git a/src/Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj b/src/Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj index 53b712451..f7e70b667 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj +++ b/src/Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj @@ -23,6 +23,7 @@ +