feat: add ci
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- push
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
IMAGE_SHA: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
|
||||||
|
IMAGE_LATEST: "$CI_REGISTRY_IMAGE:latest"
|
||||||
|
CONTAINER_NAME: "mws-api"
|
||||||
|
HOST_PORT: "2000"
|
||||||
|
CONTAINER_PORT: "8080"
|
||||||
|
DEPLOY_SSH_PORT: "8686"
|
||||||
|
ASPNETCORE_ENVIRONMENT: "Production"
|
||||||
|
|
||||||
|
default:
|
||||||
|
tags:
|
||||||
|
- deploy
|
||||||
|
- build
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"'
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- echo "Build image $IMAGE_SHA và $IMAGE_LATEST"
|
||||||
|
- docker build -t "$IMAGE_SHA" -t "$IMAGE_LATEST" .
|
||||||
|
|
||||||
|
push:
|
||||||
|
stage: push
|
||||||
|
script:
|
||||||
|
- echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
- docker push "$IMAGE_SHA"
|
||||||
|
- docker push "$IMAGE_LATEST"
|
||||||
|
- docker logout "$CI_REGISTRY"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
stage: deploy
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
before_script:
|
||||||
|
- command -v ssh-agent >/dev/null 2>&1 || (apt-get update -y && apt-get install -y openssh-client)
|
||||||
|
- eval "$(ssh-agent -s)"
|
||||||
|
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
||||||
|
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
- ssh-keyscan -p "$DEPLOY_SSH_PORT" -H "$DEPLOYER_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
- chmod 644 ~/.ssh/known_hosts
|
||||||
|
script:
|
||||||
|
- ENV_FILE="/home/$DEPLOYER_USER/apps/$CI_PROJECT_NAME/.env"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "echo '$REGISTRY_PASSWORD' | docker login -u '$REGISTRY_USER' --password-stdin '$CI_REGISTRY'"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker pull '$IMAGE_SHA'"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker stop '$CONTAINER_NAME' 2>/dev/null || true"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker rm '$CONTAINER_NAME' 2>/dev/null || true"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "test -f '$ENV_FILE' || { echo \"Thiếu $ENV_FILE trên server\"; exit 1; }"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker run -d --name '$CONTAINER_NAME' --restart unless-stopped --env-file '$ENV_FILE' -e ASPNETCORE_ENVIRONMENT='$ASPNETCORE_ENVIRONMENT' -p '$HOST_PORT:$CONTAINER_PORT' '$IMAGE_SHA'"
|
||||||
|
- ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker logout '$CI_REGISTRY'"
|
||||||
@@ -20,10 +20,12 @@ public class MasterDataController(ISender sender, IPermissionService permissions
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<MasterDataDto>>> GetAll(
|
public async Task<ActionResult<PagedResult<MasterDataDto>>> GetAll(
|
||||||
[FromQuery] string? group, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
[FromQuery] string? group, [FromQuery] string? q, [FromQuery] bool? isActive,
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.View, ct);
|
await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.View, ct);
|
||||||
return Ok(await sender.Send(new GetMasterDataListQuery(group, page, pageSize), ct));
|
var filter = new MasterDataFilter(group, q, isActive);
|
||||||
|
return Ok(await sender.Send(new GetMasterDataListQuery(filter, page, pageSize), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
using mws.backend.dotnet.application.Projects;
|
using mws.backend.dotnet.application.Projects;
|
||||||
|
using mws.backend.dotnet.domain.Projects;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.api.Controllers;
|
namespace mws.backend.dotnet.api.Controllers;
|
||||||
|
|
||||||
@@ -13,9 +14,11 @@ public class ProjectMembersController(ISender sender) : ControllerBase
|
|||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<ProjectMemberDto>>> GetAll(
|
public async Task<ActionResult<PagedResult<ProjectMemberDto>>> GetAll(
|
||||||
Guid projectId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
Guid projectId, [FromQuery] string? q, [FromQuery] MemberRole? role,
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId, page, pageSize), ct));
|
var filter = new MemberListFilter(q, role);
|
||||||
|
return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId, filter, page, pageSize), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
using mws.backend.dotnet.application.Projects;
|
using mws.backend.dotnet.application.Projects;
|
||||||
|
using mws.backend.dotnet.domain.Projects;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.api.Controllers;
|
namespace mws.backend.dotnet.api.Controllers;
|
||||||
|
|
||||||
@@ -12,9 +13,13 @@ namespace mws.backend.dotnet.api.Controllers;
|
|||||||
public class ProjectsController(ISender sender) : ControllerBase
|
public class ProjectsController(ISender sender) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<ProjectDto>>> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
public async Task<ActionResult<PagedResult<ProjectDto>>> GetAll(
|
||||||
|
[FromQuery] string? q, [FromQuery] ProjectStatus? status,
|
||||||
|
[FromQuery] string? createdBy, [FromQuery] string? updatedBy,
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId(), page, pageSize), ct));
|
var filter = new ProjectListFilter(User.GetUserId(), q, status, createdBy, updatedBy);
|
||||||
|
return Ok(await sender.Send(new GetProjectsQuery(filter, page, pageSize), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ namespace mws.backend.dotnet.api.Controllers;
|
|||||||
public class UsersController(ISender sender, AppDbContext db, IPermissionService permissions, IMapper mapper) : ControllerBase
|
public class UsersController(ISender sender, AppDbContext db, IPermissionService permissions, IMapper mapper) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<UserDto>>> GetAll([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
public async Task<ActionResult<PagedResult<UserDto>>> GetAll(
|
||||||
|
[FromQuery] string? q, [FromQuery] bool? isActive, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return Ok(await sender.Send(new GetUsersQuery(User.GetUserId(), q, page, pageSize), ct));
|
var filter = new UserListFilter(q, isActive);
|
||||||
|
return Ok(await sender.Send(new GetUsersQuery(User.GetUserId(), filter, page, pageSize), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://localhost:2000",
|
"applicationUrl": "http://localhost:2001",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://localhost:3000;http://localhost:2000",
|
"applicationUrl": "https://localhost:3000;http://localhost:2001",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
|
using mws.backend.dotnet.application.MasterData;
|
||||||
using mws.backend.dotnet.domain.MasterData;
|
using mws.backend.dotnet.domain.MasterData;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.application.Common.Repositories;
|
namespace mws.backend.dotnet.application.Common.Repositories;
|
||||||
|
|
||||||
public interface IMasterDataRepository : IRepository<MasterDataEntry>
|
public interface IMasterDataRepository : IRepository<MasterDataEntry>
|
||||||
{
|
{
|
||||||
Task<PagedResult<MasterDataEntry>> GetAllAsync(string? group, int page, int pageSize, CancellationToken ct = default);
|
Task<PagedResult<MasterDataEntry>> GetAllAsync(MasterDataFilter filter, int page, int pageSize, CancellationToken ct = default);
|
||||||
Task<List<MasterDataEntry>> GetActiveByGroupAsync(string group, CancellationToken ct = default);
|
Task<List<MasterDataEntry>> GetActiveByGroupAsync(string group, CancellationToken ct = default);
|
||||||
Task<MasterDataEntry?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
Task<MasterDataEntry?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<bool> ExistsAsync(string group, string value, Guid? excludeId, CancellationToken ct = default);
|
Task<bool> ExistsAsync(string group, string value, Guid? excludeId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
|
using mws.backend.dotnet.application.Projects;
|
||||||
using mws.backend.dotnet.domain.Projects;
|
using mws.backend.dotnet.domain.Projects;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.application.Common.Repositories;
|
namespace mws.backend.dotnet.application.Common.Repositories;
|
||||||
@@ -7,7 +8,7 @@ public interface IProjectRepository : IRepository<Project>
|
|||||||
{
|
{
|
||||||
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<Project?> GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
Task<Project?> GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
||||||
Task<PagedResult<Project>> GetForUserAsync(Guid userId, int page, int pageSize, CancellationToken ct = default);
|
Task<PagedResult<Project>> GetForUserAsync(ProjectListFilter filter, int page, int pageSize, CancellationToken ct = default);
|
||||||
Task<List<Project>> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default);
|
Task<List<Project>> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default);
|
||||||
Task<int> CountMembersAsync(Guid projectId, CancellationToken ct = default);
|
Task<int> CountMembersAsync(Guid projectId, CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ public interface IProjectRepository : IRepository<Project>
|
|||||||
Task<MemberRole?> GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
Task<MemberRole?> GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||||
Task<ProjectMember?> GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
Task<ProjectMember?> GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||||
Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||||
Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default);
|
Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, MemberListFilter filter, int page, int pageSize, CancellationToken ct = default);
|
||||||
Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default);
|
Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default);
|
||||||
Task<List<Guid>> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default);
|
Task<List<Guid>> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<List<Guid>> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default);
|
Task<List<Guid>> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
|
using mws.backend.dotnet.application.Users;
|
||||||
using mws.backend.dotnet.domain.Users;
|
using mws.backend.dotnet.domain.Users;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.application.Common.Repositories;
|
namespace mws.backend.dotnet.application.Common.Repositories;
|
||||||
@@ -12,5 +13,5 @@ public interface IUserRepository : IRepository<User>
|
|||||||
Task<bool> ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default);
|
Task<bool> ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default);
|
||||||
Task<List<Guid>> GetRoleIdsAsync(Guid userId, CancellationToken ct = default);
|
Task<List<Guid>> GetRoleIdsAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<List<User>> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default);
|
Task<List<User>> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default);
|
||||||
Task<PagedResult<User>> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default);
|
Task<PagedResult<User>> SearchWithRolePagedAsync(UserListFilter filter, int page, int pageSize, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ public class SaveMasterDataRequest
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record MasterDataFilter(string? Group, string? Term, bool? IsActive);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ using mws.backend.dotnet.application.Common;
|
|||||||
|
|
||||||
namespace mws.backend.dotnet.application.MasterData;
|
namespace mws.backend.dotnet.application.MasterData;
|
||||||
|
|
||||||
public record GetMasterDataListQuery(string? Group, int Page, int PageSize) : IRequest<PagedResult<MasterDataDto>>;
|
public record GetMasterDataListQuery(MasterDataFilter Filter, int Page, int PageSize) : IRequest<PagedResult<MasterDataDto>>;
|
||||||
|
|
||||||
public class GetMasterDataListHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetMasterDataListQuery, PagedResult<MasterDataDto>>
|
public class GetMasterDataListHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetMasterDataListQuery, PagedResult<MasterDataDto>>
|
||||||
{
|
{
|
||||||
public async Task<PagedResult<MasterDataDto>> Handle(GetMasterDataListQuery query, CancellationToken ct)
|
public async Task<PagedResult<MasterDataDto>> Handle(GetMasterDataListQuery query, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var entries = await uow.MasterData.GetAllAsync(query.Group, query.Page, query.PageSize, ct);
|
var entries = await uow.MasterData.GetAllAsync(query.Filter, query.Page, query.PageSize, ct);
|
||||||
return new PagedResult<MasterDataDto>
|
return new PagedResult<MasterDataDto>
|
||||||
{
|
{
|
||||||
Items = mapper.Map<List<MasterDataDto>>(entries.Items),
|
Items = mapper.Map<List<MasterDataDto>>(entries.Items),
|
||||||
|
|||||||
@@ -78,4 +78,8 @@ public class RecentDocumentDto
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ProjectListFilter(Guid UserId, string? Term, ProjectStatus? Status, string? CreatedBy, string? UpdatedBy);
|
||||||
|
|
||||||
|
public record MemberListFilter(string? Term, MemberRole? Role);
|
||||||
@@ -4,7 +4,7 @@ using mws.backend.dotnet.domain.Projects;
|
|||||||
|
|
||||||
namespace mws.backend.dotnet.application.Projects;
|
namespace mws.backend.dotnet.application.Projects;
|
||||||
|
|
||||||
public record GetProjectMembersQuery(Guid UserId, Guid ProjectId, int Page, int PageSize) : IRequest<PagedResult<ProjectMemberDto>>;
|
public record GetProjectMembersQuery(Guid UserId, Guid ProjectId, MemberListFilter Filter, int Page, int PageSize) : IRequest<PagedResult<ProjectMemberDto>>;
|
||||||
|
|
||||||
public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProjectMembersQuery, PagedResult<ProjectMemberDto>>
|
public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProjectMembersQuery, PagedResult<ProjectMemberDto>>
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,7 @@ public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProj
|
|||||||
throw new NotFoundException("Project not found");
|
throw new NotFoundException("Project not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, query.Page, query.PageSize, ct);
|
var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, query.Filter, query.Page, query.PageSize, ct);
|
||||||
var permissions = await uow.Projects.GetMemberPermissionsAsync(query.ProjectId, ProjectPermissionScreens.Documents, ct);
|
var permissions = await uow.Projects.GetMemberPermissionsAsync(query.ProjectId, ProjectPermissionScreens.Documents, ct);
|
||||||
|
|
||||||
var items = members.Items.Select(m =>
|
var items = members.Items.Select(m =>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ using mws.backend.dotnet.application.Common;
|
|||||||
|
|
||||||
namespace mws.backend.dotnet.application.Projects;
|
namespace mws.backend.dotnet.application.Projects;
|
||||||
|
|
||||||
public record GetProjectsQuery(Guid UserId, int Page, int PageSize) : IRequest<PagedResult<ProjectDto>>;
|
public record GetProjectsQuery(ProjectListFilter Filter, int Page, int PageSize) : IRequest<PagedResult<ProjectDto>>;
|
||||||
|
|
||||||
public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectsQuery, PagedResult<ProjectDto>>
|
public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectsQuery, PagedResult<ProjectDto>>
|
||||||
{
|
{
|
||||||
public async Task<PagedResult<ProjectDto>> Handle(GetProjectsQuery query, CancellationToken ct)
|
public async Task<PagedResult<ProjectDto>> Handle(GetProjectsQuery query, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var projects = await uow.Projects.GetForUserAsync(query.UserId, query.Page, query.PageSize, ct);
|
var projects = await uow.Projects.GetForUserAsync(query.Filter, query.Page, query.PageSize, ct);
|
||||||
return new PagedResult<ProjectDto>
|
return new PagedResult<ProjectDto>
|
||||||
{
|
{
|
||||||
Items = mapper.Map<List<ProjectDto>>(projects.Items),
|
Items = mapper.Map<List<ProjectDto>>(projects.Items),
|
||||||
|
|||||||
@@ -45,4 +45,6 @@ public class UserRoleDetailDto
|
|||||||
public class AssignRoleRequest
|
public class AssignRoleRequest
|
||||||
{
|
{
|
||||||
public Guid RoleId { get; set; }
|
public Guid RoleId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record UserListFilter(string? Term, bool? IsActive);
|
||||||
@@ -5,7 +5,7 @@ using mws.backend.dotnet.application.Permissions;
|
|||||||
|
|
||||||
namespace mws.backend.dotnet.application.Users;
|
namespace mws.backend.dotnet.application.Users;
|
||||||
|
|
||||||
public record GetUsersQuery(Guid ActorUserId, string? Term, int Page, int PageSize) : IRequest<PagedResult<UserDto>>;
|
public record GetUsersQuery(Guid ActorUserId, UserListFilter Filter, int Page, int PageSize) : IRequest<PagedResult<UserDto>>;
|
||||||
|
|
||||||
public class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
|
public class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
|
||||||
: IRequestHandler<GetUsersQuery, PagedResult<UserDto>>
|
: IRequestHandler<GetUsersQuery, PagedResult<UserDto>>
|
||||||
@@ -16,7 +16,7 @@ public class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IM
|
|||||||
{
|
{
|
||||||
await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct);
|
await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct);
|
||||||
|
|
||||||
var users = await uow.Users.SearchWithRolePagedAsync(query.Term, query.Page, query.PageSize, ct);
|
var users = await uow.Users.SearchWithRolePagedAsync(query.Filter, query.Page, query.PageSize, ct);
|
||||||
return new PagedResult<UserDto>
|
return new PagedResult<UserDto>
|
||||||
{
|
{
|
||||||
Items = mapper.Map<List<UserDto>>(users.Items),
|
Items = mapper.Map<List<UserDto>>(users.Items),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
|||||||
# CQRS/Mediator Refactor — Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the `IXService`/`XService` application layer (9 interfaces, ~38
|
|
||||||
methods, injected directly into controllers) with MediatR commands/queries
|
|
||||||
and handlers. Controllers depend on `ISender` only; each HTTP action sends
|
|
||||||
one request and returns the result. No behavior change — this is a
|
|
||||||
structural refactor, not a feature change.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Current pattern is service-per-aggregate with broad interfaces
|
|
||||||
(`IProjectService` has 7 methods covering reads, writes, search). CQRS
|
|
||||||
splits each operation into its own request type, so each handler has one
|
|
||||||
job, and cross-cutting concerns (validation, logging, auth) can later be
|
|
||||||
added as MediatR pipeline behaviors without touching every service class.
|
|
||||||
|
|
||||||
## Package
|
|
||||||
|
|
||||||
- Add `MediatR` (latest v13) to `mws.application.csproj`.
|
|
||||||
- Note: MediatR (Jimmy Bogard) moved to a commercial license for
|
|
||||||
non-OSS/non-trivial use starting v13, mirroring AutoMapper (already used
|
|
||||||
in this repo). Explicitly accepted by the project owner for this refactor;
|
|
||||||
AutoMapper is out of scope and left as-is.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
All 7 request-bearing service modules, converted in one pass (not piloted):
|
|
||||||
|
|
||||||
| Module | Old interface | Methods |
|
|
||||||
|---|---|---|
|
|
||||||
| Auth | `IAuthService` | `LoginAsync` |
|
|
||||||
| Accounts | `IAccountService` | `GetAccountsAsync`, `CreateAccountAsync`, `UpdateAccountAsync`, `DeleteAccountAsync`, `ResetPasswordAsync` |
|
|
||||||
| Roles | `IRoleService` | `GetRolesAsync`, `GetRoleAsync`, `CreateRoleAsync`, `UpdateRoleAsync`, `DeleteRoleAsync` |
|
|
||||||
| Projects | `IProjectService` | `GetProjectsAsync`, `GetProjectAsync`, `CreateProjectAsync`, `UpdateProjectAsync`, `ArchiveProjectAsync`, `GetOverviewAsync`, `SearchAsync` |
|
|
||||||
| ProjectMembers | `IProjectMemberService` | `GetMembersAsync`, `AddMemberAsync`, `UpdateMemberDocumentPermissionsAsync`, `RemoveMemberAsync` |
|
|
||||||
| Documents | `IDocumentService` | `GetTreeAsync`, `GetAsync`, `CreateAsync`, `UpdateAsync`, `MoveAsync`, `DeleteAsync`, `SearchAsync` |
|
|
||||||
| Tasks | `ITaskService` | `GetTasksAsync`, `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `SearchAsync` |
|
|
||||||
|
|
||||||
**Excluded from conversion** (stay plain injected services, unchanged):
|
|
||||||
|
|
||||||
- `IPermissionService` (`EnsureAsync`, `GetMenuAsync`) — `EnsureAsync` is
|
|
||||||
called from inside `AccountService`'s methods (soon `Account*Handler`s)
|
|
||||||
and directly in `RolesController`. Converting it to a mediator request
|
|
||||||
would mean handlers calling `Send()` on other handlers, which MediatR's
|
|
||||||
own docs call out as an anti-pattern. `GetMenuAsync` stays alongside it
|
|
||||||
for consistency (one small interface, one caller: `MenuController`).
|
|
||||||
- `ITokenService`, `IPasswordHasher` — infrastructure helpers consumed by
|
|
||||||
handlers, not themselves endpoint operations.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
Per module folder (e.g. `mws.application/Projects/`):
|
|
||||||
|
|
||||||
- `Commands/` — one file per write operation: `CreateProject.cs` containing
|
|
||||||
`public record CreateProjectCommand(Guid UserId, string Name, string?
|
|
||||||
Description) : IRequest<ProjectDto>;` and
|
|
||||||
`public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) :
|
|
||||||
IRequestHandler<CreateProjectCommand, ProjectDto>` directly below it in
|
|
||||||
the same file. Void operations (`Archive`, `Delete`, `Remove`) use
|
|
||||||
`IRequest` (no generic parameter) instead of `IRequest<T>`.
|
|
||||||
- `Queries/` — same colocation pattern for reads: `GetProjects.cs`,
|
|
||||||
`SearchProjects.cs`, etc.
|
|
||||||
- `Contracts.cs` (DTOs) stays exactly as-is.
|
|
||||||
- Handler bodies are the old service method bodies moved verbatim — same
|
|
||||||
`IUnitOfWork` calls, same exceptions (`NotFoundException`,
|
|
||||||
`ForbiddenException`, `BadRequestException`), same authorization checks
|
|
||||||
(`GetMemberRoleAsync`, `EnsureMemberAccessAsync`, etc.). No logic changes.
|
|
||||||
- The `Guid userId`/`actorUserId` parameter that every old service method
|
|
||||||
took explicitly becomes the first property on the command/query record
|
|
||||||
(handlers have no ambient HTTP context).
|
|
||||||
- Delete `IXService.cs` and `XService.cs` for every converted module once
|
|
||||||
its controller no longer references the interface.
|
|
||||||
|
|
||||||
## Controllers
|
|
||||||
|
|
||||||
Each controller's constructor param changes from `IXService x` to
|
|
||||||
`ISender sender` (`MediatR`). Each action body changes from
|
|
||||||
`await x.SomeAsync(User.GetUserId(), ...)` to
|
|
||||||
`await sender.Send(new SomeCommand(User.GetUserId(), ...), ct)`. Routes,
|
|
||||||
HTTP verbs, status codes, and response types are unchanged.
|
|
||||||
|
|
||||||
`RolesController` and `MenuController` keep their existing
|
|
||||||
`IPermissionService` dependency alongside the new `ISender`.
|
|
||||||
|
|
||||||
## DI wiring
|
|
||||||
|
|
||||||
`mws.infrastructure/DependencyInjection.cs`:
|
|
||||||
- Remove the 8 `services.AddScoped<IXService, XService>()` lines for the 7
|
|
||||||
converted modules (Auth, Accounts, Roles, Projects, ProjectMembers,
|
|
||||||
Documents, Tasks).
|
|
||||||
- Add `services.AddMediatR(cfg =>
|
|
||||||
cfg.RegisterServicesFromAssembly(typeof(Mws.Application.Common.IUnitOfWork).Assembly));`
|
|
||||||
- Keep `IPermissionService`, `ITokenService`, `IPasswordHasher`
|
|
||||||
registrations unchanged.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
Unchanged. Handlers throw the same `Mws.Application.Common.Exceptions`
|
|
||||||
types the services did; `ApiExceptionMiddleware` still catches them at the
|
|
||||||
API boundary — nothing about that pipeline changes.
|
|
||||||
|
|
||||||
## Testing / verification
|
|
||||||
|
|
||||||
No test project exists in this repo (per `CLAUDE.md`, none expected today).
|
|
||||||
Verification is:
|
|
||||||
1. `dotnet build Mws.slnx` succeeds with no errors.
|
|
||||||
2. `dotnet run --project Mws.Api`, then smoke-test with curl:
|
|
||||||
login (Auth), list + create a project (Projects, command + query path),
|
|
||||||
get menu (confirms `IPermissionService` still wired and untouched).
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- AutoMapper stays as-is.
|
|
||||||
- No FluentValidation / MediatR pipeline behaviors — not requested, and the
|
|
||||||
ladder says don't add abstractions nobody asked for. Can be layered on
|
|
||||||
later since the request/handler shape is already in place.
|
|
||||||
- No repository/read-model split — queries still go through the existing
|
|
||||||
`IUnitOfWork` repositories.
|
|
||||||
@@ -1,16 +1,36 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
using mws.backend.dotnet.application.Common.Repositories;
|
using mws.backend.dotnet.application.Common.Repositories;
|
||||||
|
using mws.backend.dotnet.application.MasterData;
|
||||||
using mws.backend.dotnet.domain.MasterData;
|
using mws.backend.dotnet.domain.MasterData;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
public class MasterDataRepository(AppDbContext db) : RepositoryBase<MasterDataEntry>(db), IMasterDataRepository
|
public class MasterDataRepository(AppDbContext db) : RepositoryBase<MasterDataEntry>(db), IMasterDataRepository
|
||||||
{
|
{
|
||||||
public Task<PagedResult<MasterDataEntry>> GetAllAsync(string? group, int page, int pageSize, CancellationToken ct = default) =>
|
public Task<PagedResult<MasterDataEntry>> GetAllAsync(MasterDataFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||||
Set.Where(m => group == null || m.Group == group)
|
{
|
||||||
.OrderBy(m => m.Group).ThenBy(m => m.SortOrder).ThenBy(m => m.Label)
|
var query = Set.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Group))
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.Group == filter.Group);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Term))
|
||||||
|
{
|
||||||
|
var lower = filter.Term.Trim().ToLower();
|
||||||
|
query = query.Where(m => m.Label.ToLower().Contains(lower) || m.Value.ToLower().Contains(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.IsActive is { } active)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.IsActive == active);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.OrderBy(m => m.Group).ThenBy(m => m.SortOrder).ThenBy(m => m.Label)
|
||||||
.ToPagedResultAsync(page, pageSize, ct);
|
.ToPagedResultAsync(page, pageSize, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<List<MasterDataEntry>> GetActiveByGroupAsync(string group, CancellationToken ct = default) =>
|
public Task<List<MasterDataEntry>> GetActiveByGroupAsync(string group, CancellationToken ct = default) =>
|
||||||
Set.Where(m => m.Group == group && m.IsActive)
|
Set.Where(m => m.Group == group && m.IsActive)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
using mws.backend.dotnet.application.Common.Repositories;
|
using mws.backend.dotnet.application.Common.Repositories;
|
||||||
|
using mws.backend.dotnet.application.Projects;
|
||||||
using mws.backend.dotnet.domain.Projects;
|
using mws.backend.dotnet.domain.Projects;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
||||||
@@ -14,11 +15,36 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase<Project>(db), I
|
|||||||
Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser)
|
Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser)
|
||||||
.FirstOrDefaultAsync(p => p.Id == projectId && p.Members.Any(m => m.UserId == userId), ct);
|
.FirstOrDefaultAsync(p => p.Id == projectId && p.Members.Any(m => m.UserId == userId), ct);
|
||||||
|
|
||||||
public Task<PagedResult<Project>> GetForUserAsync(Guid userId, int page, int pageSize, CancellationToken ct = default) =>
|
public Task<PagedResult<Project>> GetForUserAsync(ProjectListFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||||
Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser)
|
{
|
||||||
.Where(p => p.Members.Any(m => m.UserId == userId))
|
var query = Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser)
|
||||||
.OrderByDescending(p => p.UpdatedAt)
|
.Where(p => p.Members.Any(m => m.UserId == filter.UserId));
|
||||||
.ToPagedResultAsync(page, pageSize, ct);
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Term))
|
||||||
|
{
|
||||||
|
var lower = filter.Term.Trim().ToLower();
|
||||||
|
query = query.Where(p => p.Name.ToLower().Contains(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Status is { } status)
|
||||||
|
{
|
||||||
|
query = query.Where(p => p.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.CreatedBy))
|
||||||
|
{
|
||||||
|
var lower = filter.CreatedBy.Trim().ToLower();
|
||||||
|
query = query.Where(p => p.CreatedByUser != null && p.CreatedByUser.DisplayName.ToLower().Contains(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.UpdatedBy))
|
||||||
|
{
|
||||||
|
var lower = filter.UpdatedBy.Trim().ToLower();
|
||||||
|
query = query.Where(p => p.UpdatedByUser != null && p.UpdatedByUser.DisplayName.ToLower().Contains(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.OrderByDescending(p => p.UpdatedAt).ToPagedResultAsync(page, pageSize, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<List<Project>> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default)
|
public Task<List<Project>> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -51,13 +77,28 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase<Project>(db), I
|
|||||||
public Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default) =>
|
public Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default) =>
|
||||||
Db.ProjectMembers.Include(m => m.User).FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
|
Db.ProjectMembers.Include(m => m.User).FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
|
||||||
|
|
||||||
public Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default) =>
|
public Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, MemberListFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||||
Db.ProjectMembers
|
{
|
||||||
|
var query = Db.ProjectMembers
|
||||||
.Where(m => m.ProjectId == projectId)
|
.Where(m => m.ProjectId == projectId)
|
||||||
.Include(m => m.User)
|
.Include(m => m.User)
|
||||||
.OrderByDescending(m => m.Role)
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Term))
|
||||||
|
{
|
||||||
|
var lower = filter.Term.Trim().ToLower();
|
||||||
|
query = query.Where(m => m.User.Username.ToLower().Contains(lower) || m.User.DisplayName.ToLower().Contains(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Role is { } role)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.Role == role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.OrderByDescending(m => m.Role)
|
||||||
.ThenBy(m => m.User.DisplayName)
|
.ThenBy(m => m.User.DisplayName)
|
||||||
.ToPagedResultAsync(page, pageSize, ct);
|
.ToPagedResultAsync(page, pageSize, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default) =>
|
public Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default) =>
|
||||||
Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct);
|
Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using mws.backend.dotnet.application.Common;
|
using mws.backend.dotnet.application.Common;
|
||||||
using mws.backend.dotnet.application.Common.Repositories;
|
using mws.backend.dotnet.application.Common.Repositories;
|
||||||
|
using mws.backend.dotnet.application.Users;
|
||||||
using mws.backend.dotnet.domain.Users;
|
using mws.backend.dotnet.domain.Users;
|
||||||
|
|
||||||
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
||||||
@@ -43,15 +44,20 @@ public class UserRepository(AppDbContext db) : RepositoryBase<User>(db), IUserRe
|
|||||||
return query.ToListAsync(ct);
|
return query.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<PagedResult<User>> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default)
|
public Task<PagedResult<User>> SearchWithRolePagedAsync(UserListFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).AsQueryable();
|
var query = Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).AsQueryable();
|
||||||
if (!string.IsNullOrWhiteSpace(term))
|
if (!string.IsNullOrWhiteSpace(filter.Term))
|
||||||
{
|
{
|
||||||
var lower = term.Trim().ToLower();
|
var lower = filter.Term.Trim().ToLower();
|
||||||
query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower));
|
query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.IsActive is { } active)
|
||||||
|
{
|
||||||
|
query = query.Where(u => u.IsActive == active);
|
||||||
|
}
|
||||||
|
|
||||||
return query.OrderBy(u => u.DisplayName).ToPagedResultAsync(page, pageSize, ct);
|
return query.OrderBy(u => u.DisplayName).ToPagedResultAsync(page, pageSize, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user