feat: add ci

This commit is contained in:
2026-09-13 18:02:09 +07:00
parent af34bc11f1
commit ed5146c73b
21 changed files with 186 additions and 2477 deletions
+58
View File
@@ -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'"
+4 -2
View File
@@ -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]
+5 -2
View File
@@ -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]
+7 -2
View File
@@ -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")]
+4 -2
View File
@@ -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]
+2 -2
View File
@@ -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);
}
+2
View File
@@ -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),
+4
View File
@@ -79,3 +79,7 @@ public class RecentDocumentDto
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 =>
+2 -2
View File
@@ -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),
+2
View File
@@ -46,3 +46,5 @@ public class AssignRoleRequest
{
public Guid RoleId { get; set; }
}
public record UserListFilter(string? Term, bool? IsActive);
+2 -2
View File
@@ -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);
}
}