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]
|
||||
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);
|
||||
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]
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using mws.backend.dotnet.application.Common;
|
||||
using mws.backend.dotnet.application.Projects;
|
||||
using mws.backend.dotnet.domain.Projects;
|
||||
|
||||
namespace mws.backend.dotnet.api.Controllers;
|
||||
|
||||
@@ -13,9 +14,11 @@ public class ProjectMembersController(ISender sender) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
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]
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using mws.backend.dotnet.application.Common;
|
||||
using mws.backend.dotnet.application.Projects;
|
||||
using mws.backend.dotnet.domain.Projects;
|
||||
|
||||
namespace mws.backend.dotnet.api.Controllers;
|
||||
|
||||
@@ -12,9 +13,13 @@ namespace mws.backend.dotnet.api.Controllers;
|
||||
public class ProjectsController(ISender sender) : ControllerBase
|
||||
{
|
||||
[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")]
|
||||
|
||||
@@ -17,9 +17,11 @@ namespace mws.backend.dotnet.api.Controllers;
|
||||
public class UsersController(ISender sender, AppDbContext db, IPermissionService permissions, IMapper mapper) : ControllerBase
|
||||
{
|
||||
[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]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:2000",
|
||||
"applicationUrl": "http://localhost:2001",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:3000;http://localhost:2000",
|
||||
"applicationUrl": "https://localhost:3000;http://localhost:2001",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using mws.backend.dotnet.application.Common;
|
||||
using mws.backend.dotnet.application.MasterData;
|
||||
using mws.backend.dotnet.domain.MasterData;
|
||||
|
||||
namespace mws.backend.dotnet.application.Common.Repositories;
|
||||
|
||||
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<MasterDataEntry?> GetByIdAsync(Guid id, 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.Projects;
|
||||
using mws.backend.dotnet.domain.Projects;
|
||||
|
||||
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?> 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<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<ProjectMember?> GetMemberAsync(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<List<Guid>> GetProjectIdsForUserAsync(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.Users;
|
||||
using mws.backend.dotnet.domain.Users;
|
||||
|
||||
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<List<Guid>> GetRoleIdsAsync(Guid userId, 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 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;
|
||||
|
||||
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 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>
|
||||
{
|
||||
Items = mapper.Map<List<MasterDataDto>>(entries.Items),
|
||||
|
||||
@@ -78,4 +78,8 @@ public class RecentDocumentDto
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
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;
|
||||
|
||||
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>>
|
||||
{
|
||||
@@ -16,7 +16,7 @@ public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProj
|
||||
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 items = members.Items.Select(m =>
|
||||
|
||||
@@ -4,13 +4,13 @@ using mws.backend.dotnet.application.Common;
|
||||
|
||||
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 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>
|
||||
{
|
||||
Items = mapper.Map<List<ProjectDto>>(projects.Items),
|
||||
|
||||
@@ -45,4 +45,6 @@ public class UserRoleDetailDto
|
||||
public class AssignRoleRequest
|
||||
{
|
||||
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;
|
||||
|
||||
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)
|
||||
: 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);
|
||||
|
||||
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>
|
||||
{
|
||||
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 mws.backend.dotnet.application.Common;
|
||||
using mws.backend.dotnet.application.Common.Repositories;
|
||||
using mws.backend.dotnet.application.MasterData;
|
||||
using mws.backend.dotnet.domain.MasterData;
|
||||
|
||||
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
||||
|
||||
public class MasterDataRepository(AppDbContext db) : RepositoryBase<MasterDataEntry>(db), IMasterDataRepository
|
||||
{
|
||||
public Task<PagedResult<MasterDataEntry>> GetAllAsync(string? group, 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)
|
||||
public Task<PagedResult<MasterDataEntry>> GetAllAsync(MasterDataFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public Task<List<MasterDataEntry>> GetActiveByGroupAsync(string group, CancellationToken ct = default) =>
|
||||
Set.Where(m => m.Group == group && m.IsActive)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using mws.backend.dotnet.application.Common;
|
||||
using mws.backend.dotnet.application.Common.Repositories;
|
||||
using mws.backend.dotnet.application.Projects;
|
||||
using mws.backend.dotnet.domain.Projects;
|
||||
|
||||
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)
|
||||
.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) =>
|
||||
Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser)
|
||||
.Where(p => p.Members.Any(m => m.UserId == userId))
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.ToPagedResultAsync(page, pageSize, ct);
|
||||
public Task<PagedResult<Project>> GetForUserAsync(ProjectListFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var query = Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser)
|
||||
.Where(p => p.Members.Any(m => m.UserId == filter.UserId));
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -51,13 +77,28 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase<Project>(db), I
|
||||
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);
|
||||
|
||||
public Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default) =>
|
||||
Db.ProjectMembers
|
||||
public Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, MemberListFilter filter, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var query = Db.ProjectMembers
|
||||
.Where(m => m.ProjectId == projectId)
|
||||
.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)
|
||||
.ToPagedResultAsync(page, pageSize, ct);
|
||||
}
|
||||
|
||||
public Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default) =>
|
||||
Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using mws.backend.dotnet.application.Common;
|
||||
using mws.backend.dotnet.application.Common.Repositories;
|
||||
using mws.backend.dotnet.application.Users;
|
||||
using mws.backend.dotnet.domain.Users;
|
||||
|
||||
namespace mws.backend.dotnet.infrastructure.Persistence.Repositories;
|
||||
@@ -43,15 +44,20 @@ public class UserRepository(AppDbContext db) : RepositoryBase<User>(db), IUserRe
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
if (filter.IsActive is { } active)
|
||||
{
|
||||
query = query.Where(u => u.IsActive == active);
|
||||
}
|
||||
|
||||
return query.OrderBy(u => u.DisplayName).ToPagedResultAsync(page, pageSize, ct);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user