From 4dd0899209d077ce4c0ce55bc370124b99e0c27d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 22 Oct 2026 12:31:37 +0200 Subject: [PATCH] Implemented audit logging --- .../Interceptors/AuditInterceptor.cs | 222 +- .../Services/Shared/AuditService.cs | 44 +- .../src/pages/SettingsPage.tsx | 463 ++- PostFundManagement.ConsoleUI/src/types.ts | 2 + .../Entities/AuditLog.cs | 4 +- .../Extensions/Mappers.cs | 5 +- PostFundManagement.Domain/Models/AuditLog.cs | 6 +- ...022101354_AddedErrorToAuditLog.Designer.cs | 2476 +++++++++++++++++ .../20261022101354_AddedErrorToAuditLog.cs | 28 + .../ApplicationDbContextModelSnapshot.cs | 3 + .../src/services/api/models.ts | 2 + PostFundManagement.Tests/AuditLogTests.cs | 312 +++ 12 files changed, 3477 insertions(+), 90 deletions(-) create mode 100644 PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.Designer.cs create mode 100644 PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.cs create mode 100644 PostFundManagement.Tests/AuditLogTests.cs diff --git a/PostFundManagement.Application/Interceptors/AuditInterceptor.cs b/PostFundManagement.Application/Interceptors/AuditInterceptor.cs index 5b30abc..af67cba 100644 --- a/PostFundManagement.Application/Interceptors/AuditInterceptor.cs +++ b/PostFundManagement.Application/Interceptors/AuditInterceptor.cs @@ -1,11 +1,39 @@ -using PostFundManagement.Application.Services.Shared; +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using Castle.DynamicProxy; +using FluentResults; +using Microsoft.AspNetCore.Http; +using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain.Attributes; namespace PostFundManagement.Application.Interceptors; public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor { - public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed(); + public void InterceptSynchronous(IInvocation invocation) + { + var method = invocation.MethodInvocationTarget ?? invocation.Method; + var auditAttr = method.GetCustomAttribute() + ?? invocation.Method.GetCustomAttribute(); + + if (auditAttr == null) + { + invocation.Proceed(); + return; + } + + try + { + invocation.Proceed(); + AuditAction(invocation, invocation.ReturnValue, null); + } + catch (Exception ex) + { + AuditAction(invocation, null, ex); + throw; + } + } public void InterceptAsynchronous(IInvocation invocation) => invocation.ReturnValue = InternalInterceptAsynchronous(invocation); @@ -13,49 +41,177 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce public void InterceptAsynchronous(IInvocation invocation) => invocation.ReturnValue = InternalInterceptAsynchronous(invocation); - private static async Task InternalInterceptAsynchronous(IInvocation invocation) + private async Task InternalInterceptAsynchronous(IInvocation invocation) { - invocation.Proceed(); - - var task = (Task)invocation.ReturnValue; - - await task.ConfigureAwait(false); - } - - private async Task InternalInterceptAsynchronous(IInvocation invocation) - { - invocation.Proceed(); - - var task = (Task)invocation.ReturnValue; - var resultObj = await task.ConfigureAwait(false); - var method = invocation.MethodInvocationTarget ?? invocation.Method; var auditAttr = method.GetCustomAttribute() ?? invocation.Method.GetCustomAttribute(); - if (auditAttr == null) return resultObj; - - if (resultObj is IResultBase result && result.IsSuccess) + if (auditAttr == null) { - try - { - var action = ResolveActionName(method.Name); - var entityName = ResolveEntityName(method, resultObj, invocation.Arguments); - var entityId = ResolveEntityId(resultObj, invocation.Arguments); - var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments); - var performedBy = ResolveUserId(); + invocation.Proceed(); + var t = (Task)invocation.ReturnValue; + await t.ConfigureAwait(false); + return; + } + try + { + invocation.Proceed(); + var task = (Task)invocation.ReturnValue; + await task.ConfigureAwait(false); + await AuditActionAsync(invocation, null, null).ConfigureAwait(false); + } + catch (Exception ex) + { + await AuditActionAsync(invocation, null, ex).ConfigureAwait(false); + throw; + } + } + + private async Task InternalInterceptAsynchronous(IInvocation invocation) + { + var method = invocation.MethodInvocationTarget ?? invocation.Method; + var auditAttr = method.GetCustomAttribute() + ?? invocation.Method.GetCustomAttribute(); + + if (auditAttr == null) + { + invocation.Proceed(); + var t = (Task)invocation.ReturnValue; + return await t.ConfigureAwait(false); + } + + TResult resultObj; + try + { + invocation.Proceed(); + var task = (Task)invocation.ReturnValue; + resultObj = await task.ConfigureAwait(false); + } + catch (Exception ex) + { + await AuditActionAsync(invocation, null, ex).ConfigureAwait(false); + throw; + } + + await AuditActionAsync(invocation, resultObj, null).ConfigureAwait(false); + return resultObj; + } + + private async Task AuditActionAsync(IInvocation invocation, object? resultObj, Exception? exception) + { + try + { + var method = invocation.MethodInvocationTarget ?? invocation.Method; + var action = ResolveActionName(method.Name); + var entityName = ResolveEntityName(method, resultObj, invocation.Arguments); + var entityId = ResolveEntityId(resultObj, invocation.Arguments); + var changes = BuildPayloadSummary(method.GetParameters(), invocation.Arguments); + var performedBy = ResolveUserId(); + var tenantId = ResolveTenantId(invocation.Arguments); + + if (exception != null) + { + var errorDetails = $"[Exception] {exception.GetType().Name}: {exception.Message}\n{exception.StackTrace}"; await auditService.LogActionAsync( entityId: entityId, entityName: entityName, action: action, changes: changes, - performedBy: performedBy).ConfigureAwait(false); + performedBy: performedBy, + error: errorDetails, + tenantId: tenantId).ConfigureAwait(false); + return; } - catch { /* eat all exceptions, don't interrupt program flow */ } - } - return resultObj; + if (resultObj is IResultBase result) + { + if (result.IsSuccess) + { + await auditService.LogActionAsync( + entityId: entityId, + entityName: entityName, + action: action, + changes: changes, + performedBy: performedBy, + error: null, + tenantId: tenantId).ConfigureAwait(false); + } + else if (result.IsFailed) + { + var errorMessages = new List(); + if (result.Reasons != null) + { + foreach (var reason in result.Reasons) + { + if (reason is IExceptionalError exceptionalError) + errorMessages.Add($"{exceptionalError.Message} ({exceptionalError.Exception.GetType().Name}: {exceptionalError.Exception.Message})\n{exceptionalError.Exception.StackTrace}"); + else if (reason is IError error) + errorMessages.Add(error.Message); + } + } + + var rawErrorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty); + var errorType = string.IsNullOrWhiteSpace(rawErrorType) || rawErrorType == "Reason" ? "Error" : rawErrorType; + var errorsString = string.Join("; ", errorMessages); + var errorDetails = $"[{errorType}] {errorsString}"; + + await auditService.LogActionAsync( + entityId: entityId, + entityName: entityName, + action: action, + changes: changes, + performedBy: performedBy, + error: errorDetails, + tenantId: tenantId).ConfigureAwait(false); + } + } + else + { + await auditService.LogActionAsync( + entityId: entityId, + entityName: entityName, + action: action, + changes: changes, + performedBy: performedBy, + error: null, + tenantId: tenantId).ConfigureAwait(false); + } + } + catch + { + /* eat all exceptions, don't interrupt program flow */ + } + } + + private void AuditAction(IInvocation invocation, object? resultObj, Exception? exception) + { + try + { + AuditActionAsync(invocation, resultObj, exception).GetAwaiter().GetResult(); + } + catch + { + /* eat all exceptions, don't interrupt program flow */ + } + } + + private static long? ResolveTenantId(object[] args) + { + foreach (var arg in args) + { + if (arg == null || arg is CancellationToken or IFormFile) continue; + var prop = arg.GetType().GetProperty("TenantId"); + if (prop != null) + { + var val = prop.GetValue(arg); + if (val is long l && l > 0) return l; + if (val is int i && i > 0) return i; + if (val is string s && long.TryParse(s, out var parsed) && parsed > 0) return parsed; + } + } + return null; } private static string ResolveActionName(string methodName) => @@ -73,7 +229,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce { var genericDef = returnType.GetGenericTypeDefinition(); - if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>)) + if (genericDef == typeof(Task<>)) innerType = returnType.GetGenericArguments()[0]; } @@ -208,4 +364,4 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce return Guid.TryParse(sub, out var guid) ? guid : Guid.Empty; } -} \ No newline at end of file +} diff --git a/PostFundManagement.Application/Services/Shared/AuditService.cs b/PostFundManagement.Application/Services/Shared/AuditService.cs index 41d6641..a6c9eb5 100644 --- a/PostFundManagement.Application/Services/Shared/AuditService.cs +++ b/PostFundManagement.Application/Services/Shared/AuditService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain; +using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; @@ -10,27 +10,22 @@ namespace PostFundManagement.Application.Services.Shared; public sealed class AuditService(IDbContextFactory contextFactory, ITenantContextService tenantContextService) : IService { public async Task> GetAuditLogsAsync(CancellationToken cancellationToken = default) => - await Result.Try((Func>>)(async () => - { - await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - - var pagination = new Pagination { Page = 1, PageSize = 500, Index = 1 }; - - var logs = await context.AuditLogs - .OrderByDescending(o => o.Id) - .Skip((pagination.Page - 1) * pagination.PageSize) - .Take(pagination.PageSize) - .ToListAsync(cancellationToken); - - return Result.Ok(logs.Select(p => p.Map()).ToArray()); - })); + await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken); public async Task> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - var logs = await context.AuditLogs + var query = context.AuditLogs.AsQueryable(); + + if (!tenantContextService.IsAdmin) + { + var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L; + query = query.Where(o => o.TenantId == currentTenantId); + } + + var logs = await query .OrderByDescending(o => o.Id) .Skip((pagination.Page - 1) * pagination.PageSize) .Take(pagination.PageSize) @@ -39,11 +34,23 @@ public sealed class AuditService(IDbContextFactory context return Result.Ok(logs.Select(p => p.Map()).ToArray()); })); - public async Task LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) => + public async Task LogActionAsync( + string entityId, + string entityName, + string action, + string[] changes, + Guid performedBy, + string? error = null, + long? tenantId = null, + CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var resolvedTenantId = (tenantId.HasValue && tenantId.Value > 0) + ? tenantId.Value + : (tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L); + context.Add(new Domain.Entities.AuditLog { EntityId = entityId, @@ -52,7 +59,8 @@ public sealed class AuditService(IDbContextFactory context EntityName = entityName, PerformedBy = performedBy, Timestamp = DateTime.UtcNow, - TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null, + TenantId = resolvedTenantId, + Error = error, }); return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New); diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 499fa88..476e16b 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -20,6 +20,8 @@ import { Award as AwardIcon, FileText, AlertCircle, + AlertTriangle, + AlertOctagon, Layers, Activity, Calendar, @@ -370,8 +372,14 @@ export const SettingsPage: React.FC = () => { const [auditSearchTerm, setAuditSearchTerm] = useState(''); const [auditEntityFilter, setAuditEntityFilter] = useState('all'); const [auditActionFilter, setAuditActionFilter] = useState('all'); + const [auditTypeFilter, setAuditTypeFilter] = useState<'all' | 'audits' | 'exceptions'>('all'); + const [auditPage, setAuditPage] = useState(1); + const [auditPageSize, setAuditPageSize] = useState(10); const [selectedAuditLog, setSelectedAuditLog] = useState(null); const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false); + const [selectedExceptionLog, setSelectedExceptionLog] = useState(null); + const [isExceptionDetailModalOpen, setIsExceptionDetailModalOpen] = useState(false); + const [copiedExceptionTrace, setCopiedExceptionTrace] = useState(false); const [copiedAuditPayload, setCopiedAuditPayload] = useState(false); const [expandedAuditLogIds, setExpandedAuditLogIds] = useState>({}); const [copiedUserId, setCopiedUserId] = useState(null); @@ -402,11 +410,14 @@ export const SettingsPage: React.FC = () => { isFetching: isFetchingAuditLogs, } = useQuery({ queryKey: ['audit-logs'], - queryFn: () => getAuditLogs(1, 100), + queryFn: () => getAuditLogs(1, 500), }); + const isLogException = (log: AuditLog) => Boolean(log.error || log.isError); + const auditExportColumns: ExportColumn[] = [ { header: 'Event ID', key: 'id' }, + { header: 'Event Type', key: 'error', format: (val, row) => (val || row?.isError ? 'EXCEPTION' : 'AUDIT') }, { header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) }, { header: 'Action', key: 'action', format: (val) => val || '—' }, { header: 'Entity Name', key: 'entityName', format: (val) => val || '—' }, @@ -421,10 +432,11 @@ export const SettingsPage: React.FC = () => { }, { header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') }, { - header: 'Changes', + header: 'Changes / Arguments', key: 'changes', format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'), }, + { header: 'Error Details', key: 'error', format: (val) => val || '—' }, ]; const uniqueAuditEntities = Array.from( @@ -435,6 +447,13 @@ export const SettingsPage: React.FC = () => { ); const filteredAuditLogs = auditLogs.filter((log) => { + const isEx = isLogException(log); + if (auditTypeFilter === 'audits' && isEx) { + return false; + } + if (auditTypeFilter === 'exceptions' && !isEx) { + return false; + } if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) { return false; } @@ -449,6 +468,7 @@ export const SettingsPage: React.FC = () => { const entityId = String(log.entityId || '').toLowerCase(); const performedBy = (log.performedBy || '').toLowerCase(); const tenant = String(log.tenantId || '').toLowerCase(); + const err = (log.error || '').toLowerCase(); const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : ''; return ( id.includes(q) || @@ -457,6 +477,7 @@ export const SettingsPage: React.FC = () => { entityId.includes(q) || performedBy.includes(q) || tenant.includes(q) || + err.includes(q) || changes.includes(q) ); }); @@ -468,6 +489,13 @@ export const SettingsPage: React.FC = () => { handleSort: handleAuditSort, } = useTableSort(filteredAuditLogs, 'timestamp', 'desc'); + const totalAuditRecords = sortedAuditLogs.length; + const totalAuditPages = Math.max(1, Math.ceil(totalAuditRecords / auditPageSize)); + const paginatedAuditLogs = React.useMemo(() => { + const start = (auditPage - 1) * auditPageSize; + return sortedAuditLogs.slice(start, start + auditPageSize); + }, [sortedAuditLogs, auditPage, auditPageSize]); + const userExportColumns: ExportColumn[] = [ { header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' }, { header: 'User Identifier', key: 'id' }, @@ -1532,7 +1560,10 @@ export const SettingsPage: React.FC = () => { setAuditSearchTerm(e.target.value)} + onChange={(e) => { + setAuditSearchTerm(e.target.value); + setAuditPage(1); + }} placeholder="Search audit trail by action, entity name, ID, performed by UUID..." className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500" /> @@ -1543,7 +1574,10 @@ export const SettingsPage: React.FC = () => { Entity: setAuditActionFilter(e.target.value)} + onChange={(e) => { + setAuditActionFilter(e.target.value); + setAuditPage(1); + }} className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer" > @@ -1580,13 +1617,31 @@ export const SettingsPage: React.FC = () => { - {(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all') && ( +
+ Type: + +
+ + {(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all') && ( @@ -1714,12 +1788,33 @@ export const SettingsPage: React.FC = () => { {formatDate(log.timestamp)} - #{log.id} +
+ #{log.id} + {isException && ( + + + Exception + + )} +
- - {badge.label} - + {isException ? ( + + + {log.action || 'EXCEPTION'} + + FAIL + + + ) : ( + + {badge.label} + + )} { - + {isException ? ( + + ) : ( + + )} @@ -1771,11 +1882,21 @@ export const SettingsPage: React.FC = () => {
- + {isException ? ( + + ) : ( + + )}

- Recorded Changes Content (Event #{log.id}) + {isException + ? `Captured Exception & Invocation Payload (Event #${log.id})` + : `Recorded Changes Content (Event #${log.id})`}

- + {changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
@@ -1804,6 +1925,32 @@ export const SettingsPage: React.FC = () => {
+ {/* Exception Diagnostic Banner inside expanded shelf */} + {log.error && ( +
+
+ + + Captured Exception Diagnostic Trace + + +
+
+                                        {log.error}
+                                      
+
+ )} + {/* Changes List */} {Array.isArray(log.changes) && log.changes.length > 0 ? (
@@ -1823,7 +1970,7 @@ export const SettingsPage: React.FC = () => {
) : (
- No itemized field changes content recorded for this event. + No itemized field changes or invocation arguments recorded for this event.
)} @@ -1837,6 +1984,64 @@ export const SettingsPage: React.FC = () => { + + {/* Pagination Controls */} + {totalAuditRecords > 0 && ( +
+
+ + Showing {Math.min((auditPage - 1) * auditPageSize + 1, totalAuditRecords)} to{' '} + {Math.min(auditPage * auditPageSize, totalAuditRecords)} of{' '} + {totalAuditRecords} records + +
+ +
+
+ Per page: + +
+ +
+ + + + {auditPage} / {totalAuditPages} + + + +
+
+
+ )} )} @@ -1990,7 +2195,195 @@ export const SettingsPage: React.FC = () => { {/* ============================================================== */} - {/* Modal: Dispatch Invitation */} + {/* Modal: Exception Detail Inspection Modal */} + {/* ============================================================== */} + { + setIsExceptionDetailModalOpen(false); + setSelectedExceptionLog(null); + setCopiedExceptionTrace(false); + }} + title={`Captured Exception: Event #${selectedExceptionLog?.id || ''}`} + subtitle="Runtime exception diagnostics and invocation telemetry" + maxWidth="2xl" + > + {selectedExceptionLog && ( +
+ {/* Header Alert Box */} +
+
+
+
+ +
+
+ System Exception Captured + + Action: {selectedExceptionLog.action || 'Unknown'} + +
+
+ + + FAILED EXECUTION + +
+ + {/* Entity and Context Details */} +
+
+ Entity / Target: + + {selectedExceptionLog.entityName || 'General'}{' '} + {selectedExceptionLog.entityId ? `(#${selectedExceptionLog.entityId})` : ''} + +
+
+ Logged Timestamp: + {formatDate(selectedExceptionLog.timestamp)} +
+
+ Triggered By: +
+ + {getUserEmail(selectedExceptionLog.performedBy) !== '—' + ? getUserEmail(selectedExceptionLog.performedBy) + : (selectedExceptionLog.performedBy || 'System')} + + {selectedExceptionLog.performedBy && ( + + )} +
+
+
+ Tenant Scope: + + {selectedExceptionLog.tenantId ? `Tenant #${selectedExceptionLog.tenantId}` : '—'} + +
+
+
+ + {/* Error Message & Stack Trace Section */} +
+
+ + + Exception Diagnostic Message & Trace + + +
+ +
+
+                  {selectedExceptionLog.error || 'No stack trace or error message captured.'}
+                
+
+
+ + {/* Invocation Arguments / Parameters */} +
+ + + Invocation Arguments & Parameters + + + {Array.isArray(selectedExceptionLog.changes) && selectedExceptionLog.changes.length > 0 ? ( +
+ {selectedExceptionLog.changes.map((param, idx) => ( +
+ + #{idx + 1} + + + {param} + +
+ ))} +
+ ) : ( +
+ No parameters or arguments recorded for this invocation. +
+ )} +
+ + {/* Raw JSON Data Viewer */} +
+
+ + Raw Exception Record (JSON): + + +
+
+                {JSON.stringify(selectedExceptionLog, null, 2)}
+              
+
+ + {/* Modal Footer */} +
+ +
+
+ )} +
{/* ============================================================== */} builder.Property(f => f.Changes).IsRequired(false); builder.Property(f => f.PerformedBy).IsRequired(); builder.Property(f => f.Timestamp).IsRequired().HasDefaultValueSql("now()"); + builder.Property(f => f.Error).IsRequired(false); + builder.Ignore(f => f.IsError); } -} \ No newline at end of file +} diff --git a/PostFundManagement.Domain/Extensions/Mappers.cs b/PostFundManagement.Domain/Extensions/Mappers.cs index 904224d..2aeb797 100644 --- a/PostFundManagement.Domain/Extensions/Mappers.cs +++ b/PostFundManagement.Domain/Extensions/Mappers.cs @@ -65,7 +65,8 @@ public static class Mappers EntityId = entity.EntityId, EntityName = entity.EntityName, PerformedBy = entity.PerformedBy, - Timestamp = entity.Timestamp + Timestamp = entity.Timestamp, + Error = entity.Error }; public static Award Map(this Entities.Award entity) => new() @@ -398,4 +399,4 @@ public static class Mappers Role = entity.Role, IdNumber = entity.IdNumber }; -} \ No newline at end of file +} diff --git a/PostFundManagement.Domain/Models/AuditLog.cs b/PostFundManagement.Domain/Models/AuditLog.cs index 1bcd1a2..158a49f 100644 --- a/PostFundManagement.Domain/Models/AuditLog.cs +++ b/PostFundManagement.Domain/Models/AuditLog.cs @@ -17,4 +17,8 @@ public class AuditLog public Guid PerformedBy { get; set; } public DateTime Timestamp { get; set; } -} \ No newline at end of file + + public string? Error { get; set; } + + public bool IsError => !string.IsNullOrWhiteSpace(Error); +} diff --git a/PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.Designer.cs b/PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.Designer.cs new file mode 100644 index 0000000..38ee2fb --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.Designer.cs @@ -0,0 +1,2476 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PostFundManagement.Infrastructure.Database; + +#nullable disable + +namespace PostFundManagement.Infrastructure.Database.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20261022101354_AddedErrorToAuditLog")] + partial class AddedErrorToAuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Activity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ApprovedBy") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MilestoneId") + .HasColumnType("bigint"); + + b.Property("PerformedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedBy"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("MilestoneId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Activities", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection("Changes") + .HasColumnType("text[]"); + + b.Property("EntityId") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("PerformedBy") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Award", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ApprovedBy") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("OrganisationId") + .HasColumnType("bigint"); + + b.Property("ProgrammeId") + .HasColumnType("bigint"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedBy"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("OrganisationId"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Awards", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Beneficiary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Location") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("ReachedCount") + .HasColumnType("integer"); + + b.Property("TargetCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Beneficiaries", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ApprovedBy") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedBy"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.ToTable("Budgets", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.ChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApprovedBy") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("RevisedBudget") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RevisedEndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedBy"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.ToTable("ChangeRequests", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.ComplianceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Requirements") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VerifiedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("VerifiedBy"); + + b.ToTable("ComplianceItems", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("EffectiveAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalSignatureId") + .HasColumnType("text"); + + b.Property("SignedDocumentUrl") + .HasColumnType("text"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("TotalValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("AwardId") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TenantId"); + + b.ToTable("Contracts", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Disbursement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("MilestoneId") + .HasColumnType("bigint"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("MilestoneId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Disbursements", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Evidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DocumentUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("IndicatorId") + .HasColumnType("bigint"); + + b.Property("MilestoneId") + .HasColumnType("bigint"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("9"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("IndicatorId"); + + b.HasIndex("MilestoneId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Evidences", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Indicator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BaselineAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("TargetAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UnitOfMeasure") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("2"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.ToTable("Indicators", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Instrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProgrammeId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("2"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("TenantId"); + + b.ToTable("Instruments", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardId") + .HasColumnType("bigint"); + + b.Property("ContractId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("InvitedOrganisationId") + .HasColumnType("bigint"); + + b.Property("OrganisationId") + .HasColumnType("bigint"); + + b.Property("Recipient") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AwardId"); + + b.HasIndex("ContractId"); + + b.HasIndex("InvitedOrganisationId"); + + b.HasIndex("OrganisationId"); + + b.HasIndex("Recipient"); + + b.HasIndex("TenantId"); + + b.ToTable("Invites", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Issue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedTo") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Resolution") + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedTo"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.ToTable("Issues", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Milestone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Milestones", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Destination") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("OrganisationId") + .HasColumnType("bigint"); + + b.Property("Platform") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("Recipient") + .HasColumnType("uuid"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("Subject") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganisationId"); + + b.HasIndex("Recipient"); + + b.HasIndex("TenantId"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Organisation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RegistrationNo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("5"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UserId"); + + b.ToTable("Organisations", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Outcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BeneficiaryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BeneficiaryId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TenantId"); + + b.ToTable("Outcomes", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Portfolio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganisationId") + .HasColumnType("bigint"); + + b.Property("OwnedBy") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("OrganisationId"); + + b.HasIndex("OwnedBy"); + + b.HasIndex("TenantId"); + + b.ToTable("Portfolios", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Programme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnedBy") + .HasColumnType("uuid"); + + b.Property("PortfolioId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("OwnedBy"); + + b.HasIndex("PortfolioId"); + + b.HasIndex("TenantId"); + + b.ToTable("Programmes", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProgrammeId") + .HasColumnType("bigint"); + + b.Property("ProjectType") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("10"); + + b.Property("Sector") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("11"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Risk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Impact") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("Likelihood") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("3"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.ToTable("Risks", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Rule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProgrammeId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("2"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Rules", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.SiteVisit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Findings") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("InspectorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("VisitedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("TenantId"); + + b.ToTable("SiteVisits", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.TenantOrganisation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("OrganisationId") + .HasColumnType("bigint"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("OrganisationId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantOrganisations", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.TenantUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("1"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IdNumber") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValueSql("2"); + + b.HasKey("Id"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Activity", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApprovedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Milestone", "Milestone") + .WithMany() + .HasForeignKey("MilestoneId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Approver"); + + b.Navigation("Creator"); + + b.Navigation("Milestone"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Award", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApprovedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Organisation", "Organisation") + .WithMany() + .HasForeignKey("OrganisationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Programme", "Programme") + .WithMany("Awards") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany("Awards") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Approver"); + + b.Navigation("Creator"); + + b.Navigation("Organisation"); + + b.Navigation("Programme"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Beneficiary", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Budget", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApprovedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Approver"); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.ChangeRequest", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApprovedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Approver"); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.ComplianceItem", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Verifier") + .WithMany() + .HasForeignKey("VerifiedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("Verifier"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Contract", b => + { + b.HasOne("PostFundManagement.Domain.Entities.Award", "Award") + .WithOne("Contract") + .HasForeignKey("PostFundManagement.Domain.Entities.Contract", "AwardId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Award"); + + b.Navigation("Creator"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Disbursement", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Milestone", "Milestone") + .WithMany() + .HasForeignKey("MilestoneId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Creator"); + + b.Navigation("Milestone"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Evidence", b => + { + b.HasOne("PostFundManagement.Domain.Entities.Activity", "Activity") + .WithMany("Evidences") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Indicator", "Indicator") + .WithMany() + .HasForeignKey("IndicatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Milestone", "Milestone") + .WithMany() + .HasForeignKey("MilestoneId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Activity"); + + b.Navigation("Creator"); + + b.Navigation("Indicator"); + + b.Navigation("Milestone"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Indicator", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Instrument", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Programme", "Programme") + .WithMany("Instruments") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Programme"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Invite", b => + { + b.HasOne("PostFundManagement.Domain.Entities.Award", "Award") + .WithMany() + .HasForeignKey("AwardId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Contract", "Contract") + .WithMany() + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Organisation", "InvitedOrganisation") + .WithMany() + .HasForeignKey("InvitedOrganisationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Organisation", "Organisation") + .WithMany() + .HasForeignKey("OrganisationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("Recipient") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Award"); + + b.Navigation("Contract"); + + b.Navigation("InvitedOrganisation"); + + b.Navigation("Organisation"); + + b.Navigation("RecipientUser"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Issue", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Assignee") + .WithMany() + .HasForeignKey("AssignedTo") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Assignee"); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Milestone", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Notification", b => + { + b.HasOne("PostFundManagement.Domain.Entities.Organisation", "Organisation") + .WithMany() + .HasForeignKey("OrganisationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("Recipient") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organisation"); + + b.Navigation("RecipientUser"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Organisation", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Creator"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Outcome", b => + { + b.HasOne("PostFundManagement.Domain.Entities.Beneficiary", "Beneficiary") + .WithMany() + .HasForeignKey("BeneficiaryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Beneficiary"); + + b.Navigation("Creator"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Portfolio", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Organisation", "Organisation") + .WithMany() + .HasForeignKey("OrganisationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Organisation"); + + b.Navigation("Owner"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Programme", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Portfolio", "Portfolio") + .WithMany("Programmes") + .HasForeignKey("PortfolioId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Owner"); + + b.Navigation("Portfolio"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Project", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Programme", "Programme") + .WithMany("Projects") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Creator"); + + b.Navigation("Programme"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Risk", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Rule", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Programme", "Programme") + .WithMany("Rules") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Creator"); + + b.Navigation("Programme"); + + b.Navigation("Tenant"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.SiteVisit", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Project"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Tenant", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "Updator") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Creator"); + + b.Navigation("Updator"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.TenantOrganisation", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Organisation", "Organisation") + .WithMany() + .HasForeignKey("OrganisationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Organisation"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.TenantUser", b => + { + b.HasOne("PostFundManagement.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PostFundManagement.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Activity", b => + { + b.Navigation("Evidences"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Award", b => + { + b.Navigation("Contract"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Portfolio", b => + { + b.Navigation("Programmes"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Programme", b => + { + b.Navigation("Awards"); + + b.Navigation("Instruments"); + + b.Navigation("Projects"); + + b.Navigation("Rules"); + }); + + modelBuilder.Entity("PostFundManagement.Domain.Entities.Project", b => + { + b.Navigation("Awards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.cs b/PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.cs new file mode 100644 index 0000000..82fcd36 --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/Migrations/20261022101354_AddedErrorToAuditLog.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PostFundManagement.Infrastructure.Database.Migrations +{ + /// + public partial class AddedErrorToAuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Error", + table: "AuditLogs", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Error", + table: "AuditLogs"); + } + } +} diff --git a/PostFundManagement.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs b/PostFundManagement.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs index ae5b0a7..961ef59 100644 --- a/PostFundManagement.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/PostFundManagement.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs @@ -125,6 +125,9 @@ namespace PostFundManagement.Infrastructure.Database.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("Error") + .HasColumnType("text"); + b.Property("PerformedBy") .HasColumnType("uuid"); diff --git a/PostFundManagement.PartnerUI/src/services/api/models.ts b/PostFundManagement.PartnerUI/src/services/api/models.ts index 26654a4..e674ffe 100644 --- a/PostFundManagement.PartnerUI/src/services/api/models.ts +++ b/PostFundManagement.PartnerUI/src/services/api/models.ts @@ -537,6 +537,8 @@ export interface AuditLog { timestamp?: string; performedBy?: string; details?: string; + error?: string | null; + isError?: boolean; [key: string]: any; } diff --git a/PostFundManagement.Tests/AuditLogTests.cs b/PostFundManagement.Tests/AuditLogTests.cs new file mode 100644 index 0000000..45cb75f --- /dev/null +++ b/PostFundManagement.Tests/AuditLogTests.cs @@ -0,0 +1,312 @@ +using System.Reflection; +using System.Security.Claims; +using Castle.DynamicProxy; +using FluentResults; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PostFundManagement.Application.Interceptors; +using PostFundManagement.Application.Services.Shared; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Attributes; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Infrastructure.Database; +using Entities = PostFundManagement.Domain.Entities; +using Models = PostFundManagement.Domain.Models; + +namespace PostFundManagement.Tests; + +public class AuditLogTests +{ + [Fact] + public void AuditLog_WhenErrorNull_IsErrorIsFalse() + { + var model = new Models.AuditLog + { + Id = 1, + TenantId = 1, + Action = "Test", + Error = null + }; + + Assert.False(model.IsError); + } + + [Fact] + public void AuditLog_WhenErrorPopulated_IsErrorIsTrue() + { + var model = new Models.AuditLog + { + Id = 2, + TenantId = 1, + Action = "Test", + Error = "[ValidationError] Something went wrong" + }; + + Assert.True(model.IsError); + Assert.Equal("[ValidationError] Something went wrong", model.Error); + } + + [Fact] + public void AuditLog_Map_CorrectlyMapsErrorField() + { + var entity = new Entities.AuditLog + { + Id = 10, + TenantId = 3, + Action = "RegisterAward", + Changes = ["amount: 50000"], + EntityId = "AW-100", + EntityName = "Award", + PerformedBy = Guid.NewGuid(), + Timestamp = DateTime.UtcNow, + Error = "[Exception] InvalidOperationException: Award already exists" + }; + + var mapped = entity.Map(); + + Assert.Equal(entity.Id, mapped.Id); + Assert.Equal(entity.TenantId, mapped.TenantId); + Assert.Equal(entity.Action, mapped.Action); + Assert.Equal(entity.Changes, mapped.Changes); + Assert.Equal(entity.EntityId, mapped.EntityId); + Assert.Equal(entity.EntityName, mapped.EntityName); + Assert.Equal(entity.PerformedBy, mapped.PerformedBy); + Assert.Equal(entity.Timestamp, mapped.Timestamp); + Assert.Equal(entity.Error, mapped.Error); + Assert.True(mapped.IsError); + } + + public interface ITestService + { + [Audit] + Task DoSuccessfulActionAsync(string param); + + [Audit] + Task DoFailedActionAsync(string param); + + [Audit] + Task ThrowExceptionAsync(string param); + } + + public class TestService : ITestService + { + public Task DoSuccessfulActionAsync(string param) => + Task.FromResult(Result.Ok()); + + public Task DoFailedActionAsync(string param) => + Task.FromResult(Result.Fail(new Error("Database connection timed out"))); + + public Task ThrowExceptionAsync(string param) => + throw new InvalidOperationException("Fatal system error occurred"); + } + + private sealed class DummyTenantContextService : ITenantContextService + { + public string? TenantName => "TestTenant"; + public long TenantId { get; } + public bool ByPassTenantQueryFilter { get; set; } + public bool IsAdmin { get; } + public bool HasTenantFilter => !IsAdmin; + + public DummyTenantContextService(long tenantId, bool isAdmin) + { + TenantId = tenantId; + IsAdmin = isAdmin; + ByPassTenantQueryFilter = isAdmin; + } + } + + private sealed class TestDbContextFactory(DbContextOptions options, ITenantContextService tenantService) : IDbContextFactory + { + public ApplicationDbContext CreateDbContext() => new(options, tenantService); + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new ApplicationDbContext(options, tenantService)); + } + + [Fact] + public async Task AuditInterceptor_WhenActionFails_LogsErrorDetails() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false); + var dbContextFactory = new TestDbContextFactory(options, tenantService); + var auditService = new AuditService(dbContextFactory, tenantService); + + var httpContext = new DefaultHttpContext(); + var userId = Guid.NewGuid(); + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("tenant_id", "5") + }; + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + var proxyGenerator = new ProxyGenerator(); + var interceptor = new AuditInterceptor(auditService, httpContextAccessor); + var target = new TestService(); + var proxy = proxyGenerator.CreateInterfaceProxyWithTarget(target, interceptor); + + // Act + var result = await proxy.DoFailedActionAsync("test_payload"); + + // Assert + Assert.True(result.IsFailed); + + await using var verifyContext = new ApplicationDbContext(options, tenantService); + var logs = await verifyContext.AuditLogs.ToListAsync(); + Assert.Single(logs); + var log = logs[0]; + Assert.Equal("DoFailedAction", log.Action); + Assert.Equal(5, log.TenantId); + Assert.Equal(userId, log.PerformedBy); + Assert.NotNull(log.Error); + Assert.Contains("Database connection timed out", log.Error); + } + + [Fact] + public async Task AuditInterceptor_WhenActionThrowsException_LogsExceptionAndRethrows() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false); + var dbContextFactory = new TestDbContextFactory(options, tenantService); + var auditService = new AuditService(dbContextFactory, tenantService); + + var httpContext = new DefaultHttpContext(); + var userId = Guid.NewGuid(); + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("tenant_id", "5") + }; + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + var proxyGenerator = new ProxyGenerator(); + var interceptor = new AuditInterceptor(auditService, httpContextAccessor); + var target = new TestService(); + var proxy = proxyGenerator.CreateInterfaceProxyWithTarget(target, interceptor); + + // Act & Assert + await Assert.ThrowsAsync(() => proxy.ThrowExceptionAsync("test_payload")); + + await using var verifyContext = new ApplicationDbContext(options, tenantService); + var logs = await verifyContext.AuditLogs.ToListAsync(); + Assert.Single(logs); + var log = logs[0]; + Assert.Equal("ThrowException", log.Action); + Assert.Equal(5, log.TenantId); + Assert.Equal(userId, log.PerformedBy); + Assert.NotNull(log.Error); + Assert.Contains("InvalidOperationException", log.Error); + Assert.Contains("Fatal system error occurred", log.Error); + } + + [Fact] + public async Task AuditInterceptor_WhenActionSucceeds_LogsWithoutError() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false); + var dbContextFactory = new TestDbContextFactory(options, tenantService); + var auditService = new AuditService(dbContextFactory, tenantService); + + var httpContext = new DefaultHttpContext(); + var userId = Guid.NewGuid(); + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("tenant_id", "5") + }; + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + var proxyGenerator = new ProxyGenerator(); + var interceptor = new AuditInterceptor(auditService, httpContextAccessor); + var target = new TestService(); + var proxy = proxyGenerator.CreateInterfaceProxyWithTarget(target, interceptor); + + // Act + var result = await proxy.DoSuccessfulActionAsync("test_payload"); + + // Assert + Assert.True(result.IsSuccess); + + await using var verifyContext = new ApplicationDbContext(options, tenantService); + var logs = await verifyContext.AuditLogs.ToListAsync(); + Assert.Single(logs); + var log = logs[0]; + Assert.Equal("DoSuccessfulAction", log.Action); + Assert.Equal(5, log.TenantId); + Assert.Equal(userId, log.PerformedBy); + Assert.Null(log.Error); + } + + [Fact] + public async Task AuditService_GetAuditLogsAsync_NonAdmin_FiltersByTenant() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var tenantService = new DummyTenantContextService(tenantId: 2, isAdmin: false); + await using (var dbContext = new ApplicationDbContext(options, tenantService)) + { + dbContext.AuditLogs.AddRange( + new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() }, + new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() }, + new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() } + ); + await dbContext.SaveChangesAsync(); + } + + var dbContextFactory = new TestDbContextFactory(options, tenantService); + var auditService = new AuditService(dbContextFactory, tenantService); + + // Act + var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10)); + + // Assert + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal(2, result.Value[0].TenantId); + } + + [Fact] + public async Task AuditService_GetAuditLogsAsync_Tenant1Admin_SeesAllTenantsLogs() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var tenantService = new DummyTenantContextService(tenantId: 1, isAdmin: true); + await using (var dbContext = new ApplicationDbContext(options, tenantService)) + { + dbContext.AuditLogs.AddRange( + new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() }, + new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() }, + new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() } + ); + await dbContext.SaveChangesAsync(); + } + + var dbContextFactory = new TestDbContextFactory(options, tenantService); + var auditService = new AuditService(dbContextFactory, tenantService); + + // Act + var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10)); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(3, result.Value.Length); + } +} + -- 2.47.3