ref: project structure

This commit is contained in:
2026-08-19 23:25:08 +07:00
parent 05a93cfc09
commit a1fe31cf70
155 changed files with 3339 additions and 1007 deletions
+15 -15
View File
@@ -6,16 +6,16 @@ Backend for My Workspace (MWS) — ASP.NET Core 10 Web API with JWT auth, EF Cor
```bash
# build everything
dotnet build Mws.slnx
dotnet build mws.backend.dotnet.sln
# run the API (auto-migrates + seeds on startup)
dotnet run --project Mws.Api
dotnet run --project mws.api
# add a migration (from repo root, after editing entities)
dotnet ef migrations add <Name> --project Mws.Infrastructure --startup-project Mws.Api
dotnet ef migrations add <Name> --project mws.infrastructure --startup-project mws.api
# apply migrations explicitly (otherwise the app does it on boot)
dotnet ef database update --project Mws.Infrastructure --startup-project Mws.Api
dotnet ef database update --project mws.infrastructure --startup-project mws.api
# run the docker image
docker build -t mws.api .
@@ -26,35 +26,35 @@ No test project exists. Add one under `Mws.Tests/` (xUnit) when tests are needed
## Architecture
Clean-lite, 4 projects in [Mws.slnx](Mws.slnx). Dependencies point inward only: `Api → Application ← Infrastructure`, both `Application` and `Infrastructure → Domain`.
Clean-lite, 4 projects in [mws.backend.dotnet.sln](mws.backend.dotnet.sln). Dependencies point inward only: `Api → Application ← Infrastructure`, both `Application` and `Infrastructure → Domain`.
- **[Mws.Domain](Mws.Domain/)** — POCOs only. Entities (`User`, `Project`, `ProjectMember`, `TaskItem`, `Document`, `Role`, `RolePermission`) and enums (`ProjectStatus`, `MemberRole`, `TaskStatus`, `TaskPriority`, `DocumentType`). No EF attributes. Roles are data (`roles` table), not a hardcoded enum — `User.RoleId` FK's to `Role`.
- **[Mws.Application](Mws.Application/)** — use cases. One folder per aggregate (`Auth/`, `Projects/`, `Tasks/`, `Documents/`, `Accounts/`, `Roles/`, `Permissions/`) plus shared `Common/` (DTO interfaces + exception types). Each service depends only on `IApplicationDbContext`, never on EF Core directly.
- **[Mws.Domain](Mws.Domain/)** — POCOs only. Entities (`User`, `Project`, `ProjectMember`, `TaskItem`, `Document`, `Role`, `RolePermission`, `UserRole`, `MasterDataEntry`) and enums (`ProjectStatus`, `MemberRole`, `TaskStatus`, `TaskPriority`, `DocumentType`). No EF attributes. Roles are data (`roles` table), not a hardcoded enum — users link to roles through the `UserRole` join table (`user_roles`, many-to-many; `User.UserRoles`).
- **[Mws.Application](Mws.Application/)** — use cases. One folder per module (`Auth/`, `Users/`, `Projects/`, `Tasks/`, `Documents/`, `MasterData/`, `Permissions/`) plus shared `Common/` (DTOs, repositories, exceptions). Module names match controllers/routes (`api/users`, `api/permissions`, `api/masterdata`). Use cases are MediatR command/query handlers; each depends on `IUnitOfWork` (via `Common/Repositories/`) and `IPermissionService`, never on EF Core directly.
- **[Mws.Infrastructure](Mws.Infrastructure/)** — EF Core + auth wiring. `AppDbContext` (in [Mws.Infrastructure/Persistence/AppDbContext.cs](Mws.Infrastructure/Persistence/AppDbContext.cs)) configures all entities via fluent API and stores enums as strings. `Authentication/` contains `BcryptPasswordHasher` and `JwtTokenService`. Migrations live under `Migrations/`. `DependencyInjection.AddInfrastructure` is the single composition root.
- **[Mws.Api](Mws.Api/)** — controllers, middleware, `Program.cs`. Thin: controllers resolve a service via DI, get the user id from `User.GetUserId()` ([Mws.Api/ClaimsExtensions.cs](Mws.Api/ClaimsExtensions.cs)), and delegate.
### Request flow
```
HTTP → ApiExceptionMiddleware → JwtBearer auth → Controller → Application service
→ IApplicationDbContext (→ AppDbContext) → PostgreSQL
HTTP → ApiExceptionMiddleware → JwtBearer auth → Controller → MediatR handler
→ IUnitOfWork (repos) → AppDbContext → PostgreSQL
```
Exceptions in [Mws.Application/Common/Exceptions.cs](Mws.Application/Common/Exceptions.cs) (`BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`) are caught by [Mws.Api/Middleware/ApiExceptionMiddleware.cs](Mws.Api/Middleware/ApiExceptionMiddleware.cs) and mapped to 400/401/403/404. Anything else → 500 with a generic message (original logged). Use these exceptions in services instead of returning `Result<T>`.
Exceptions in [Mws.Application/Common/Exceptions.cs](Mws.Application/Common/Exceptions.cs) (`BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`) are caught by [Mws.Api/Middleware/ApiExceptionMiddleware.cs](Mws.Api/Middleware/ApiExceptionMiddleware.cs) and mapped to 400/401/403/404. Anything else → 500 with a generic message (original logged). Use these exceptions in handlers instead of returning `Result<T>`.
### Authorization model
Two independent mechanisms — don't cross-wire them:
1. **Project membership** (unchanged). JWT carries `sub` = user `Guid`. Every protected project/task/document endpoint looks up `ProjectMember` to verify the caller belongs to the project — checks live in the application service (`GetProjectForUserAsync`, `EnsureMemberAccessAsync`, `GetMemberRoleAsync`). Mutating project settings requires `MemberRole.Owner`. Add a new check the same way.
2. **Screen-level role permissions** (admin screens only: Accounts, Roles). `Role` is a DB entity (`roles` table), not an enum — a user's `User.RoleId` points to one. `RolePermission` is a per-(role, screen) CRUD matrix (`CanView`/`CanCreate`/`CanEdit`/`CanDelete`). `IPermissionService.EnsureAsync(userId, screen, action)` (in `Mws.Application/Permissions/PermissionService.cs`) is the gate — called at the top of `AccountService`/`RoleService` methods and inline in `RolesController`. There is **no** `[Authorize(Roles=...)]` JWT-claim-based gating — the JWT only carries `sub`/`name`; role/permission is always resolved fresh from the DB per request, so changing a role's permissions or a user's role takes effect immediately without re-login. `ScreenCatalog.cs` is the single source of truth for valid screen keys (`dashboard`, `projects`, `tasks`, `documents`, `accounts`, `roles`) — adding a new admin-gated screen means adding it there. `GET /api/menu` returns the CRUD matrix per screen for the caller's role so the frontend can render nav + gate buttons; it does not itself enforce anything server-side.
1. **Project membership** (unchanged). JWT carries `sub` = user `Guid`. Every protected project/task/document endpoint looks up `ProjectMember` to verify the caller belongs to the project — checks live in the application handlers via `ProjectAccess`/`TaskAccess` helpers and `IProjectRepository` (`GetMemberRoleAsync`). Mutating project settings requires `MemberRole.Owner`. Add a new check the same way.
2. **Screen-level role permissions** (admin screens only: Users, Permissions, Master Data). `Role` is a DB entity (`roles` table), not an enum — a user links to roles via the `UserRole` join table. `RolePermission` is a per-(role, screen) CRUD matrix (`CanView`/`CanCreate`/`CanEdit`/`CanDelete`). `IPermissionService.EnsureAsync(userId, screen, action)` (in `Mws.Application/Permissions/PermissionService.cs`) is the gate — called at the top of the users/roles/masterdata handlers. There is **no** `[Authorize(Roles=...)]` JWT-claim-based gating — the JWT only carries `sub`/`name`; role/permission is always resolved fresh from the DB per request, so changing a role's permissions or a user's roles takes effect immediately without re-login. `ScreenCatalog.cs` is the single source of truth for valid screen keys (`dashboard`, `projects`, `tasks`, `documents`, `users`, `permissions`, `masterdata`) — adding a new admin-gated screen means adding it there. `GET /api/menu` returns the CRUD matrix per screen for the caller's roles so the frontend can render nav + gate buttons; it does not itself enforce anything server-side.
### Database
- PostgreSQL via `Npgsql.EntityFrameworkCore.PostgreSQL` 10. Connection string key is `ConnectionStrings:Default` (see [Mws.Api/appsettings.json](Mws.Api/appsettings.json)); fallback to `localhost:5432` dev creds `mws/mws`. Override via env if you must.
- Enum columns are stored as `varchar(20)` (not ints) — preserve that for any new enum.
- Self-referencing `Document.ParentId` uses `Restrict` to block accidental cycles. Cascade delete is implemented in `DocumentService.DeleteAsync` via a manual descendant walk; do not switch the FK to cascade.
- `DbSeeder` ([Mws.Infrastructure/Persistence/DbSeeder.cs](Mws.Infrastructure/Persistence/DbSeeder.cs)) seeds two system roles (`Admin` — full CRUD on every screen; `Member` — CRUD on everything except Accounts/Roles, no delete) plus admin/alice/bob (password: `password`, admin → Admin role, alice/bob → Member) and one demo project on first boot. Skips if any users exist. System roles (`Role.IsSystem = true`) can't be deleted via `RoleService.DeleteRoleAsync`.
- Self-referencing `Document.ParentId` uses `Restrict` to block accidental cycles. Cascade delete is implemented in `DeleteDocumentHandler` via a manual descendant walk (`DocumentAccess.DeleteDescendantsAsync`); do not switch the FK to cascade.
- `DbSeeder` ([Mws.Infrastructure/Persistence/DbSeeder.cs](Mws.Infrastructure/Persistence/DbSeeder.cs)) seeds two system roles (`Admin` — full CRUD on every screen; `Member` — CRUD on everything except Users/Permissions/Master Data, no delete) plus admin/alice/bob (password: `password`, admin → Admin role, alice/bob → Member) and one demo project on first boot. Skips if any users exist. System roles (`Role.IsSystem = true`) can't be deleted via `DeleteRoleCommand`.
### Config
@@ -67,7 +67,7 @@ Two independent mechanisms — don't cross-wire them:
### Conventions
- `net10.0`, `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>` on every project.
- DTOs live next to the service that returns them (e.g. `ProjectDto` in `Projects/Contracts.cs`). Use `Contracts.cs` per folder; don't sprawl.
- DTOs live next to the module that returns them (e.g. `ProjectDto` in `Projects/Contracts.cs`). Use `Contracts.cs` per folder; don't sprawl.
- All async service methods take `CancellationToken ct = default` and forward it.
- Enums serialized as strings globally (`JsonStringEnumConverter` in `Program.cs`).
+8 -8
View File
@@ -1,19 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /app
COPY Mws.slnx ./
COPY Mws.Domain/*.csproj Mws.Domain/
COPY Mws.Application/*.csproj Mws.Application/
COPY Mws.Infrastructure/*.csproj Mws.Infrastructure/
COPY Mws.Api/*.csproj Mws.Api/
COPY mws.backend.dotnet.sln ./
COPY mws.domain/*.csproj mws.domain/
COPY mws.application/*.csproj mws.application/
COPY mws.infrastructure/*.csproj mws.infrastructure/
COPY mws.api/*.csproj mws.api/
RUN dotnet restore
COPY app/ app/
RUN dotnet publish app/Mws.Api -c Release -o /app/publish
COPY . .
RUN dotnet publish mws.api -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Mws.Api.dll"]
ENTRYPOINT ["dotnet", "mws.api.dll"]
@@ -1,7 +1,7 @@
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Mws.Api;
namespace mws.backend.dotnet.api;
public static class ClaimsExtensions
{
@@ -1,19 +1,27 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Auth;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Users;
namespace Mws.Api.Controllers;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api/auth")]
[AllowAnonymous]
public class AuthController(ISender sender) : ControllerBase
{
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
{
var response = await sender.Send(new LoginCommand(request), ct);
return Ok(response);
}
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<UserDto>> GetProfile(CancellationToken ct)
{
return Ok(await sender.Send(new GetProfileQuery(User.GetUserId()), ct));
}
}
@@ -1,9 +1,9 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Documents;
using mws.backend.dotnet.application.Documents;
namespace Mws.Api.Controllers;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api")]
+50
View File
@@ -0,0 +1,50 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.MasterData;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api/masterdata")]
[Authorize]
public class MasterDataController(ISender sender, IPermissionService permissions) : ControllerBase
{
[HttpGet("groups/{group}")]
public async Task<ActionResult<List<MasterDataDto>>> GetByGroup(string group, CancellationToken ct)
{
return Ok(await sender.Send(new GetMasterDataByGroupQuery(group), ct));
}
[HttpGet]
public async Task<ActionResult<PagedResult<MasterDataDto>>> GetAll(
[FromQuery] string? group, [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));
}
[HttpPost]
public async Task<ActionResult<MasterDataDto>> Create([FromBody] SaveMasterDataRequest request, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.Create, ct);
return Ok(await sender.Send(new CreateMasterDataCommand(request), ct));
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<MasterDataDto>> Update(Guid id, [FromBody] SaveMasterDataRequest request, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.Edit, ct);
return Ok(await sender.Send(new UpdateMasterDataCommand(id, request), ct));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.Delete, ct);
await sender.Send(new DeleteMasterDataCommand(id), ct);
return NoContent();
}
}
+55
View File
@@ -0,0 +1,55 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api/settings/permission")]
[Authorize]
public class PermissionsController(ISender sender, IPermissionService permissions) : ControllerBase
{
[HttpGet("menu")]
public async Task<ActionResult<List<MenuItemDto>>> GetMenu(CancellationToken ct)
{
return Ok(await permissions.GetMenuAsync(User.GetUserId(), ct));
}
[HttpGet]
public async Task<ActionResult<PagedResult<RoleDto>>> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.View, ct);
return Ok(await sender.Send(new GetRolesQuery(page, pageSize), ct));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<RoleDto>> Get(Guid id, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.View, ct);
return Ok(await sender.Send(new GetRoleQuery(id), ct));
}
[HttpPost]
public async Task<ActionResult<RoleDto>> Create([FromBody] SaveRoleRequest request, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Create, ct);
return Ok(await sender.Send(new CreateRoleCommand(request), ct));
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<RoleDto>> Update(Guid id, [FromBody] SaveRoleRequest request, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Edit, ct);
return Ok(await sender.Send(new UpdateRoleCommand(id, request), ct));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Delete, ct);
await sender.Send(new DeleteRoleCommand(id), ct);
return NoContent();
}
}
@@ -1,9 +1,10 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Projects;
namespace Mws.Api.Controllers;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api/projects/{projectId:guid}/members")]
@@ -11,9 +12,10 @@ namespace Mws.Api.Controllers;
public class ProjectMembersController(ISender sender) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<ProjectMemberDto>>> GetAll(Guid projectId, CancellationToken ct)
public async Task<ActionResult<PagedResult<ProjectMemberDto>>> GetAll(
Guid projectId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{
return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId), ct));
return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId, page, pageSize), ct));
}
[HttpPost]
@@ -1,9 +1,10 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Projects;
namespace Mws.Api.Controllers;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api/projects")]
@@ -11,9 +12,9 @@ namespace Mws.Api.Controllers;
public class ProjectsController(ISender sender) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<ProjectDto>>> GetAll(CancellationToken ct)
public async Task<ActionResult<PagedResult<ProjectDto>>> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{
return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId()), ct));
return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId(), page, pageSize), ct));
}
[HttpGet("search")]
@@ -1,12 +1,12 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Common;
using Mws.Application.Tasks;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Tasks;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
namespace Mws.Api.Controllers;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api")]
@@ -20,16 +20,18 @@ public class TasksController(ISender sender) : ControllerBase
}
[HttpGet("projects/{projectId:guid}/tasks")]
public async Task<ActionResult<List<TaskDto>>> GetAll(
public async Task<ActionResult<PagedResult<TaskDto>>> GetAll(
Guid projectId,
[FromQuery] string? status,
[FromQuery] string? priority,
[FromQuery] Guid? assigneeId,
CancellationToken ct)
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var statusValue = ParseOptional<TaskStatus>(status);
var priorityValue = ParseOptional<TaskPriority>(priority);
return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId), ct));
return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId, page, pageSize), ct));
}
[HttpPost("projects/{projectId:guid}/tasks")]
+161
View File
@@ -0,0 +1,161 @@
using AutoMapper;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.application.Users;
using mws.backend.dotnet.domain.Users;
using mws.backend.dotnet.infrastructure.Persistence;
namespace mws.backend.dotnet.api.Controllers;
[ApiController]
[Route("api/users")]
[Authorize]
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)
{
return Ok(await sender.Send(new GetUsersQuery(User.GetUserId(), q, page, pageSize), ct));
}
[HttpPost]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
return Ok(await sender.Send(new CreateUserCommand(User.GetUserId(), request), ct));
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<UserDto>> Update(Guid id, [FromBody] UpdateUserRequest request, CancellationToken ct)
{
return Ok(await sender.Send(new UpdateUserCommand(User.GetUserId(), id, request), ct));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await sender.Send(new DeleteUserCommand(User.GetUserId(), id), ct);
return NoContent();
}
[HttpPost("{id:guid}/reset-password")]
public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct)
{
await sender.Send(new ResetPasswordCommand(User.GetUserId(), id, request), ct);
return NoContent();
}
[HttpGet("list")]
public async Task<ActionResult<List<UserDto>>> GetList([FromQuery] string? q, CancellationToken ct)
{
var query = db.Users.AsQueryable();
if (!string.IsNullOrWhiteSpace(q))
{
var lower = q.Trim().ToLower();
query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower));
}
var users = await query.OrderBy(u => u.DisplayName).ToListAsync(ct);
return Ok(mapper.Map<List<UserDto>>(users));
}
[HttpGet("{id:guid}/roles")]
public async Task<ActionResult<UserRoleDetailDto>> GetUserRoles(Guid id, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.View, ct);
var user = await db.Users
.Include(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.FirstOrDefaultAsync(u => u.Id == id, ct);
if (user is null) return NotFound("User not found");
var allRoles = await db.Roles.Include(r => r.Permissions).OrderBy(r => r.Name).ToListAsync(ct);
var assignedRoleIds = user.UserRoles.Select(ur => ur.RoleId).ToHashSet();
var assigned = allRoles.Where(r => assignedRoleIds.Contains(r.Id)).ToList();
var unassigned = allRoles.Where(r => !assignedRoleIds.Contains(r.Id)).ToList();
return Ok(new UserRoleDetailDto
{
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
AssignedRoles = mapper.Map<List<RoleDto>>(assigned),
UnassignedRoles = mapper.Map<List<RoleDto>>(unassigned)
});
}
[HttpPost("{id:guid}/roles")]
public async Task<IActionResult> AssignRole(Guid id, [FromBody] AssignRoleRequest req, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Edit, ct);
var user = await db.Users.Include(u => u.UserRoles).FirstOrDefaultAsync(u => u.Id == id, ct);
if (user is null) return NotFound("User not found");
if (!user.UserRoles.Any(ur => ur.RoleId == req.RoleId))
{
user.UserRoles.Add(new UserRole { UserId = id, RoleId = req.RoleId });
await db.SaveChangesAsync(ct);
}
return NoContent();
}
[HttpDelete("{id:guid}/roles/{roleId:guid}")]
public async Task<IActionResult> UnassignRole(Guid id, Guid roleId, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Edit, ct);
var ur = await db.UserRoles.FirstOrDefaultAsync(x => x.UserId == id && x.RoleId == roleId, ct);
if (ur != null)
{
db.UserRoles.Remove(ur);
await db.SaveChangesAsync(ct);
}
return NoContent();
}
[HttpGet("{id:guid}/permissions")]
public async Task<ActionResult<List<MenuItemDto>>> GetUserPermissions(Guid id, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.View, ct);
var roleIds = await db.UserRoles.Where(ur => ur.UserId == id).Select(ur => ur.RoleId).ToListAsync(ct);
var permissionsList = await db.RolePermissions.Where(p => roleIds.Contains(p.RoleId)).ToListAsync(ct);
var merged = permissionsList.GroupBy(p => p.Screen).ToDictionary(
g => g.Key,
g => new
{
CanView = g.Any(p => p.CanView),
CanCreate = g.Any(p => p.CanCreate),
CanEdit = g.Any(p => p.CanEdit),
CanDelete = g.Any(p => p.CanDelete),
}
);
var res = ScreenCatalog.Screens.Select(s =>
{
merged.TryGetValue(s.Key, out var p);
return new MenuItemDto
{
Key = s.Key,
Label = s.Label,
Path = s.Path,
CanView = p?.CanView ?? false,
CanCreate = p?.CanCreate ?? false,
CanEdit = p?.CanEdit ?? false,
CanDelete = p?.CanDelete ?? false,
};
}).ToList();
return Ok(res);
}
}
@@ -1,7 +1,7 @@
using System.Text.Json;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Api.Middleware;
namespace mws.backend.dotnet.api.Middleware;
public class ApiExceptionMiddleware(RequestDelegate next, ILogger<ApiExceptionMiddleware> logger)
{
+4 -4
View File
@@ -3,10 +3,10 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Mws.Api.Middleware;
using Mws.Application.Auth;
using Mws.Infrastructure;
using Mws.Infrastructure.Persistence;
using mws.backend.dotnet.api.Middleware;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.infrastructure;
using mws.backend.dotnet.infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>mws.backend.dotnet.api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\application\application.csproj" />
<ProjectReference Include="..\domain\domain.csproj" />
<ProjectReference Include="..\infrastructure\infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -1,22 +1,22 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=mws;Username=postgres;Password=Pa55w0rd"
},
"Jwt": {
"Secret": "mws-development-secret-key-change-me-in-production-123456",
"Issuer": "Mws",
"Audience": "Mws.Clients",
"Expiration": "12:00:00"
},
"Cors": {
"Origins": "*"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*"
{
"ConnectionStrings": {
"Default": "Host=192.168.2.100;Port=5432;Database=mws;Username=postgres;Password=Pa55w0rd"
},
"Jwt": {
"Secret": "mws-development-secret-key-change-me-in-production-123456",
"Issuer": "Mws",
"Audience": "Mws.Clients",
"Expiration": "12:00:00"
},
"Cors": {
"Origins": "*"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -1,8 +1,9 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Users;
namespace Mws.Application.Auth;
namespace mws.backend.dotnet.application.Auth;
public record LoginCommand(LoginRequest Request) : IRequest<LoginResponse>;
@@ -1,4 +1,6 @@
namespace Mws.Application.Auth;
using mws.backend.dotnet.application.Users;
namespace mws.backend.dotnet.application.Auth;
public class LoginRequest
{
@@ -6,15 +8,6 @@ public class LoginRequest
public string Password { get; set; } = string.Empty;
}
public class UserDto
{
public Guid Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public Guid RoleId { get; set; }
public string RoleName { get; set; } = string.Empty;
}
public class LoginResponse
{
public string Token { get; set; } = string.Empty;
+23
View File
@@ -0,0 +1,23 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Users;
namespace mws.backend.dotnet.application.Auth;
public record GetProfileQuery(Guid UserId) : IRequest<UserDto>;
public class GetProfileHandler(IUnitOfWork uow, IMapper mapper)
: IRequestHandler<GetProfileQuery, UserDto>
{
public async Task<UserDto> Handle(GetProfileQuery request, CancellationToken ct)
{
var user = await uow.Users.GetByIdWithRoleAsync(request.UserId, ct);
if (user is null)
{
throw new NotFoundException("User not found");
}
return mapper.Map<UserDto>(user);
}
}
@@ -1,4 +1,4 @@
namespace Mws.Application.Common;
namespace mws.backend.dotnet.application.Common;
public class NotFoundException(string message) : Exception(message);
@@ -1,6 +1,6 @@
using Mws.Application.Common.Repositories;
using mws.backend.dotnet.application.Common.Repositories;
namespace Mws.Application.Common;
namespace mws.backend.dotnet.application.Common;
public interface IUnitOfWork
{
@@ -9,6 +9,7 @@ public interface IUnitOfWork
ITaskRepository Tasks { get; }
IDocumentRepository Documents { get; }
IRoleRepository Roles { get; }
IMasterDataRepository MasterData { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
+37
View File
@@ -0,0 +1,37 @@
using AutoMapper;
using mws.backend.dotnet.application.Documents;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.application.Projects;
using mws.backend.dotnet.application.Tasks;
using mws.backend.dotnet.application.Users;
using mws.backend.dotnet.domain.Documents;
using mws.backend.dotnet.domain.MasterData;
using mws.backend.dotnet.domain.Roles;
using mws.backend.dotnet.domain.Tasks;
using mws.backend.dotnet.domain.Users;
namespace mws.backend.dotnet.application.Common;
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<mws.backend.dotnet.domain.Projects.Project, ProjectDto>()
.ForMember(d => d.CreatedByName, opt => opt.MapFrom(s => s.CreatedByUser == null ? null : s.CreatedByUser.DisplayName))
.ForMember(d => d.UpdatedByName, opt => opt.MapFrom(s => s.UpdatedByUser == null ? null : s.UpdatedByUser.DisplayName));
CreateMap<Role, RoleDto>();
CreateMap<MasterDataEntry, MasterData.MasterDataDto>();
CreateMap<RolePermission, PermissionEntryDto>();
CreateMap<User, UserDto>()
.ForMember(d => d.RoleId, opt => opt.MapFrom(s => s.UserRoles.Select(r => r.RoleId).FirstOrDefault()))
.ForMember(d => d.RoleName, opt => opt.MapFrom(s => string.Join(", ", s.UserRoles.Select(r => r.Role.Name))));
CreateMap<Document, DocumentDto>();
CreateMap<Document, DocumentNodeDto>()
.ForMember(d => d.Children, opt => opt.Ignore());
CreateMap<TaskItem, TaskDto>()
.ForMember(d => d.AssigneeName, opt => opt.MapFrom(s => s.Assignee == null ? null : s.Assignee.DisplayName));
CreateMap<TaskItem, RecentTaskDto>()
.ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString()));
CreateMap<Document, RecentDocumentDto>();
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace mws.backend.dotnet.application.Common;
public class PagedResult<T>
{
public List<T> Items { get; init; } = [];
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
}
@@ -1,6 +1,6 @@
using Mws.Domain.Documents;
using mws.backend.dotnet.domain.Documents;
namespace Mws.Application.Common.Repositories;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface IDocumentRepository : IRepository<Document>
{
@@ -0,0 +1,12 @@
using mws.backend.dotnet.application.Common;
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<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,12 +1,13 @@
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Common.Repositories;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface IProjectRepository : IRepository<Project>
{
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Project?> GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<List<Project>> GetForUserAsync(Guid userId, CancellationToken ct = default);
Task<PagedResult<Project>> GetForUserAsync(Guid userId, 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);
@@ -14,7 +15,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<List<ProjectMember>> GetMembersWithUserAsync(Guid projectId, CancellationToken ct = default);
Task<PagedResult<ProjectMember>> GetMembersWithUserAsync(Guid projectId, 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,4 @@
namespace Mws.Application.Common.Repositories;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface IRepository<in T> where T : class
{
@@ -1,14 +1,16 @@
using Mws.Domain.Roles;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Roles;
namespace Mws.Application.Common.Repositories;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface IRoleRepository : IRepository<Role>
{
Task<List<Role>> GetAllWithPermissionsAsync(CancellationToken ct = default);
Task<PagedResult<Role>> GetAllWithPermissionsAsync(int page, int pageSize, CancellationToken ct = default);
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default);
Task<Role?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<bool> ExistsByNameAsync(string name, Guid? excludeId, CancellationToken ct = default);
Task<RolePermission?> GetPermissionAsync(Guid roleId, string screen, CancellationToken ct = default);
Task<Dictionary<string, RolePermission>> GetPermissionsAsync(Guid roleId, CancellationToken ct = default);
Task<List<RolePermission>> GetPermissionsForRolesAsync(IEnumerable<Guid> roleIds, CancellationToken ct = default);
void RemovePermissions(IEnumerable<RolePermission> permissions);
}
@@ -1,15 +1,16 @@
using Mws.Domain.Tasks;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Tasks;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
namespace Mws.Application.Common.Repositories;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface ITaskRepository : IRepository<TaskItem>
{
Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<TaskItem?> GetWithAssigneeAsync(Guid id, CancellationToken ct = default);
Task<List<TaskItem>> GetForProjectAsync(
Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default);
Task<PagedResult<TaskItem>> GetForProjectAsync(
Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, int page, int pageSize, CancellationToken ct = default);
Task<List<TaskItem>> SearchWithAssigneeAsync(List<Guid> projectIds, string? term, int take, CancellationToken ct = default);
Task<Dictionary<TaskStatus, int>> CountByStatusForProjectAsync(Guid projectId, CancellationToken ct = default);
Task<List<TaskItem>> GetRecentForProjectAsync(Guid projectId, int take, CancellationToken ct = default);
@@ -1,6 +1,7 @@
using Mws.Domain.Users;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Users;
namespace Mws.Application.Common.Repositories;
namespace mws.backend.dotnet.application.Common.Repositories;
public interface IUserRepository : IRepository<User>
{
@@ -9,6 +10,7 @@ public interface IUserRepository : IRepository<User>
Task<User?> GetByUsernameWithRoleAsync(string username, CancellationToken ct = default);
Task<bool> ExistsByUsernameAsync(string username, CancellationToken ct = default);
Task<bool> ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default);
Task<Guid> GetRoleIdAsync(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<PagedResult<User>> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default);
}
@@ -1,10 +1,10 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>;
@@ -1,8 +1,8 @@
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest;
@@ -1,9 +1,9 @@
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest;
@@ -1,10 +1,10 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>;
@@ -1,6 +1,6 @@
using Mws.Domain.Documents;
using mws.backend.dotnet.domain.Documents;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public class CreateDocumentRequest
{
@@ -1,9 +1,9 @@
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Documents;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
internal static class DocumentAccess
{
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest<DocumentDto>;
@@ -1,9 +1,9 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest<List<DocumentNodeDto>>;
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Documents;
namespace mws.backend.dotnet.application.Documents;
public record SearchDocumentsQuery(Guid UserId, string Term) : IRequest<List<DocumentNodeDto>>;
@@ -0,0 +1,24 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record CreateMasterDataCommand(SaveMasterDataRequest Request) : IRequest<MasterDataDto>;
public class CreateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateMasterDataCommand, MasterDataDto>
{
public async Task<MasterDataDto> Handle(CreateMasterDataCommand command, CancellationToken ct)
{
var entry = await MasterDataValidation.ValidateAsync(uow, command.Request, null, ct);
var now = DateTime.UtcNow;
entry.Id = Guid.NewGuid();
entry.CreatedAt = now;
entry.UpdatedAt = now;
uow.MasterData.Add(entry);
await uow.SaveChangesAsync(ct);
return mapper.Map<MasterDataDto>(entry);
}
}
@@ -0,0 +1,18 @@
using MediatR;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record DeleteMasterDataCommand(Guid Id) : IRequest;
public class DeleteMasterDataHandler(IUnitOfWork uow) : IRequestHandler<DeleteMasterDataCommand>
{
public async Task Handle(DeleteMasterDataCommand command, CancellationToken ct)
{
var entry = await uow.MasterData.GetByIdAsync(command.Id, ct)
?? throw new NotFoundException("Master data entry not found");
uow.MasterData.Remove(entry);
await uow.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,28 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record UpdateMasterDataCommand(Guid Id, SaveMasterDataRequest Request) : IRequest<MasterDataDto>;
public class UpdateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateMasterDataCommand, MasterDataDto>
{
public async Task<MasterDataDto> Handle(UpdateMasterDataCommand command, CancellationToken ct)
{
var existing = await uow.MasterData.GetByIdAsync(command.Id, ct)
?? throw new NotFoundException("Master data entry not found");
var updated = await MasterDataValidation.ValidateAsync(uow, command.Request, command.Id, ct);
existing.Group = updated.Group;
existing.Label = updated.Label;
existing.Value = updated.Value;
existing.SortOrder = updated.SortOrder;
existing.IsActive = updated.IsActive;
existing.UpdatedAt = DateTime.UtcNow;
await uow.SaveChangesAsync(ct);
return mapper.Map<MasterDataDto>(existing);
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace mws.backend.dotnet.application.MasterData;
public class MasterDataDto
{
public Guid Id { get; set; }
public string Group { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
public class SaveMasterDataRequest
{
public string Group { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,33 @@
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.MasterData;
namespace mws.backend.dotnet.application.MasterData;
internal static class MasterDataValidation
{
public static async Task<MasterDataEntry> ValidateAsync(IUnitOfWork uow, SaveMasterDataRequest request, Guid? excludeId, CancellationToken ct)
{
var group = request.Group.Trim();
var label = request.Label.Trim();
var value = request.Value.Trim();
if (string.IsNullOrWhiteSpace(group) || string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(value))
{
throw new BadRequestException("Group, label and value are required");
}
if (await uow.MasterData.ExistsAsync(group, value, excludeId, ct))
{
throw new BadRequestException("An entry with this group and value already exists");
}
return new MasterDataEntry
{
Group = group,
Label = label,
Value = value,
SortOrder = request.SortOrder,
IsActive = request.IsActive,
};
}
}
@@ -0,0 +1,16 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.MasterData;
public record GetMasterDataByGroupQuery(string Group) : IRequest<List<MasterDataDto>>;
public class GetMasterDataByGroupHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetMasterDataByGroupQuery, List<MasterDataDto>>
{
public async Task<List<MasterDataDto>> Handle(GetMasterDataByGroupQuery query, CancellationToken ct)
{
var entries = await uow.MasterData.GetActiveByGroupAsync(query.Group, ct);
return mapper.Map<List<MasterDataDto>>(entries);
}
}
@@ -0,0 +1,22 @@
using AutoMapper;
using MediatR;
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 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);
return new PagedResult<MasterDataDto>
{
Items = mapper.Map<List<MasterDataDto>>(entries.Items),
TotalCount = entries.TotalCount,
Page = entries.Page,
PageSize = entries.PageSize,
};
}
}
@@ -1,9 +1,9 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Roles;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Roles;
namespace Mws.Application.Roles;
namespace mws.backend.dotnet.application.Permissions;
public record CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>;
@@ -1,7 +1,7 @@
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Roles;
namespace mws.backend.dotnet.application.Permissions;
public record DeleteRoleCommand(Guid Id) : IRequest;
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Roles;
namespace mws.backend.dotnet.application.Permissions;
public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>;
@@ -1,4 +1,23 @@
namespace Mws.Application.Roles;
namespace mws.backend.dotnet.application.Permissions;
public enum PermissionAction
{
View,
Create,
Edit,
Delete,
}
public class MenuItemDto
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public bool CanView { get; set; }
public bool CanCreate { get; set; }
public bool CanEdit { get; set; }
public bool CanDelete { get; set; }
}
public class PermissionEntryDto
{
@@ -1,4 +1,4 @@
namespace Mws.Application.Permissions;
namespace mws.backend.dotnet.application.Permissions;
public interface IPermissionService
{
@@ -0,0 +1,59 @@
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.Permissions;
public class PermissionService(IUnitOfWork uow) : IPermissionService
{
public async Task<List<MenuItemDto>> GetMenuAsync(Guid userId, CancellationToken ct = default)
{
var roleIds = await uow.Users.GetRoleIdsAsync(userId, ct);
var permissions = await uow.Roles.GetPermissionsForRolesAsync(roleIds, ct);
var merged = permissions.GroupBy(p => p.Screen).ToDictionary(
g => g.Key,
g => new
{
CanView = g.Any(p => p.CanView),
CanCreate = g.Any(p => p.CanCreate),
CanEdit = g.Any(p => p.CanEdit),
CanDelete = g.Any(p => p.CanDelete),
}
);
return ScreenCatalog.Screens.Select(s =>
{
merged.TryGetValue(s.Key, out var p);
return new MenuItemDto
{
Key = s.Key,
Label = s.Label,
Path = s.Path,
CanView = p?.CanView ?? false,
CanCreate = p?.CanCreate ?? false,
CanEdit = p?.CanEdit ?? false,
CanDelete = p?.CanDelete ?? false,
};
}).ToList();
}
public async Task EnsureAsync(Guid userId, string screen, PermissionAction action, CancellationToken ct = default)
{
var roleIds = await uow.Users.GetRoleIdsAsync(userId, ct);
var permissions = await uow.Roles.GetPermissionsForRolesAsync(roleIds, ct);
var screenPermissions = permissions.Where(p => p.Screen == screen).ToList();
var allowed = action switch
{
PermissionAction.View => screenPermissions.Any(p => p.CanView),
PermissionAction.Create => screenPermissions.Any(p => p.CanCreate),
PermissionAction.Edit => screenPermissions.Any(p => p.CanEdit),
PermissionAction.Delete => screenPermissions.Any(p => p.CanDelete),
_ => false,
};
if (!allowed)
{
throw new ForbiddenException($"Not permitted to {action} on {screen}");
}
}
}
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Roles;
namespace mws.backend.dotnet.application.Permissions;
public record GetRoleQuery(Guid Id) : IRequest<RoleDto>;
@@ -0,0 +1,22 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
namespace mws.backend.dotnet.application.Permissions;
public record GetRolesQuery(int Page, int PageSize) : IRequest<PagedResult<RoleDto>>;
public class GetRolesHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetRolesQuery, PagedResult<RoleDto>>
{
public async Task<PagedResult<RoleDto>> Handle(GetRolesQuery query, CancellationToken ct)
{
var roles = await uow.Roles.GetAllWithPermissionsAsync(query.Page, query.PageSize, ct);
return new PagedResult<RoleDto>
{
Items = mapper.Map<List<RoleDto>>(roles.Items),
TotalCount = roles.TotalCount,
Page = roles.Page,
PageSize = roles.PageSize,
};
}
}
@@ -1,7 +1,7 @@
using Mws.Application.Permissions;
using Mws.Domain.Roles;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Roles;
namespace Mws.Application.Roles;
namespace mws.backend.dotnet.application.Permissions;
internal static class RolePermissionBuilder
{
@@ -1,4 +1,4 @@
namespace Mws.Application.Permissions;
namespace mws.backend.dotnet.application.Permissions;
public record ScreenDefinition(string Key, string Label, string Path);
@@ -10,8 +10,9 @@ public static class ScreenCatalog
new("projects", "Projects", "/projects"),
new("tasks", "Tasks", "/tasks"),
new("documents", "Documents", "/documents"),
new("accounts", "Accounts", "/accounts"),
new("roles", "Roles", "/roles"),
new("users", "Users", "/users"),
new("permissions", "Permissions", "/settings/permission"),
new("masterdata", "Master Data", "/settings/masterdata"),
];
public static readonly IReadOnlySet<string> Keys = Screens.Select(s => s.Key).ToHashSet();
@@ -1,8 +1,8 @@
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>;
@@ -1,8 +1,8 @@
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest;
@@ -1,9 +1,9 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>;
@@ -24,7 +24,9 @@ public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHan
Name = request.Name.Trim(),
Description = request.Description,
Status = ProjectStatus.Active,
CreatedBy = command.UserId,
CreatedAt = now,
UpdatedBy = command.UserId,
UpdatedAt = now,
};
@@ -1,8 +1,8 @@
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest;
@@ -1,8 +1,8 @@
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record UpdateMemberDocumentPermissionsCommand(
Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>;
@@ -1,9 +1,9 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>;
@@ -27,6 +27,7 @@ public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHan
project.Name = request.Name.Trim();
project.Description = request.Description;
project.Status = request.Status;
project.UpdatedBy = command.UserId;
project.UpdatedAt = DateTime.UtcNow;
await uow.SaveChangesAsync(ct);
@@ -1,6 +1,6 @@
using Mws.Domain.Projects;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public class CreateProjectRequest
{
@@ -47,7 +47,11 @@ public class ProjectDto
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public ProjectStatus Status { get; set; }
public Guid CreatedBy { get; set; }
public string CreatedByName { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public Guid? UpdatedBy { get; set; }
public string? UpdatedByName { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -1,7 +1,7 @@
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
internal static class ProjectAccess
{
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectDto>;
@@ -1,14 +1,14 @@
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Projects;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest<List<ProjectMemberDto>>;
public record GetProjectMembersQuery(Guid UserId, Guid ProjectId, int Page, int PageSize) : IRequest<PagedResult<ProjectMemberDto>>;
public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProjectMembersQuery, List<ProjectMemberDto>>
public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProjectMembersQuery, PagedResult<ProjectMemberDto>>
{
public async Task<List<ProjectMemberDto>> Handle(GetProjectMembersQuery query, CancellationToken ct)
public async Task<PagedResult<ProjectMemberDto>> Handle(GetProjectMembersQuery query, CancellationToken ct)
{
var isMember = await uow.Projects.IsMemberAsync(query.ProjectId, query.UserId, ct);
if (!isMember)
@@ -16,10 +16,10 @@ public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProj
throw new NotFoundException("Project not found");
}
var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, ct);
var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, query.Page, query.PageSize, ct);
var permissions = await uow.Projects.GetMemberPermissionsAsync(query.ProjectId, ProjectPermissionScreens.Documents, ct);
return members.Select(m =>
var items = members.Items.Select(m =>
{
permissions.TryGetValue(m.UserId, out var p);
return new ProjectMemberDto
@@ -34,5 +34,13 @@ public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProj
CanDeleteDocuments = m.Role == MemberRole.Owner || (p?.CanDelete ?? false),
};
}).ToList();
return new PagedResult<ProjectMemberDto>
{
Items = items,
TotalCount = members.TotalCount,
Page = members.Page,
PageSize = members.PageSize,
};
}
}
@@ -1,9 +1,9 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Documents;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Documents;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectOverviewDto>;
@@ -0,0 +1,22 @@
using AutoMapper;
using MediatR;
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 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);
return new PagedResult<ProjectDto>
{
Items = mapper.Map<List<ProjectDto>>(projects.Items),
TotalCount = projects.TotalCount,
Page = projects.Page,
PageSize = projects.PageSize,
};
}
}
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Projects;
namespace mws.backend.dotnet.application.Projects;
public record SearchProjectsQuery(Guid UserId, string Term) : IRequest<List<ProjectDto>>;
@@ -1,11 +1,11 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Tasks;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Tasks;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>;
@@ -1,7 +1,7 @@
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest;
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>;
@@ -1,8 +1,8 @@
using Mws.Domain.Tasks;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
using mws.backend.dotnet.domain.Tasks;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
public class CreateTaskRequest
{
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
public record GetTaskQuery(Guid UserId, Guid TaskId) : IRequest<TaskDto>;
+27
View File
@@ -0,0 +1,27 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority;
using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus;
namespace mws.backend.dotnet.application.Tasks;
public record GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId, int Page, int PageSize)
: IRequest<PagedResult<TaskDto>>;
public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetTasksQuery, PagedResult<TaskDto>>
{
public async Task<PagedResult<TaskDto>> Handle(GetTasksQuery query, CancellationToken ct)
{
await TaskAccess.EnsureMemberAccessAsync(uow, query.UserId, query.ProjectId, ct);
var tasks = await uow.Tasks.GetForProjectAsync(query.ProjectId, query.Status, query.Priority, query.AssigneeId, query.Page, query.PageSize, ct);
return new PagedResult<TaskDto>
{
Items = mapper.Map<List<TaskDto>>(tasks.Items),
TotalCount = tasks.TotalCount,
Page = tasks.Page,
PageSize = tasks.PageSize,
};
}
}
@@ -1,8 +1,8 @@
using AutoMapper;
using MediatR;
using Mws.Application.Common;
using mws.backend.dotnet.application.Common;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
public record SearchTasksQuery(Guid UserId, string Term) : IRequest<List<TaskDto>>;
@@ -1,8 +1,8 @@
using AutoMapper;
using Mws.Application.Common;
using Mws.Domain.Tasks;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.domain.Tasks;
namespace Mws.Application.Tasks;
namespace mws.backend.dotnet.application.Tasks;
internal static class TaskAccess
{
+58
View File
@@ -0,0 +1,58 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.domain.Roles;
using mws.backend.dotnet.domain.Users;
namespace mws.backend.dotnet.application.Users;
public record CreateUserCommand(Guid ActorUserId, CreateUserRequest Request) : IRequest<UserDto>;
public class CreateUserHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper)
: IRequestHandler<CreateUserCommand, UserDto>
{
private const string Screen = "users";
public async Task<UserDto> Handle(CreateUserCommand command, CancellationToken ct)
{
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Create, ct);
var request = command.Request;
var username = request.Username.Trim();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
{
throw new BadRequestException("Username and password are required");
}
if (await uow.Users.ExistsByUsernameAsync(username, ct))
{
throw new BadRequestException("Username already exists");
}
Role? role = null;
if (request.RoleId.HasValue)
{
role = await uow.Roles.GetByIdAsync(request.RoleId.Value, ct)
?? throw new BadRequestException("Role not found");
}
var now = DateTime.UtcNow;
var user = new User
{
Id = Guid.NewGuid(),
Username = username,
PasswordHash = passwordHasher.Hash(request.Password),
DisplayName = request.DisplayName.Trim(),
IsActive = true,
CreatedAt = now,
UpdatedAt = now,
UserRoles = role != null ? new List<UserRole> { new UserRole { RoleId = role.Id } } : new List<UserRole>()
};
uow.Users.Add(user);
await uow.SaveChangesAsync(ct);
return mapper.Map<UserDto>(user);
}
}
+34
View File
@@ -0,0 +1,34 @@
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
public record DeleteUserCommand(Guid ActorUserId, Guid Id) : IRequest;
public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler<DeleteUserCommand>
{
private const string Screen = "users";
public async Task Handle(DeleteUserCommand command, CancellationToken ct)
{
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Delete, ct);
var user = await uow.Users.GetByIdAsync(command.Id, ct)
?? throw new NotFoundException("User not found");
var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(command.Id, ct);
foreach (var projectId in soleOwnerProjectIds)
{
var ownerCount = await uow.Projects.CountOwnersAsync(projectId, ct);
if (ownerCount <= 1)
{
throw new BadRequestException("Cannot delete a user that is the sole owner of a project");
}
}
uow.Users.Remove(user);
await uow.SaveChangesAsync(ct);
}
}
@@ -1,16 +1,16 @@
using MediatR;
using Mws.Application.Auth;
using Mws.Application.Common;
using Mws.Application.Permissions;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace Mws.Application.Accounts;
namespace mws.backend.dotnet.application.Users;
public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest;
public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions)
: IRequestHandler<ResetPasswordCommand>
{
private const string Screen = "accounts";
private const string Screen = "users";
public async Task Handle(ResetPasswordCommand command, CancellationToken ct)
{
@@ -22,7 +22,7 @@ public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHashe
}
var user = await uow.Users.GetByIdAsync(command.Id, ct)
?? throw new NotFoundException("Account not found");
?? throw new NotFoundException("User not found");
user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword);
user.UpdatedAt = DateTime.UtcNow;
+36
View File
@@ -0,0 +1,36 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
namespace mws.backend.dotnet.application.Users;
public record UpdateUserCommand(Guid ActorUserId, Guid Id, UpdateUserRequest Request) : IRequest<UserDto>;
public class UpdateUserHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
: IRequestHandler<UpdateUserCommand, UserDto>
{
private const string Screen = "users";
public async Task<UserDto> Handle(UpdateUserCommand command, CancellationToken ct)
{
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct);
var user = await uow.Users.GetByIdWithRoleAsync(command.Id, ct)
?? throw new NotFoundException("User not found");
var request = command.Request;
if (request.RoleId.HasValue)
{
var role = await uow.Roles.GetByIdAsync(request.RoleId.Value, ct)
?? throw new BadRequestException("Role not found");
}
user.DisplayName = request.DisplayName.Trim();
user.IsActive = request.IsActive;
user.UpdatedAt = DateTime.UtcNow;
await uow.SaveChangesAsync(ct);
return mapper.Map<UserDto>(user);
}
}
@@ -1,17 +1,19 @@
namespace Mws.Application.Accounts;
using mws.backend.dotnet.application.Permissions;
public class CreateAccountRequest
namespace mws.backend.dotnet.application.Users;
public class CreateUserRequest
{
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public Guid RoleId { get; set; }
public Guid? RoleId { get; set; }
}
public class UpdateAccountRequest
public class UpdateUserRequest
{
public string DisplayName { get; set; } = string.Empty;
public Guid RoleId { get; set; }
public Guid? RoleId { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -20,7 +22,7 @@ public class ResetPasswordRequest
public string NewPassword { get; set; } = string.Empty;
}
public class AccountDto
public class UserDto
{
public Guid Id { get; set; }
public string Username { get; set; } = string.Empty;
@@ -30,3 +32,17 @@ public class AccountDto
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
public class UserRoleDetailDto
{
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public List<RoleDto> AssignedRoles { get; set; } = [];
public List<RoleDto> UnassignedRoles { get; set; } = [];
}
public class AssignRoleRequest
{
public Guid RoleId { get; set; }
}
+28
View File
@@ -0,0 +1,28 @@
using AutoMapper;
using MediatR;
using mws.backend.dotnet.application.Common;
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 class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
: IRequestHandler<GetUsersQuery, PagedResult<UserDto>>
{
private const string Screen = "users";
public async Task<PagedResult<UserDto>> Handle(GetUsersQuery query, CancellationToken ct)
{
await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct);
var users = await uow.Users.SearchWithRolePagedAsync(query.Term, query.Page, query.PageSize, ct);
return new PagedResult<UserDto>
{
Items = mapper.Map<List<UserDto>>(users.Items),
TotalCount = users.TotalCount,
Page = users.Page,
PageSize = users.PageSize,
};
}
}
@@ -4,10 +4,11 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>mws.backend.dotnet.application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\mws.domain\mws.domain.csproj" />
<ProjectReference Include="..\domain\domain.csproj" />
</ItemGroup>
<ItemGroup>
@@ -1,4 +1,4 @@
namespace Mws.Domain.Documents;
namespace mws.backend.dotnet.domain.Documents;
public enum DocumentType
{
+13
View File
@@ -0,0 +1,13 @@
namespace mws.backend.dotnet.domain.MasterData;
public class MasterDataEntry
{
public Guid Id { get; set; }
public string Group { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -1,4 +1,4 @@
namespace Mws.Domain.Projects;
namespace mws.backend.dotnet.domain.Projects;
public enum ProjectStatus
{
@@ -12,8 +12,13 @@ public class Project
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public ProjectStatus Status { get; set; } = ProjectStatus.Active;
public Guid CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public Guid? UpdatedBy { get; set; }
public DateTime UpdatedAt { get; set; }
public List<ProjectMember> Members { get; set; } = [];
public mws.backend.dotnet.domain.Users.User? CreatedByUser { get; set; }
public mws.backend.dotnet.domain.Users.User? UpdatedByUser { get; set; }
}
@@ -1,4 +1,4 @@
namespace Mws.Domain.Projects;
namespace mws.backend.dotnet.domain.Projects;
public enum MemberRole
{
@@ -13,5 +13,5 @@ public class ProjectMember
public MemberRole Role { get; set; } = MemberRole.Member;
public Project Project { get; set; } = null!;
public Mws.Domain.Users.User User { get; set; } = null!;
public mws.backend.dotnet.domain.Users.User User { get; set; } = null!;
}
@@ -1,4 +1,4 @@
namespace Mws.Domain.Projects;
namespace mws.backend.dotnet.domain.Projects;
public static class ProjectPermissionScreens
{
@@ -1,4 +1,4 @@
namespace Mws.Domain.Roles;
namespace mws.backend.dotnet.domain.Roles;
public class Role
{
@@ -9,4 +9,5 @@ public class Role
public DateTime UpdatedAt { get; set; }
public List<RolePermission> Permissions { get; set; } = [];
public List<mws.backend.dotnet.domain.Users.UserRole> UserRoles { get; set; } = [];
}
@@ -1,4 +1,4 @@
namespace Mws.Domain.Roles;
namespace mws.backend.dotnet.domain.Roles;
public class RolePermission
{
@@ -1,4 +1,4 @@
namespace Mws.Domain.Tasks;
namespace mws.backend.dotnet.domain.Tasks;
public enum TaskStatus
{
@@ -29,5 +29,5 @@ public class TaskItem
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public Mws.Domain.Users.User? Assignee { get; set; }
public mws.backend.dotnet.domain.Users.User? Assignee { get; set; }
}
@@ -1,6 +1,6 @@
using Mws.Domain.Roles;
using mws.backend.dotnet.domain.Roles;
namespace Mws.Domain.Users;
namespace mws.backend.dotnet.domain.Users;
public class User
{
@@ -8,10 +8,8 @@ public class User
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public Guid RoleId { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public Role Role { get; set; } = null!;
public List<UserRole> UserRoles { get; set; } = [];
}
+12
View File
@@ -0,0 +1,12 @@
using mws.backend.dotnet.domain.Roles;
namespace mws.backend.dotnet.domain.Users;
public class UserRole
{
public Guid UserId { get; set; }
public Guid RoleId { get; set; }
public User User { get; set; } = null!;
public Role Role { get; set; } = null!;
}
@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>mws.backend.dotnet.domain</RootNamespace>
</PropertyGroup>
</Project>
@@ -1,6 +1,6 @@
using Mws.Application.Auth;
using mws.backend.dotnet.application.Auth;
namespace Mws.Infrastructure.Authentication;
namespace mws.backend.dotnet.infrastructure.Authentication;
public class BcryptPasswordHasher : IPasswordHasher
{
@@ -1,4 +1,4 @@
namespace Mws.Infrastructure.Authentication;
namespace mws.backend.dotnet.infrastructure.Authentication;
public class JwtOptions
{
@@ -2,9 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Mws.Application.Auth;
using mws.backend.dotnet.application.Auth;
namespace Mws.Infrastructure.Authentication;
namespace mws.backend.dotnet.infrastructure.Authentication;
public class JwtTokenService(JwtOptions options) : ITokenService
{
@@ -1,13 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mws.Application.Auth;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Infrastructure.Authentication;
using Mws.Infrastructure.Persistence;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.infrastructure.Authentication;
using mws.backend.dotnet.infrastructure.Persistence;
namespace Mws.Infrastructure;
namespace mws.backend.dotnet.infrastructure;
public static class DependencyInjection
{

Some files were not shown because too many files have changed in this diff Show More