feat: add Logs

This commit is contained in:
2026-09-20 16:21:15 +07:00
parent 637ca51f47
commit d3c3ecd5e6
62 changed files with 1577 additions and 66 deletions
+7
View File
@@ -0,0 +1,7 @@
namespace mws.backend.dotnet.application.Audit;
public class AuditContext : IAuditContext
{
public Guid? EntityId { get; set; }
public string? EntityName { get; set; }
}
+48
View File
@@ -0,0 +1,48 @@
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Audit;
namespace mws.backend.dotnet.application.Audit;
public class AuditLogBehavior<TRequest, TResponse>(
IUnitOfWork uow,
ICurrentUser currentUser,
IAuditContext auditContext)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (request is not IAuditableRequest auditable)
{
return await next();
}
auditContext.EntityId = null;
auditContext.EntityName = null;
var response = await next();
uow.ActionLogs.Add(new ActionLog
{
Id = Guid.NewGuid(),
ActorUserId = currentUser.UserId ?? Guid.Empty,
ActorUsername = TruncateRequired(currentUser.Username ?? "unknown", 100),
Action = auditable.Action,
EntityType = auditable.EntityType,
EntityId = auditable.EntityId ?? auditContext.EntityId ?? (response as IHasEntityId)?.EntityId,
EntityName = Truncate(auditable.EntityName ?? auditContext.EntityName, 255),
IpAddress = currentUser.IpAddress,
UserAgent = currentUser.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
});
await uow.SaveChangesAsync(ct);
return response;
}
private static string TruncateRequired(string value, int max) =>
value.Length <= max ? value : value[..max];
private static string? Truncate(string? value, int max) =>
value is null || value.Length <= max ? value : value[..max];
}
+41
View File
@@ -0,0 +1,41 @@
namespace mws.backend.dotnet.application.Audit;
public record ActionLogFilter(
DateTimeOffset? From,
DateTimeOffset? To,
string? Actor,
string? Action,
string? EntityType,
Guid? EntityId);
public record LoginLogFilter(
DateTimeOffset? From,
DateTimeOffset? To,
string? Username,
bool? Success);
public class ActionLogDto
{
public Guid Id { get; set; }
public Guid ActorUserId { get; set; }
public string ActorUsername { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public string EntityType { get; set; } = string.Empty;
public Guid? EntityId { get; set; }
public string? EntityName { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public class LoginLogDto
{
public Guid Id { get; set; }
public string Username { get; set; } = string.Empty;
public Guid? UserId { get; set; }
public bool Success { get; set; }
public string? FailureReason { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
+7
View File
@@ -0,0 +1,7 @@
namespace mws.backend.dotnet.application.Audit;
public interface IAuditContext
{
Guid? EntityId { get; set; }
string? EntityName { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
namespace mws.backend.dotnet.application.Audit;
public interface IAuditableRequest
{
string Action { get; }
string EntityType { get; }
Guid? EntityId => null;
string? EntityName => null;
}
+6
View File
@@ -0,0 +1,6 @@
namespace mws.backend.dotnet.application.Audit;
public interface IHasEntityId
{
Guid EntityId { get; }
}
@@ -0,0 +1,38 @@
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Audit;
public record GetActionLogsQuery(Guid ActorUserId, ActionLogFilter Filter, int Page, int PageSize)
: IRequest<PagedResult<ActionLogDto>>;
public class GetActionLogsHandler(IUnitOfWork uow, IPermissionService permissions)
: IRequestHandler<GetActionLogsQuery, PagedResult<ActionLogDto>>
{
public async Task<PagedResult<ActionLogDto>> Handle(GetActionLogsQuery query, CancellationToken ct)
{
await permissions.EnsureAsync(query.ActorUserId, "actionlogs", PermissionAction.View, ct);
var page = await uow.ActionLogs.SearchAsync(query.Filter, query.Page, query.PageSize, ct);
return new PagedResult<ActionLogDto>
{
Items = page.Items.Select(x => new ActionLogDto
{
Id = x.Id,
ActorUserId = x.ActorUserId,
ActorUsername = x.ActorUsername,
Action = x.Action,
EntityType = x.EntityType,
EntityId = x.EntityId,
EntityName = x.EntityName,
IpAddress = x.IpAddress,
UserAgent = x.UserAgent,
CreatedAt = x.CreatedAt,
}).ToList(),
TotalCount = page.TotalCount,
Page = page.Page,
PageSize = page.PageSize,
};
}
}
+36
View File
@@ -0,0 +1,36 @@
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Audit;
public record GetLoginLogsQuery(Guid ActorUserId, LoginLogFilter Filter, int Page, int PageSize)
: IRequest<PagedResult<LoginLogDto>>;
public class GetLoginLogsHandler(IUnitOfWork uow, IPermissionService permissions)
: IRequestHandler<GetLoginLogsQuery, PagedResult<LoginLogDto>>
{
public async Task<PagedResult<LoginLogDto>> Handle(GetLoginLogsQuery query, CancellationToken ct)
{
await permissions.EnsureAsync(query.ActorUserId, "loginlogs", PermissionAction.View, ct);
var page = await uow.LoginLogs.SearchAsync(query.Filter, query.Page, query.PageSize, ct);
return new PagedResult<LoginLogDto>
{
Items = page.Items.Select(x => new LoginLogDto
{
Id = x.Id,
Username = x.Username,
UserId = x.UserId,
Success = x.Success,
FailureReason = x.FailureReason,
IpAddress = x.IpAddress,
UserAgent = x.UserAgent,
CreatedAt = x.CreatedAt,
}).ToList(),
TotalCount = page.TotalCount,
Page = page.Page,
PageSize = page.PageSize,
};
}
}
+29 -2
View File
@@ -2,12 +2,13 @@ using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Users;
using mws.backend.dotnet.domain.Audit;
namespace mws.backend.dotnet.application.Auth;
public record LoginCommand(LoginRequest Request) : IRequest<LoginResponse>;
public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper)
public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper, ICurrentUser currentUser)
: IRequestHandler<LoginCommand, LoginResponse>
{
public async Task<LoginResponse> Handle(LoginCommand command, CancellationToken ct)
@@ -15,20 +16,46 @@ public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IToke
var username = command.Request.Username.Trim();
var user = await uow.Users.GetByUsernameWithRoleAsync(username, ct);
if (user is null || !passwordHasher.Verify(command.Request.Password, user.PasswordHash))
if (user is null)
{
await WriteLoginLogAsync(username, null, false, "Unknown username", ct);
throw new UnauthorizedException("Invalid username or password");
}
if (!passwordHasher.Verify(command.Request.Password, user.PasswordHash))
{
await WriteLoginLogAsync(username, user.Id, false, "Invalid password", ct);
throw new UnauthorizedException("Invalid username or password");
}
if (!user.IsActive)
{
await WriteLoginLogAsync(username, user.Id, false, "Account disabled", ct);
throw new ForbiddenException("Account disabled");
}
await WriteLoginLogAsync(username, user.Id, true, null, ct);
return new LoginResponse
{
Token = tokenService.CreateToken(user.Id, user.Username),
User = mapper.Map<UserDto>(user),
};
}
private async Task WriteLoginLogAsync(string username, Guid? userId, bool success, string? failureReason, CancellationToken ct)
{
uow.LoginLogs.Add(new LoginLog
{
Id = Guid.NewGuid(),
Username = username.Length <= 100 ? username : username[..100],
UserId = userId,
Success = success,
FailureReason = failureReason,
IpAddress = currentUser.IpAddress,
UserAgent = currentUser.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
});
await uow.SaveChangesAsync(ct);
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace mws.backend.dotnet.application.Common;
public interface ICurrentUser
{
Guid? UserId { get; }
string? Username { get; }
string? IpAddress { get; }
string? UserAgent { get; }
}
+2
View File
@@ -10,6 +10,8 @@ public interface IUnitOfWork
IDocumentRepository Documents { get; }
IRoleRepository Roles { get; }
IMasterDataRepository MasterData { get; }
IActionLogRepository ActionLogs { get; }
ILoginLogRepository LoginLogs { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Audit;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface IActionLogRepository : IRepository<ActionLog>
{
Task<PagedResult<ActionLog>> SearchAsync(ActionLogFilter filter, int page, int pageSize, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Audit;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface ILoginLogRepository : IRepository<LoginLog>
{
Task<PagedResult<LoginLog>> SearchAsync(LoginLogFilter filter, int page, int pageSize, CancellationToken ct = default);
}
@@ -1,13 +1,19 @@
using System.Text;
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
namespace mws.backend.dotnet.application.Documents;
public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>;
public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>, IAuditableRequest
{
public string Action => "Document.Create";
public string EntityType => "Document";
public string? EntityName => Request.Title;
}
public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore)
: IRequestHandler<CreateDocumentCommand, DocumentDto>
@@ -1,12 +1,18 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Documents;
public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest;
public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest, IAuditableRequest
{
public string Action => "Document.Delete";
public string EntityType => "Document";
public Guid? EntityId => DocumentId;
}
public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore contentStore)
public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore contentStore, IAuditContext auditContext)
: IRequestHandler<DeleteDocumentCommand>
{
public async Task Handle(DeleteDocumentCommand command, CancellationToken ct)
@@ -25,6 +31,8 @@ public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore conten
uow.Documents.Remove(doc);
await uow.SaveChangesAsync(ct);
auditContext.EntityName = doc.Title;
foreach (var key in keys)
{
await contentStore.DeleteAsync(key, ct);
+10 -2
View File
@@ -1,13 +1,19 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
namespace mws.backend.dotnet.application.Documents;
public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest;
public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest, IAuditableRequest
{
public string Action => "Document.Move";
public string EntityType => "Document";
public Guid? EntityId => DocumentId;
}
public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler<MoveDocumentCommand>
public class MoveDocumentHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler<MoveDocumentCommand>
{
public async Task Handle(MoveDocumentCommand command, CancellationToken ct)
{
@@ -47,5 +53,7 @@ public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler<MoveDocument
doc.UpdatedAt = DateTime.UtcNow;
doc.UpdatedBy = command.UserId;
await uow.SaveChangesAsync(ct);
auditContext.EntityName = doc.Title;
}
}
@@ -1,13 +1,20 @@
using System.Text;
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
namespace mws.backend.dotnet.application.Documents;
public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>;
public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>, IAuditableRequest
{
public string Action => "Document.Update";
public string EntityType => "Document";
public Guid? EntityId => DocumentId;
public string? EntityName => Request.Title;
}
public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore)
: IRequestHandler<UpdateDocumentCommand, DocumentDto>
+3 -1
View File
@@ -1,3 +1,4 @@
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.domain.Documents;
namespace mws.backend.dotnet.application.Documents;
@@ -21,9 +22,10 @@ public class MoveDocumentRequest
public Guid? NewParentId { get; set; }
}
public class DocumentDto
public class DocumentDto : IHasEntityId
{
public Guid Id { get; set; }
public Guid EntityId => Id;
public Guid ProjectId { get; set; }
public Guid? ParentId { get; set; }
public string Title { get; set; } = string.Empty;
@@ -1,10 +1,16 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record CreateMasterDataCommand(SaveMasterDataRequest Request) : IRequest<MasterDataDto>;
public record CreateMasterDataCommand(SaveMasterDataRequest Request) : IRequest<MasterDataDto>, IAuditableRequest
{
public string Action => "MasterData.Create";
public string EntityType => "MasterData";
public string? EntityName => Request.Label;
}
public class CreateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateMasterDataCommand, MasterDataDto>
{
@@ -1,11 +1,17 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record DeleteMasterDataCommand(Guid Id) : IRequest;
public record DeleteMasterDataCommand(Guid Id) : IRequest, IAuditableRequest
{
public string Action => "MasterData.Delete";
public string EntityType => "MasterData";
public Guid? EntityId => Id;
}
public class DeleteMasterDataHandler(IUnitOfWork uow) : IRequestHandler<DeleteMasterDataCommand>
public class DeleteMasterDataHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler<DeleteMasterDataCommand>
{
public async Task Handle(DeleteMasterDataCommand command, CancellationToken ct)
{
@@ -14,5 +20,7 @@ public class DeleteMasterDataHandler(IUnitOfWork uow) : IRequestHandler<DeleteMa
uow.MasterData.Remove(entry);
await uow.SaveChangesAsync(ct);
auditContext.EntityName = entry.Label;
}
}
@@ -1,10 +1,17 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record UpdateMasterDataCommand(Guid Id, SaveMasterDataRequest Request) : IRequest<MasterDataDto>;
public record UpdateMasterDataCommand(Guid Id, SaveMasterDataRequest Request) : IRequest<MasterDataDto>, IAuditableRequest
{
public string Action => "MasterData.Update";
public string EntityType => "MasterData";
public Guid? EntityId => Id;
public string? EntityName => Request.Label;
}
public class UpdateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateMasterDataCommand, MasterDataDto>
{
+4 -1
View File
@@ -1,8 +1,11 @@
using mws.backend.dotnet.application.Audit;
namespace mws.backend.dotnet.application.MasterData;
public class MasterDataDto
public class MasterDataDto : IHasEntityId
{
public Guid Id { get; set; }
public Guid EntityId => Id;
public string Group { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
@@ -1,11 +1,17 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Roles;
namespace mws.backend.dotnet.application.Permissions;
public record CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>;
public record CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>, IAuditableRequest
{
public string Action => "Role.Create";
public string EntityType => "Role";
public string? EntityName => Request.Name;
}
public class CreateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateRoleCommand, RoleDto>
{
+10 -2
View File
@@ -1,11 +1,17 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.Permissions;
public record DeleteRoleCommand(Guid Id) : IRequest;
public record DeleteRoleCommand(Guid Id) : IRequest, IAuditableRequest
{
public string Action => "Role.Delete";
public string EntityType => "Role";
public Guid? EntityId => Id;
}
public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler<DeleteRoleCommand>
public class DeleteRoleHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler<DeleteRoleCommand>
{
public async Task Handle(DeleteRoleCommand command, CancellationToken ct)
{
@@ -24,5 +30,7 @@ public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler<DeleteRoleComm
uow.Roles.Remove(role);
await uow.SaveChangesAsync(ct);
auditContext.EntityName = role.Name;
}
}
@@ -1,10 +1,17 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.Permissions;
public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>;
public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>, IAuditableRequest
{
public string Action => "Role.Update";
public string EntityType => "Role";
public Guid? EntityId => Id;
public string? EntityName => Request.Name;
}
public class UpdateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateRoleCommand, RoleDto>
{
+4 -1
View File
@@ -1,3 +1,5 @@
using mws.backend.dotnet.application.Audit;
namespace mws.backend.dotnet.application.Permissions;
public enum PermissionAction
@@ -28,9 +30,10 @@ public class PermissionEntryDto
public bool CanDelete { get; set; }
}
public class RoleDto
public class RoleDto : IHasEntityId
{
public Guid Id { get; set; }
public Guid EntityId => Id;
public string Name { get; set; } = string.Empty;
public bool IsSystem { get; set; }
public List<PermissionEntryDto> Permissions { get; set; } = [];
+2
View File
@@ -13,6 +13,8 @@ public static class ScreenCatalog
new("users", "Users", "/users"),
new("permissions", "Permissions", "/settings/permission"),
new("masterdata", "Master Data", "/settings/masterdata"),
new("actionlogs", "Action Logs", "/settings/actionlogs"),
new("loginlogs", "Login Logs", "/settings/loginlogs"),
];
public static readonly IReadOnlySet<string> Keys = Screens.Select(s => s.Key).ToHashSet();
@@ -1,12 +1,18 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace mws.backend.dotnet.application.Projects;
public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>;
public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>, IAuditableRequest
{
public string Action => "ProjectMember.Add";
public string EntityType => "ProjectMember";
public Guid? EntityId => Request.UserId;
}
public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<AddProjectMemberCommand, ProjectMemberDto>
public class AddProjectMemberHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler<AddProjectMemberCommand, ProjectMemberDto>
{
public async Task<ProjectMemberDto> Handle(AddProjectMemberCommand command, CancellationToken ct)
{
@@ -46,6 +52,8 @@ public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<AddProje
});
await uow.SaveChangesAsync(ct);
auditContext.EntityName = user.Username;
return new ProjectMemberDto
{
UserId = user.Id,
@@ -1,13 +1,19 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Projects;
namespace mws.backend.dotnet.application.Projects;
public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest;
public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest, IAuditableRequest
{
public string Action => "Project.Archive";
public string EntityType => "Project";
public Guid? EntityId => ProjectId;
}
public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler<ArchiveProjectCommand>
public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext) : IRequestHandler<ArchiveProjectCommand>
{
public async Task Handle(ArchiveProjectCommand command, CancellationToken ct)
{
@@ -18,5 +24,7 @@ public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissio
project.Status = ProjectStatus.Archived;
project.UpdatedAt = DateTime.UtcNow;
await uow.SaveChangesAsync(ct);
auditContext.EntityName = project.Name;
}
}
@@ -1,12 +1,18 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Projects;
namespace mws.backend.dotnet.application.Projects;
public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>;
public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>, IAuditableRequest
{
public string Action => "Project.Create";
public string EntityType => "Project";
public string? EntityName => Request.Name;
}
public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper, IPermissionService permissions) : IRequestHandler<CreateProjectCommand, ProjectDto>
{
@@ -1,12 +1,18 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace mws.backend.dotnet.application.Projects;
public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest;
public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest, IAuditableRequest
{
public string Action => "ProjectMember.Remove";
public string EntityType => "ProjectMember";
public Guid? EntityId => MemberUserId;
}
public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<RemoveProjectMemberCommand>
public class RemoveProjectMemberHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler<RemoveProjectMemberCommand>
{
public async Task Handle(RemoveProjectMemberCommand command, CancellationToken ct)
{
@@ -15,7 +21,7 @@ public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<Remov
throw new ForbiddenException("Only the project owner can remove members");
}
var member = await uow.Projects.GetMemberAsync(command.ProjectId, command.MemberUserId, ct)
var member = await uow.Projects.GetMemberWithUserAsync(command.ProjectId, command.MemberUserId, ct)
?? throw new NotFoundException("Member not found in project");
var owners = await uow.Projects.CountOwnersAsync(command.ProjectId, ct);
@@ -27,5 +33,7 @@ public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<Remov
uow.Projects.RemoveMember(member);
await uow.SaveChangesAsync(ct);
auditContext.EntityName = member.User.Username;
}
}
@@ -1,13 +1,19 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace mws.backend.dotnet.application.Projects;
public record UpdateMemberDocumentPermissionsCommand(
Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>;
Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>, IAuditableRequest
{
public string Action => "ProjectMember.UpdatePermissions";
public string EntityType => "ProjectMember";
public Guid? EntityId => MemberUserId;
}
public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow)
public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow, IAuditContext auditContext)
: IRequestHandler<UpdateMemberDocumentPermissionsCommand, ProjectMemberDto>
{
public async Task<ProjectMemberDto> Handle(UpdateMemberDocumentPermissionsCommand command, CancellationToken ct)
@@ -39,6 +45,8 @@ public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow)
permission.CanDelete = request.CanDeleteDocuments;
await uow.SaveChangesAsync(ct);
auditContext.EntityName = member.User.Username;
return new ProjectMemberDto
{
UserId = member.UserId,
@@ -1,11 +1,18 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Projects;
public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>;
public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>, IAuditableRequest
{
public string Action => "Project.Update";
public string EntityType => "Project";
public Guid? EntityId => ProjectId;
public string? EntityName => Request.Name;
}
public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper, IPermissionService permissions) : IRequestHandler<UpdateProjectCommand, ProjectDto>
{
+3 -1
View File
@@ -1,3 +1,4 @@
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.domain.Projects;
namespace mws.backend.dotnet.application.Projects;
@@ -41,9 +42,10 @@ public class UpdateMemberDocumentPermissionsRequest
public bool CanDeleteDocuments { get; set; }
}
public class ProjectDto
public class ProjectDto : IHasEntityId
{
public Guid Id { get; set; }
public Guid EntityId => Id;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public ProjectStatus Status { get; set; }
+7 -1
View File
@@ -1,5 +1,6 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Tasks;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
@@ -7,7 +8,12 @@ using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
namespace mws.backend.dotnet.application.Tasks;
public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>;
public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>, IAuditableRequest
{
public string Action => "Task.Create";
public string EntityType => "Task";
public string? EntityName => Request.Title;
}
public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateTaskCommand, TaskDto>
{
+10 -2
View File
@@ -1,16 +1,24 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.Tasks;
public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest;
public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest, IAuditableRequest
{
public string Action => "Task.Delete";
public string EntityType => "Task";
public Guid? EntityId => TaskId;
}
public class DeleteTaskHandler(IUnitOfWork uow) : IRequestHandler<DeleteTaskCommand>
public class DeleteTaskHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler<DeleteTaskCommand>
{
public async Task Handle(DeleteTaskCommand command, CancellationToken ct)
{
var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct);
uow.Tasks.Remove(task);
await uow.SaveChangesAsync(ct);
auditContext.EntityName = task.Title;
}
}
+8 -1
View File
@@ -1,10 +1,17 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.Tasks;
public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>;
public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>, IAuditableRequest
{
public string Action => "Task.Update";
public string EntityType => "Task";
public Guid? EntityId => TaskId;
public string? EntityName => Request.Title;
}
public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateTaskCommand, TaskDto>
{
+3 -1
View File
@@ -1,3 +1,4 @@
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.domain.Tasks;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
@@ -24,9 +25,10 @@ public class UpdateTaskRequest
public DateTime? DueDate { get; set; }
}
public class TaskDto
public class TaskDto : IHasEntityId
{
public Guid Id { get; set; }
public Guid EntityId => Id;
public Guid ProjectId { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
@@ -0,0 +1,34 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Users;
namespace mws.backend.dotnet.application.Users;
public record AssignUserRoleCommand(Guid ActorUserId, Guid TargetUserId, Guid RoleId) : IRequest, IAuditableRequest
{
public string Action => "User.AssignRole";
public string EntityType => "User";
public Guid? EntityId => TargetUserId;
}
public class AssignUserRoleHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext)
: IRequestHandler<AssignUserRoleCommand>
{
public async Task Handle(AssignUserRoleCommand command, CancellationToken ct)
{
await permissions.EnsureAsync(command.ActorUserId, "permissions", PermissionAction.Edit, ct);
var user = await uow.Users.GetByIdWithRoleAsync(command.TargetUserId, ct)
?? throw new NotFoundException("User not found");
if (user.UserRoles.All(ur => ur.RoleId != command.RoleId))
{
user.UserRoles.Add(new UserRole { UserId = command.TargetUserId, RoleId = command.RoleId });
await uow.SaveChangesAsync(ct);
}
auditContext.EntityName = user.Username;
}
}
+7 -1
View File
@@ -1,5 +1,6 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
@@ -8,7 +9,12 @@ using mws.backend.dotnet.domain.Users;
namespace mws.backend.dotnet.application.Users;
public record CreateUserCommand(Guid ActorUserId, CreateUserRequest Request) : IRequest<UserDto>;
public record CreateUserCommand(Guid ActorUserId, CreateUserRequest Request) : IRequest<UserDto>, IAuditableRequest
{
public string Action => "User.Create";
public string EntityType => "User";
public string? EntityName => Request.Username;
}
public class CreateUserHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper)
: IRequestHandler<CreateUserCommand, UserDto>
+10 -2
View File
@@ -1,12 +1,18 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
public record DeleteUserCommand(Guid ActorUserId, Guid Id) : IRequest;
public record DeleteUserCommand(Guid ActorUserId, Guid Id) : IRequest, IAuditableRequest
{
public string Action => "User.Delete";
public string EntityType => "User";
public Guid? EntityId => Id;
}
public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler<DeleteUserCommand>
public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext) : IRequestHandler<DeleteUserCommand>
{
private const string Screen = "users";
@@ -30,5 +36,7 @@ public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions)
uow.Users.Remove(user);
await uow.SaveChangesAsync(ct);
auditContext.EntityName = user.Username;
}
}
+10 -2
View File
@@ -1,13 +1,19 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest;
public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest, IAuditableRequest
{
public string Action => "User.ResetPassword";
public string EntityType => "User";
public Guid? EntityId => Id;
}
public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions)
public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IAuditContext auditContext)
: IRequestHandler<ResetPasswordCommand>
{
private const string Screen = "users";
@@ -27,5 +33,7 @@ public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHashe
user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword);
user.UpdatedAt = DateTime.UtcNow;
await uow.SaveChangesAsync(ct);
auditContext.EntityName = user.Username;
}
}
@@ -0,0 +1,34 @@
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
public record UnassignUserRoleCommand(Guid ActorUserId, Guid TargetUserId, Guid RoleId) : IRequest, IAuditableRequest
{
public string Action => "User.UnassignRole";
public string EntityType => "User";
public Guid? EntityId => TargetUserId;
}
public class UnassignUserRoleHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext)
: IRequestHandler<UnassignUserRoleCommand>
{
public async Task Handle(UnassignUserRoleCommand command, CancellationToken ct)
{
await permissions.EnsureAsync(command.ActorUserId, "permissions", PermissionAction.Edit, ct);
var user = await uow.Users.GetByIdWithRoleAsync(command.TargetUserId, ct)
?? throw new NotFoundException("User not found");
var userRole = user.UserRoles.FirstOrDefault(ur => ur.RoleId == command.RoleId);
if (userRole is not null)
{
user.UserRoles.Remove(userRole);
await uow.SaveChangesAsync(ct);
}
auditContext.EntityName = user.Username;
}
}
+8 -1
View File
@@ -1,11 +1,18 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
public record UpdateUserCommand(Guid ActorUserId, Guid Id, UpdateUserRequest Request) : IRequest<UserDto>;
public record UpdateUserCommand(Guid ActorUserId, Guid Id, UpdateUserRequest Request) : IRequest<UserDto>, IAuditableRequest
{
public string Action => "User.Update";
public string EntityType => "User";
public Guid? EntityId => Id;
public string? EntityName => Request.DisplayName;
}
public class UpdateUserHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
: IRequestHandler<UpdateUserCommand, UserDto>
+3 -1
View File
@@ -1,3 +1,4 @@
using mws.backend.dotnet.application.Audit;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
@@ -22,9 +23,10 @@ public class ResetPasswordRequest
public string NewPassword { get; set; } = string.Empty;
}
public class UserDto
public class UserDto : IHasEntityId
{
public Guid Id { get; set; }
public Guid EntityId => Id;
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public Guid RoleId { get; set; }