diff --git a/CLAUDE.md b/CLAUDE.md index 9288029..d2e6f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --project Mws.Infrastructure --startup-project Mws.Api +dotnet ef migrations add --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`. +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`. ### 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`, `enable`, `enable` 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`). diff --git a/Dockerfile b/Dockerfile index 77e3ad1..c954a04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +ENTRYPOINT ["dotnet", "mws.api.dll"] \ No newline at end of file diff --git a/mws.api/ClaimsExtensions.cs b/api/ClaimsExtensions.cs similarity index 94% rename from mws.api/ClaimsExtensions.cs rename to api/ClaimsExtensions.cs index 7d35d34..a874e01 100644 --- a/mws.api/ClaimsExtensions.cs +++ b/api/ClaimsExtensions.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using Microsoft.IdentityModel.JsonWebTokens; -namespace Mws.Api; +namespace mws.backend.dotnet.api; public static class ClaimsExtensions { diff --git a/mws.api/Controllers/AuthController.cs b/api/Controllers/AuthController.cs similarity index 54% rename from mws.api/Controllers/AuthController.cs rename to api/Controllers/AuthController.cs index 8ee28c2..2533232 100644 --- a/mws.api/Controllers/AuthController.cs +++ b/api/Controllers/AuthController.cs @@ -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> Login([FromBody] LoginRequest request, CancellationToken ct) { var response = await sender.Send(new LoginCommand(request), ct); return Ok(response); } + + [HttpGet("me")] + [Authorize] + public async Task> GetProfile(CancellationToken ct) + { + return Ok(await sender.Send(new GetProfileQuery(User.GetUserId()), ct)); + } } diff --git a/mws.api/Controllers/DocumentsController.cs b/api/Controllers/DocumentsController.cs similarity index 95% rename from mws.api/Controllers/DocumentsController.cs rename to api/Controllers/DocumentsController.cs index 83237be..b8461c6 100644 --- a/mws.api/Controllers/DocumentsController.cs +++ b/api/Controllers/DocumentsController.cs @@ -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")] diff --git a/api/Controllers/MasterDataController.cs b/api/Controllers/MasterDataController.cs new file mode 100644 index 0000000..efddca7 --- /dev/null +++ b/api/Controllers/MasterDataController.cs @@ -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>> GetByGroup(string group, CancellationToken ct) + { + return Ok(await sender.Send(new GetMasterDataByGroupQuery(group), ct)); + } + + [HttpGet] + public async Task>> 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> 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> 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 Delete(Guid id, CancellationToken ct) + { + await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.Delete, ct); + await sender.Send(new DeleteMasterDataCommand(id), ct); + return NoContent(); + } +} diff --git a/api/Controllers/PermissionsController.cs b/api/Controllers/PermissionsController.cs new file mode 100644 index 0000000..bde0a65 --- /dev/null +++ b/api/Controllers/PermissionsController.cs @@ -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>> GetMenu(CancellationToken ct) + { + return Ok(await permissions.GetMenuAsync(User.GetUserId(), ct)); + } + + [HttpGet] + public async Task>> 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> 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> 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> 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 Delete(Guid id, CancellationToken ct) + { + await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Delete, ct); + await sender.Send(new DeleteRoleCommand(id), ct); + return NoContent(); + } +} diff --git a/mws.api/Controllers/ProjectMembersController.cs b/api/Controllers/ProjectMembersController.cs similarity index 77% rename from mws.api/Controllers/ProjectMembersController.cs rename to api/Controllers/ProjectMembersController.cs index 02c4acb..a0d0478 100644 --- a/mws.api/Controllers/ProjectMembersController.cs +++ b/api/Controllers/ProjectMembersController.cs @@ -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>> GetAll(Guid projectId, CancellationToken ct) + public async Task>> 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] diff --git a/mws.api/Controllers/ProjectsController.cs b/api/Controllers/ProjectsController.cs similarity index 85% rename from mws.api/Controllers/ProjectsController.cs rename to api/Controllers/ProjectsController.cs index 0345d92..72642f2 100644 --- a/mws.api/Controllers/ProjectsController.cs +++ b/api/Controllers/ProjectsController.cs @@ -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>> GetAll(CancellationToken ct) + public async Task>> 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")] diff --git a/mws.api/Controllers/TasksController.cs b/api/Controllers/TasksController.cs similarity index 81% rename from mws.api/Controllers/TasksController.cs rename to api/Controllers/TasksController.cs index 6f448f4..425bba4 100644 --- a/mws.api/Controllers/TasksController.cs +++ b/api/Controllers/TasksController.cs @@ -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>> GetAll( + public async Task>> 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(status); var priorityValue = ParseOptional(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")] diff --git a/api/Controllers/UsersController.cs b/api/Controllers/UsersController.cs new file mode 100644 index 0000000..e17c247 --- /dev/null +++ b/api/Controllers/UsersController.cs @@ -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>> 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> Create([FromBody] CreateUserRequest request, CancellationToken ct) + { + return Ok(await sender.Send(new CreateUserCommand(User.GetUserId(), request), ct)); + } + + [HttpPut("{id:guid}")] + public async Task> 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 Delete(Guid id, CancellationToken ct) + { + await sender.Send(new DeleteUserCommand(User.GetUserId(), id), ct); + return NoContent(); + } + + [HttpPost("{id:guid}/reset-password")] + public async Task 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>> 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>(users)); + } + + [HttpGet("{id:guid}/roles")] + public async Task> 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>(assigned), + UnassignedRoles = mapper.Map>(unassigned) + }); + } + + [HttpPost("{id:guid}/roles")] + public async Task 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 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>> 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); + } +} diff --git a/mws.api/Middleware/ApiExceptionMiddleware.cs b/api/Middleware/ApiExceptionMiddleware.cs similarity index 94% rename from mws.api/Middleware/ApiExceptionMiddleware.cs rename to api/Middleware/ApiExceptionMiddleware.cs index 46d03d1..1368ba5 100644 --- a/mws.api/Middleware/ApiExceptionMiddleware.cs +++ b/api/Middleware/ApiExceptionMiddleware.cs @@ -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 logger) { diff --git a/mws.api/Program.cs b/api/Program.cs similarity index 94% rename from mws.api/Program.cs rename to api/Program.cs index 404f425..0c4d49f 100644 --- a/mws.api/Program.cs +++ b/api/Program.cs @@ -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); diff --git a/mws.api/Properties/launchSettings.json b/api/Properties/launchSettings.json similarity index 100% rename from mws.api/Properties/launchSettings.json rename to api/Properties/launchSettings.json diff --git a/api/api.csproj b/api/api.csproj new file mode 100644 index 0000000..a6c5547 --- /dev/null +++ b/api/api.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + mws.backend.dotnet.api + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/mws.api/appsettings.Development.json b/api/appsettings.Development.json similarity index 79% rename from mws.api/appsettings.Development.json rename to api/appsettings.Development.json index ba58a21..16ff39e 100644 --- a/mws.api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -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": "*" } \ No newline at end of file diff --git a/mws.api/appsettings.json b/api/appsettings.json similarity index 100% rename from mws.api/appsettings.json rename to api/appsettings.json diff --git a/mws.application/Auth/Commands/Login.cs b/application/Auth/Commands/Login.cs similarity index 87% rename from mws.application/Auth/Commands/Login.cs rename to application/Auth/Commands/Login.cs index 8de57ae..ad005c1 100644 --- a/mws.application/Auth/Commands/Login.cs +++ b/application/Auth/Commands/Login.cs @@ -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; diff --git a/mws.application/Auth/Contracts.cs b/application/Auth/Contracts.cs similarity index 61% rename from mws.application/Auth/Contracts.cs rename to application/Auth/Contracts.cs index 7df4124..5b41466 100644 --- a/mws.application/Auth/Contracts.cs +++ b/application/Auth/Contracts.cs @@ -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; diff --git a/application/Auth/Queries/GetProfile.cs b/application/Auth/Queries/GetProfile.cs new file mode 100644 index 0000000..75afa04 --- /dev/null +++ b/application/Auth/Queries/GetProfile.cs @@ -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; + +public class GetProfileHandler(IUnitOfWork uow, IMapper mapper) + : IRequestHandler +{ + public async Task 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(user); + } +} diff --git a/mws.application/Common/Exceptions.cs b/application/Common/Exceptions.cs similarity index 85% rename from mws.application/Common/Exceptions.cs rename to application/Common/Exceptions.cs index 48ebb76..e7de005 100644 --- a/mws.application/Common/Exceptions.cs +++ b/application/Common/Exceptions.cs @@ -1,4 +1,4 @@ -namespace Mws.Application.Common; +namespace mws.backend.dotnet.application.Common; public class NotFoundException(string message) : Exception(message); diff --git a/mws.application/Common/IUnitOfWork.cs b/application/Common/IUnitOfWork.cs similarity index 65% rename from mws.application/Common/IUnitOfWork.cs rename to application/Common/IUnitOfWork.cs index 9056372..9cf3b78 100644 --- a/mws.application/Common/IUnitOfWork.cs +++ b/application/Common/IUnitOfWork.cs @@ -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 SaveChangesAsync(CancellationToken ct = default); } diff --git a/application/Common/MappingProfile.cs b/application/Common/MappingProfile.cs new file mode 100644 index 0000000..57d1ddd --- /dev/null +++ b/application/Common/MappingProfile.cs @@ -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() + .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(); + CreateMap(); + CreateMap(); + CreateMap() + .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(); + CreateMap() + .ForMember(d => d.Children, opt => opt.Ignore()); + CreateMap() + .ForMember(d => d.AssigneeName, opt => opt.MapFrom(s => s.Assignee == null ? null : s.Assignee.DisplayName)); + CreateMap() + .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString())); + CreateMap(); + } +} diff --git a/application/Common/PagedResult.cs b/application/Common/PagedResult.cs new file mode 100644 index 0000000..07b2dc2 --- /dev/null +++ b/application/Common/PagedResult.cs @@ -0,0 +1,9 @@ +namespace mws.backend.dotnet.application.Common; + +public class PagedResult +{ + public List Items { get; init; } = []; + public int TotalCount { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } +} diff --git a/mws.application/Common/Repositories/IDocumentRepository.cs b/application/Common/Repositories/IDocumentRepository.cs similarity index 89% rename from mws.application/Common/Repositories/IDocumentRepository.cs rename to application/Common/Repositories/IDocumentRepository.cs index 5f30e23..af0943e 100644 --- a/mws.application/Common/Repositories/IDocumentRepository.cs +++ b/application/Common/Repositories/IDocumentRepository.cs @@ -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 { diff --git a/application/Common/Repositories/IMasterDataRepository.cs b/application/Common/Repositories/IMasterDataRepository.cs new file mode 100644 index 0000000..671548b --- /dev/null +++ b/application/Common/Repositories/IMasterDataRepository.cs @@ -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 +{ + Task> GetAllAsync(string? group, int page, int pageSize, CancellationToken ct = default); + Task> GetActiveByGroupAsync(string group, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task ExistsAsync(string group, string value, Guid? excludeId, CancellationToken ct = default); +} diff --git a/mws.application/Common/Repositories/IProjectRepository.cs b/application/Common/Repositories/IProjectRepository.cs similarity index 80% rename from mws.application/Common/Repositories/IProjectRepository.cs rename to application/Common/Repositories/IProjectRepository.cs index ae5310a..ad986a6 100644 --- a/mws.application/Common/Repositories/IProjectRepository.cs +++ b/application/Common/Repositories/IProjectRepository.cs @@ -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 { Task GetByIdAsync(Guid id, CancellationToken ct = default); Task GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default); - Task> GetForUserAsync(Guid userId, CancellationToken ct = default); + Task> GetForUserAsync(Guid userId, int page, int pageSize, CancellationToken ct = default); Task> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default); Task CountMembersAsync(Guid projectId, CancellationToken ct = default); @@ -14,7 +15,7 @@ public interface IProjectRepository : IRepository Task GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default); Task GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default); Task GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default); - Task> GetMembersWithUserAsync(Guid projectId, CancellationToken ct = default); + Task> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default); Task CountOwnersAsync(Guid projectId, CancellationToken ct = default); Task> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default); Task> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default); diff --git a/mws.application/Common/Repositories/IRepository.cs b/application/Common/Repositories/IRepository.cs similarity index 63% rename from mws.application/Common/Repositories/IRepository.cs rename to application/Common/Repositories/IRepository.cs index 06855fb..8849f68 100644 --- a/mws.application/Common/Repositories/IRepository.cs +++ b/application/Common/Repositories/IRepository.cs @@ -1,4 +1,4 @@ -namespace Mws.Application.Common.Repositories; +namespace mws.backend.dotnet.application.Common.Repositories; public interface IRepository where T : class { diff --git a/mws.application/Common/Repositories/IRoleRepository.cs b/application/Common/Repositories/IRoleRepository.cs similarity index 61% rename from mws.application/Common/Repositories/IRoleRepository.cs rename to application/Common/Repositories/IRoleRepository.cs index 58939c0..d663c21 100644 --- a/mws.application/Common/Repositories/IRoleRepository.cs +++ b/application/Common/Repositories/IRoleRepository.cs @@ -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 { - Task> GetAllWithPermissionsAsync(CancellationToken ct = default); + Task> GetAllWithPermissionsAsync(int page, int pageSize, CancellationToken ct = default); Task GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); Task ExistsByNameAsync(string name, Guid? excludeId, CancellationToken ct = default); Task GetPermissionAsync(Guid roleId, string screen, CancellationToken ct = default); Task> GetPermissionsAsync(Guid roleId, CancellationToken ct = default); + Task> GetPermissionsForRolesAsync(IEnumerable roleIds, CancellationToken ct = default); void RemovePermissions(IEnumerable permissions); } diff --git a/mws.application/Common/Repositories/ITaskRepository.cs b/application/Common/Repositories/ITaskRepository.cs similarity index 61% rename from mws.application/Common/Repositories/ITaskRepository.cs rename to application/Common/Repositories/ITaskRepository.cs index 68878d2..ac62d0b 100644 --- a/mws.application/Common/Repositories/ITaskRepository.cs +++ b/application/Common/Repositories/ITaskRepository.cs @@ -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 { Task GetByIdAsync(Guid id, CancellationToken ct = default); Task GetWithAssigneeAsync(Guid id, CancellationToken ct = default); - Task> GetForProjectAsync( - Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default); + Task> GetForProjectAsync( + Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, int page, int pageSize, CancellationToken ct = default); Task> SearchWithAssigneeAsync(List projectIds, string? term, int take, CancellationToken ct = default); Task> CountByStatusForProjectAsync(Guid projectId, CancellationToken ct = default); Task> GetRecentForProjectAsync(Guid projectId, int take, CancellationToken ct = default); diff --git a/mws.application/Common/Repositories/IUserRepository.cs b/application/Common/Repositories/IUserRepository.cs similarity index 61% rename from mws.application/Common/Repositories/IUserRepository.cs rename to application/Common/Repositories/IUserRepository.cs index 2f6ce18..2f1e0fc 100644 --- a/mws.application/Common/Repositories/IUserRepository.cs +++ b/application/Common/Repositories/IUserRepository.cs @@ -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 { @@ -9,6 +10,7 @@ public interface IUserRepository : IRepository Task GetByUsernameWithRoleAsync(string username, CancellationToken ct = default); Task ExistsByUsernameAsync(string username, CancellationToken ct = default); Task ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default); - Task GetRoleIdAsync(Guid userId, CancellationToken ct = default); + Task> GetRoleIdsAsync(Guid userId, CancellationToken ct = default); Task> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default); + Task> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default); } diff --git a/mws.application/Documents/Commands/CreateDocument.cs b/application/Documents/Commands/CreateDocument.cs similarity index 90% rename from mws.application/Documents/Commands/CreateDocument.cs rename to application/Documents/Commands/CreateDocument.cs index 01e8571..b01a33a 100644 --- a/mws.application/Documents/Commands/CreateDocument.cs +++ b/application/Documents/Commands/CreateDocument.cs @@ -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; diff --git a/mws.application/Documents/Commands/DeleteDocument.cs b/application/Documents/Commands/DeleteDocument.cs similarity index 82% rename from mws.application/Documents/Commands/DeleteDocument.cs rename to application/Documents/Commands/DeleteDocument.cs index f0caf08..6fe9c98 100644 --- a/mws.application/Documents/Commands/DeleteDocument.cs +++ b/application/Documents/Commands/DeleteDocument.cs @@ -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; diff --git a/mws.application/Documents/Commands/MoveDocument.cs b/application/Documents/Commands/MoveDocument.cs similarity index 90% rename from mws.application/Documents/Commands/MoveDocument.cs rename to application/Documents/Commands/MoveDocument.cs index 79ef864..15e7796 100644 --- a/mws.application/Documents/Commands/MoveDocument.cs +++ b/application/Documents/Commands/MoveDocument.cs @@ -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; diff --git a/mws.application/Documents/Commands/UpdateDocument.cs b/application/Documents/Commands/UpdateDocument.cs similarity index 86% rename from mws.application/Documents/Commands/UpdateDocument.cs rename to application/Documents/Commands/UpdateDocument.cs index 57eed4c..81ee8c7 100644 --- a/mws.application/Documents/Commands/UpdateDocument.cs +++ b/application/Documents/Commands/UpdateDocument.cs @@ -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; diff --git a/mws.application/Documents/Contracts.cs b/application/Documents/Contracts.cs similarity index 93% rename from mws.application/Documents/Contracts.cs rename to application/Documents/Contracts.cs index c8cf0e4..0fd98c9 100644 --- a/mws.application/Documents/Contracts.cs +++ b/application/Documents/Contracts.cs @@ -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 { diff --git a/mws.application/Documents/DocumentAccess.cs b/application/Documents/DocumentAccess.cs similarity index 91% rename from mws.application/Documents/DocumentAccess.cs rename to application/Documents/DocumentAccess.cs index cf14bdb..4891182 100644 --- a/mws.application/Documents/DocumentAccess.cs +++ b/application/Documents/DocumentAccess.cs @@ -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 { diff --git a/mws.application/Documents/Queries/GetDocument.cs b/application/Documents/Queries/GetDocument.cs similarity index 83% rename from mws.application/Documents/Queries/GetDocument.cs rename to application/Documents/Queries/GetDocument.cs index 77c9d0c..0534e50 100644 --- a/mws.application/Documents/Queries/GetDocument.cs +++ b/application/Documents/Queries/GetDocument.cs @@ -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; diff --git a/mws.application/Documents/Queries/GetDocumentTree.cs b/application/Documents/Queries/GetDocumentTree.cs similarity index 87% rename from mws.application/Documents/Queries/GetDocumentTree.cs rename to application/Documents/Queries/GetDocumentTree.cs index 86e1aed..ce93b33 100644 --- a/mws.application/Documents/Queries/GetDocumentTree.cs +++ b/application/Documents/Queries/GetDocumentTree.cs @@ -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>; diff --git a/mws.application/Documents/Queries/SearchDocuments.cs b/application/Documents/Queries/SearchDocuments.cs similarity index 86% rename from mws.application/Documents/Queries/SearchDocuments.cs rename to application/Documents/Queries/SearchDocuments.cs index dcfcb8b..3ae5f5a 100644 --- a/mws.application/Documents/Queries/SearchDocuments.cs +++ b/application/Documents/Queries/SearchDocuments.cs @@ -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>; diff --git a/application/MasterData/Commands/CreateMasterData.cs b/application/MasterData/Commands/CreateMasterData.cs new file mode 100644 index 0000000..0cfaeb7 --- /dev/null +++ b/application/MasterData/Commands/CreateMasterData.cs @@ -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; + +public class CreateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task 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(entry); + } +} diff --git a/application/MasterData/Commands/DeleteMasterData.cs b/application/MasterData/Commands/DeleteMasterData.cs new file mode 100644 index 0000000..48c0d42 --- /dev/null +++ b/application/MasterData/Commands/DeleteMasterData.cs @@ -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 +{ + 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); + } +} diff --git a/application/MasterData/Commands/UpdateMasterData.cs b/application/MasterData/Commands/UpdateMasterData.cs new file mode 100644 index 0000000..e9cedf9 --- /dev/null +++ b/application/MasterData/Commands/UpdateMasterData.cs @@ -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; + +public class UpdateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task 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(existing); + } +} diff --git a/application/MasterData/Contracts.cs b/application/MasterData/Contracts.cs new file mode 100644 index 0000000..65fe527 --- /dev/null +++ b/application/MasterData/Contracts.cs @@ -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; +} diff --git a/application/MasterData/MasterDataValidation.cs b/application/MasterData/MasterDataValidation.cs new file mode 100644 index 0000000..83286f5 --- /dev/null +++ b/application/MasterData/MasterDataValidation.cs @@ -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 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, + }; + } +} diff --git a/application/MasterData/Queries/GetMasterDataByGroup.cs b/application/MasterData/Queries/GetMasterDataByGroup.cs new file mode 100644 index 0000000..c96bce0 --- /dev/null +++ b/application/MasterData/Queries/GetMasterDataByGroup.cs @@ -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>; + +public class GetMasterDataByGroupHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetMasterDataByGroupQuery query, CancellationToken ct) + { + var entries = await uow.MasterData.GetActiveByGroupAsync(query.Group, ct); + return mapper.Map>(entries); + } +} diff --git a/application/MasterData/Queries/GetMasterDataList.cs b/application/MasterData/Queries/GetMasterDataList.cs new file mode 100644 index 0000000..296db8c --- /dev/null +++ b/application/MasterData/Queries/GetMasterDataList.cs @@ -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>; + +public class GetMasterDataListHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetMasterDataListQuery query, CancellationToken ct) + { + var entries = await uow.MasterData.GetAllAsync(query.Group, query.Page, query.PageSize, ct); + return new PagedResult + { + Items = mapper.Map>(entries.Items), + TotalCount = entries.TotalCount, + Page = entries.Page, + PageSize = entries.PageSize, + }; + } +} diff --git a/mws.application/Roles/Commands/CreateRole.cs b/application/Permissions/Commands/CreateRole.cs similarity index 88% rename from mws.application/Roles/Commands/CreateRole.cs rename to application/Permissions/Commands/CreateRole.cs index 4afaaa7..fc0af5b 100644 --- a/mws.application/Roles/Commands/CreateRole.cs +++ b/application/Permissions/Commands/CreateRole.cs @@ -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; diff --git a/mws.application/Roles/Commands/DeleteRole.cs b/application/Permissions/Commands/DeleteRole.cs similarity index 88% rename from mws.application/Roles/Commands/DeleteRole.cs rename to application/Permissions/Commands/DeleteRole.cs index e0990d2..92179b4 100644 --- a/mws.application/Roles/Commands/DeleteRole.cs +++ b/application/Permissions/Commands/DeleteRole.cs @@ -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; diff --git a/mws.application/Roles/Commands/UpdateRole.cs b/application/Permissions/Commands/UpdateRole.cs similarity index 92% rename from mws.application/Roles/Commands/UpdateRole.cs rename to application/Permissions/Commands/UpdateRole.cs index 9c888f9..c5b6d29 100644 --- a/mws.application/Roles/Commands/UpdateRole.cs +++ b/application/Permissions/Commands/UpdateRole.cs @@ -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; diff --git a/mws.application/Roles/Contracts.cs b/application/Permissions/Contracts.cs similarity index 56% rename from mws.application/Roles/Contracts.cs rename to application/Permissions/Contracts.cs index 61982c8..bd46bf5 100644 --- a/mws.application/Roles/Contracts.cs +++ b/application/Permissions/Contracts.cs @@ -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 { diff --git a/mws.application/Permissions/IPermissionService.cs b/application/Permissions/IPermissionService.cs similarity index 81% rename from mws.application/Permissions/IPermissionService.cs rename to application/Permissions/IPermissionService.cs index d8e9e18..77e97a7 100644 --- a/mws.application/Permissions/IPermissionService.cs +++ b/application/Permissions/IPermissionService.cs @@ -1,4 +1,4 @@ -namespace Mws.Application.Permissions; +namespace mws.backend.dotnet.application.Permissions; public interface IPermissionService { diff --git a/application/Permissions/PermissionService.cs b/application/Permissions/PermissionService.cs new file mode 100644 index 0000000..e44903e --- /dev/null +++ b/application/Permissions/PermissionService.cs @@ -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> 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}"); + } + } +} diff --git a/mws.application/Roles/Queries/GetRole.cs b/application/Permissions/Queries/GetRole.cs similarity index 82% rename from mws.application/Roles/Queries/GetRole.cs rename to application/Permissions/Queries/GetRole.cs index 0f10169..2b9a6f8 100644 --- a/mws.application/Roles/Queries/GetRole.cs +++ b/application/Permissions/Queries/GetRole.cs @@ -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; diff --git a/application/Permissions/Queries/GetRoles.cs b/application/Permissions/Queries/GetRoles.cs new file mode 100644 index 0000000..3a1d456 --- /dev/null +++ b/application/Permissions/Queries/GetRoles.cs @@ -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>; + +public class GetRolesHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetRolesQuery query, CancellationToken ct) + { + var roles = await uow.Roles.GetAllWithPermissionsAsync(query.Page, query.PageSize, ct); + return new PagedResult + { + Items = mapper.Map>(roles.Items), + TotalCount = roles.TotalCount, + Page = roles.Page, + PageSize = roles.PageSize, + }; + } +} diff --git a/mws.application/Roles/RolePermissionBuilder.cs b/application/Permissions/RolePermissionBuilder.cs similarity index 82% rename from mws.application/Roles/RolePermissionBuilder.cs rename to application/Permissions/RolePermissionBuilder.cs index ab9d3d3..0c1e98d 100644 --- a/mws.application/Roles/RolePermissionBuilder.cs +++ b/application/Permissions/RolePermissionBuilder.cs @@ -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 { diff --git a/mws.application/Permissions/ScreenCatalog.cs b/application/Permissions/ScreenCatalog.cs similarity index 67% rename from mws.application/Permissions/ScreenCatalog.cs rename to application/Permissions/ScreenCatalog.cs index 13c2d64..9aae8e2 100644 --- a/mws.application/Permissions/ScreenCatalog.cs +++ b/application/Permissions/ScreenCatalog.cs @@ -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 Keys = Screens.Select(s => s.Key).ToHashSet(); diff --git a/mws.application/Projects/Commands/AddProjectMember.cs b/application/Projects/Commands/AddProjectMember.cs similarity index 93% rename from mws.application/Projects/Commands/AddProjectMember.cs rename to application/Projects/Commands/AddProjectMember.cs index 7694242..163a974 100644 --- a/mws.application/Projects/Commands/AddProjectMember.cs +++ b/application/Projects/Commands/AddProjectMember.cs @@ -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; diff --git a/mws.application/Projects/Commands/ArchiveProject.cs b/application/Projects/Commands/ArchiveProject.cs similarity index 84% rename from mws.application/Projects/Commands/ArchiveProject.cs rename to application/Projects/Commands/ArchiveProject.cs index ce96c73..671fbba 100644 --- a/mws.application/Projects/Commands/ArchiveProject.cs +++ b/application/Projects/Commands/ArchiveProject.cs @@ -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; diff --git a/mws.application/Projects/Commands/CreateProject.cs b/application/Projects/Commands/CreateProject.cs similarity index 84% rename from mws.application/Projects/Commands/CreateProject.cs rename to application/Projects/Commands/CreateProject.cs index c4df59a..e03fac8 100644 --- a/mws.application/Projects/Commands/CreateProject.cs +++ b/application/Projects/Commands/CreateProject.cs @@ -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; @@ -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, }; diff --git a/mws.application/Projects/Commands/RemoveProjectMember.cs b/application/Projects/Commands/RemoveProjectMember.cs similarity index 88% rename from mws.application/Projects/Commands/RemoveProjectMember.cs rename to application/Projects/Commands/RemoveProjectMember.cs index fafd754..2bea193 100644 --- a/mws.application/Projects/Commands/RemoveProjectMember.cs +++ b/application/Projects/Commands/RemoveProjectMember.cs @@ -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; diff --git a/mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs b/application/Projects/Commands/UpdateMemberDocumentPermissions.cs similarity index 94% rename from mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs rename to application/Projects/Commands/UpdateMemberDocumentPermissions.cs index 08d1fb0..567af77 100644 --- a/mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs +++ b/application/Projects/Commands/UpdateMemberDocumentPermissions.cs @@ -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; diff --git a/mws.application/Projects/Commands/UpdateProject.cs b/application/Projects/Commands/UpdateProject.cs similarity index 86% rename from mws.application/Projects/Commands/UpdateProject.cs rename to application/Projects/Commands/UpdateProject.cs index ca59940..3756edd 100644 --- a/mws.application/Projects/Commands/UpdateProject.cs +++ b/application/Projects/Commands/UpdateProject.cs @@ -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; @@ -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); diff --git a/mws.application/Projects/Contracts.cs b/application/Projects/Contracts.cs similarity index 88% rename from mws.application/Projects/Contracts.cs rename to application/Projects/Contracts.cs index c47c770..4343155 100644 --- a/mws.application/Projects/Contracts.cs +++ b/application/Projects/Contracts.cs @@ -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; } } diff --git a/mws.application/Projects/ProjectAccess.cs b/application/Projects/ProjectAccess.cs similarity index 69% rename from mws.application/Projects/ProjectAccess.cs rename to application/Projects/ProjectAccess.cs index 23d80ac..ff7a6e8 100644 --- a/mws.application/Projects/ProjectAccess.cs +++ b/application/Projects/ProjectAccess.cs @@ -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 { diff --git a/mws.application/Projects/Queries/GetProject.cs b/application/Projects/Queries/GetProject.cs similarity index 83% rename from mws.application/Projects/Queries/GetProject.cs rename to application/Projects/Queries/GetProject.cs index abb7d11..2bccc51 100644 --- a/mws.application/Projects/Queries/GetProject.cs +++ b/application/Projects/Queries/GetProject.cs @@ -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; diff --git a/mws.application/Projects/Queries/GetProjectMembers.cs b/application/Projects/Queries/GetProjectMembers.cs similarity index 63% rename from mws.application/Projects/Queries/GetProjectMembers.cs rename to application/Projects/Queries/GetProjectMembers.cs index 5612da4..d4883ec 100644 --- a/mws.application/Projects/Queries/GetProjectMembers.cs +++ b/application/Projects/Queries/GetProjectMembers.cs @@ -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>; +public record GetProjectMembersQuery(Guid UserId, Guid ProjectId, int Page, int PageSize) : IRequest>; -public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler> +public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler> { - public async Task> Handle(GetProjectMembersQuery query, CancellationToken ct) + public async Task> 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 + 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 + { + Items = items, + TotalCount = members.TotalCount, + Page = members.Page, + PageSize = members.PageSize, + }; } } diff --git a/mws.application/Projects/Queries/GetProjectOverview.cs b/application/Projects/Queries/GetProjectOverview.cs similarity index 91% rename from mws.application/Projects/Queries/GetProjectOverview.cs rename to application/Projects/Queries/GetProjectOverview.cs index c9b7ed5..7473aed 100644 --- a/mws.application/Projects/Queries/GetProjectOverview.cs +++ b/application/Projects/Queries/GetProjectOverview.cs @@ -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; diff --git a/application/Projects/Queries/GetProjects.cs b/application/Projects/Queries/GetProjects.cs new file mode 100644 index 0000000..1a572b2 --- /dev/null +++ b/application/Projects/Queries/GetProjects.cs @@ -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>; + +public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetProjectsQuery query, CancellationToken ct) + { + var projects = await uow.Projects.GetForUserAsync(query.UserId, query.Page, query.PageSize, ct); + return new PagedResult + { + Items = mapper.Map>(projects.Items), + TotalCount = projects.TotalCount, + Page = projects.Page, + PageSize = projects.PageSize, + }; + } +} diff --git a/mws.application/Projects/Queries/SearchProjects.cs b/application/Projects/Queries/SearchProjects.cs similarity index 84% rename from mws.application/Projects/Queries/SearchProjects.cs rename to application/Projects/Queries/SearchProjects.cs index d1c0ec5..c0acf25 100644 --- a/mws.application/Projects/Queries/SearchProjects.cs +++ b/application/Projects/Queries/SearchProjects.cs @@ -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>; diff --git a/mws.application/Tasks/Commands/CreateTask.cs b/application/Tasks/Commands/CreateTask.cs similarity index 85% rename from mws.application/Tasks/Commands/CreateTask.cs rename to application/Tasks/Commands/CreateTask.cs index 006e15d..31c4e19 100644 --- a/mws.application/Tasks/Commands/CreateTask.cs +++ b/application/Tasks/Commands/CreateTask.cs @@ -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; diff --git a/mws.application/Tasks/Commands/DeleteTask.cs b/application/Tasks/Commands/DeleteTask.cs similarity index 82% rename from mws.application/Tasks/Commands/DeleteTask.cs rename to application/Tasks/Commands/DeleteTask.cs index e98379c..ae3d938 100644 --- a/mws.application/Tasks/Commands/DeleteTask.cs +++ b/application/Tasks/Commands/DeleteTask.cs @@ -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; diff --git a/mws.application/Tasks/Commands/UpdateTask.cs b/application/Tasks/Commands/UpdateTask.cs similarity index 93% rename from mws.application/Tasks/Commands/UpdateTask.cs rename to application/Tasks/Commands/UpdateTask.cs index b88f806..fa1f127 100644 --- a/mws.application/Tasks/Commands/UpdateTask.cs +++ b/application/Tasks/Commands/UpdateTask.cs @@ -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; diff --git a/mws.application/Tasks/Contracts.cs b/application/Tasks/Contracts.cs similarity index 84% rename from mws.application/Tasks/Contracts.cs rename to application/Tasks/Contracts.cs index 59a81ea..63bcf3e 100644 --- a/mws.application/Tasks/Contracts.cs +++ b/application/Tasks/Contracts.cs @@ -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 { diff --git a/mws.application/Tasks/Queries/GetTask.cs b/application/Tasks/Queries/GetTask.cs similarity index 83% rename from mws.application/Tasks/Queries/GetTask.cs rename to application/Tasks/Queries/GetTask.cs index f6e909e..b97124f 100644 --- a/mws.application/Tasks/Queries/GetTask.cs +++ b/application/Tasks/Queries/GetTask.cs @@ -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; diff --git a/application/Tasks/Queries/GetTasks.cs b/application/Tasks/Queries/GetTasks.cs new file mode 100644 index 0000000..8c9a9f3 --- /dev/null +++ b/application/Tasks/Queries/GetTasks.cs @@ -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>; + +public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> 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 + { + Items = mapper.Map>(tasks.Items), + TotalCount = tasks.TotalCount, + Page = tasks.Page, + PageSize = tasks.PageSize, + }; + } +} diff --git a/mws.application/Tasks/Queries/SearchTasks.cs b/application/Tasks/Queries/SearchTasks.cs similarity index 86% rename from mws.application/Tasks/Queries/SearchTasks.cs rename to application/Tasks/Queries/SearchTasks.cs index 5a887bc..4d6a62e 100644 --- a/mws.application/Tasks/Queries/SearchTasks.cs +++ b/application/Tasks/Queries/SearchTasks.cs @@ -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>; diff --git a/mws.application/Tasks/TaskAccess.cs b/application/Tasks/TaskAccess.cs similarity index 89% rename from mws.application/Tasks/TaskAccess.cs rename to application/Tasks/TaskAccess.cs index 88d0e53..d4f666b 100644 --- a/mws.application/Tasks/TaskAccess.cs +++ b/application/Tasks/TaskAccess.cs @@ -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 { diff --git a/application/Users/Commands/CreateUser.cs b/application/Users/Commands/CreateUser.cs new file mode 100644 index 0000000..c34bfde --- /dev/null +++ b/application/Users/Commands/CreateUser.cs @@ -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; + +public class CreateUserHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) + : IRequestHandler +{ + private const string Screen = "users"; + + public async Task 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 { new UserRole { RoleId = role.Id } } : new List() + }; + + uow.Users.Add(user); + await uow.SaveChangesAsync(ct); + return mapper.Map(user); + } +} diff --git a/application/Users/Commands/DeleteUser.cs b/application/Users/Commands/DeleteUser.cs new file mode 100644 index 0000000..52f8f49 --- /dev/null +++ b/application/Users/Commands/DeleteUser.cs @@ -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 +{ + 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); + } +} diff --git a/mws.application/Accounts/Commands/ResetPassword.cs b/application/Users/Commands/ResetPassword.cs similarity index 74% rename from mws.application/Accounts/Commands/ResetPassword.cs rename to application/Users/Commands/ResetPassword.cs index 6251e35..3aaced1 100644 --- a/mws.application/Accounts/Commands/ResetPassword.cs +++ b/application/Users/Commands/ResetPassword.cs @@ -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 { - 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; diff --git a/application/Users/Commands/UpdateUser.cs b/application/Users/Commands/UpdateUser.cs new file mode 100644 index 0000000..7dceb42 --- /dev/null +++ b/application/Users/Commands/UpdateUser.cs @@ -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; + +public class UpdateUserHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) + : IRequestHandler +{ + private const string Screen = "users"; + + public async Task 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(user); + } +} diff --git a/mws.application/Accounts/Contracts.cs b/application/Users/Contracts.cs similarity index 55% rename from mws.application/Accounts/Contracts.cs rename to application/Users/Contracts.cs index b269886..6ad29f6 100644 --- a/mws.application/Accounts/Contracts.cs +++ b/application/Users/Contracts.cs @@ -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 AssignedRoles { get; set; } = []; + public List UnassignedRoles { get; set; } = []; +} + +public class AssignRoleRequest +{ + public Guid RoleId { get; set; } +} \ No newline at end of file diff --git a/application/Users/Queries/GetUsers.cs b/application/Users/Queries/GetUsers.cs new file mode 100644 index 0000000..1c118f5 --- /dev/null +++ b/application/Users/Queries/GetUsers.cs @@ -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>; + +public class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) + : IRequestHandler> +{ + private const string Screen = "users"; + + public async Task> 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 + { + Items = mapper.Map>(users.Items), + TotalCount = users.TotalCount, + Page = users.Page, + PageSize = users.PageSize, + }; + } +} diff --git a/mws.application/mws.application.csproj b/application/application.csproj similarity index 76% rename from mws.application/mws.application.csproj rename to application/application.csproj index 5f414fa..b8cb41e 100644 --- a/mws.application/mws.application.csproj +++ b/application/application.csproj @@ -4,10 +4,11 @@ net10.0 enable enable + mws.backend.dotnet.application - + diff --git a/mws.domain/Documents/Document.cs b/domain/Documents/Document.cs similarity index 93% rename from mws.domain/Documents/Document.cs rename to domain/Documents/Document.cs index e79363d..f8e2b81 100644 --- a/mws.domain/Documents/Document.cs +++ b/domain/Documents/Document.cs @@ -1,4 +1,4 @@ -namespace Mws.Domain.Documents; +namespace mws.backend.dotnet.domain.Documents; public enum DocumentType { diff --git a/domain/MasterData/MasterDataEntry.cs b/domain/MasterData/MasterDataEntry.cs new file mode 100644 index 0000000..c141684 --- /dev/null +++ b/domain/MasterData/MasterDataEntry.cs @@ -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; } +} diff --git a/mws.domain/Projects/Project.cs b/domain/Projects/Project.cs similarity index 60% rename from mws.domain/Projects/Project.cs rename to domain/Projects/Project.cs index 8c9881d..c9ee440 100644 --- a/mws.domain/Projects/Project.cs +++ b/domain/Projects/Project.cs @@ -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 Members { get; set; } = []; + + public mws.backend.dotnet.domain.Users.User? CreatedByUser { get; set; } + public mws.backend.dotnet.domain.Users.User? UpdatedByUser { get; set; } } \ No newline at end of file diff --git a/mws.domain/Projects/ProjectMember.cs b/domain/Projects/ProjectMember.cs similarity index 69% rename from mws.domain/Projects/ProjectMember.cs rename to domain/Projects/ProjectMember.cs index d46d8f7..f75ea70 100644 --- a/mws.domain/Projects/ProjectMember.cs +++ b/domain/Projects/ProjectMember.cs @@ -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!; } \ No newline at end of file diff --git a/mws.domain/Projects/ProjectMemberPermission.cs b/domain/Projects/ProjectMemberPermission.cs similarity index 90% rename from mws.domain/Projects/ProjectMemberPermission.cs rename to domain/Projects/ProjectMemberPermission.cs index c003099..f1bb83d 100644 --- a/mws.domain/Projects/ProjectMemberPermission.cs +++ b/domain/Projects/ProjectMemberPermission.cs @@ -1,4 +1,4 @@ -namespace Mws.Domain.Projects; +namespace mws.backend.dotnet.domain.Projects; public static class ProjectPermissionScreens { diff --git a/mws.domain/Roles/Role.cs b/domain/Roles/Role.cs similarity index 69% rename from mws.domain/Roles/Role.cs rename to domain/Roles/Role.cs index d0320fc..23ce77a 100644 --- a/mws.domain/Roles/Role.cs +++ b/domain/Roles/Role.cs @@ -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 Permissions { get; set; } = []; + public List UserRoles { get; set; } = []; } diff --git a/mws.domain/Roles/RolePermission.cs b/domain/Roles/RolePermission.cs similarity index 88% rename from mws.domain/Roles/RolePermission.cs rename to domain/Roles/RolePermission.cs index 2044258..8c24830 100644 --- a/mws.domain/Roles/RolePermission.cs +++ b/domain/Roles/RolePermission.cs @@ -1,4 +1,4 @@ -namespace Mws.Domain.Roles; +namespace mws.backend.dotnet.domain.Roles; public class RolePermission { diff --git a/mws.domain/Tasks/TaskItem.cs b/domain/Tasks/TaskItem.cs similarity index 86% rename from mws.domain/Tasks/TaskItem.cs rename to domain/Tasks/TaskItem.cs index 752f1de..bc5b7f5 100644 --- a/mws.domain/Tasks/TaskItem.cs +++ b/domain/Tasks/TaskItem.cs @@ -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; } } \ No newline at end of file diff --git a/mws.domain/Users/User.cs b/domain/Users/User.cs similarity index 72% rename from mws.domain/Users/User.cs rename to domain/Users/User.cs index ad1a9e4..a687214 100644 --- a/mws.domain/Users/User.cs +++ b/domain/Users/User.cs @@ -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 UserRoles { get; set; } = []; } diff --git a/domain/Users/UserRole.cs b/domain/Users/UserRole.cs new file mode 100644 index 0000000..1630b90 --- /dev/null +++ b/domain/Users/UserRole.cs @@ -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!; +} \ No newline at end of file diff --git a/mws.domain/mws.domain.csproj b/domain/domain.csproj similarity index 77% rename from mws.domain/mws.domain.csproj rename to domain/domain.csproj index 237d661..44577c8 100644 --- a/mws.domain/mws.domain.csproj +++ b/domain/domain.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + mws.backend.dotnet.domain diff --git a/mws.infrastructure/Authentication/BcryptPasswordHasher.cs b/infrastructure/Authentication/BcryptPasswordHasher.cs similarity index 72% rename from mws.infrastructure/Authentication/BcryptPasswordHasher.cs rename to infrastructure/Authentication/BcryptPasswordHasher.cs index 30bd9aa..e3134a7 100644 --- a/mws.infrastructure/Authentication/BcryptPasswordHasher.cs +++ b/infrastructure/Authentication/BcryptPasswordHasher.cs @@ -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 { diff --git a/mws.infrastructure/Authentication/JwtOptions.cs b/infrastructure/Authentication/JwtOptions.cs similarity index 81% rename from mws.infrastructure/Authentication/JwtOptions.cs rename to infrastructure/Authentication/JwtOptions.cs index 8373efa..e68cbb6 100644 --- a/mws.infrastructure/Authentication/JwtOptions.cs +++ b/infrastructure/Authentication/JwtOptions.cs @@ -1,4 +1,4 @@ -namespace Mws.Infrastructure.Authentication; +namespace mws.backend.dotnet.infrastructure.Authentication; public class JwtOptions { diff --git a/mws.infrastructure/Authentication/JwtTokenService.cs b/infrastructure/Authentication/JwtTokenService.cs similarity index 90% rename from mws.infrastructure/Authentication/JwtTokenService.cs rename to infrastructure/Authentication/JwtTokenService.cs index d75ad79..847810d 100644 --- a/mws.infrastructure/Authentication/JwtTokenService.cs +++ b/infrastructure/Authentication/JwtTokenService.cs @@ -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 { diff --git a/mws.infrastructure/DependencyInjection.cs b/infrastructure/DependencyInjection.cs similarity index 83% rename from mws.infrastructure/DependencyInjection.cs rename to infrastructure/DependencyInjection.cs index e8a919e..e4c82f0 100644 --- a/mws.infrastructure/DependencyInjection.cs +++ b/infrastructure/DependencyInjection.cs @@ -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 { diff --git a/mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs b/infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs similarity index 86% rename from mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs rename to infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs index 7609625..100c055 100644 --- a/mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs +++ b/infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mws.Infrastructure.Persistence; +using mws.backend.dotnet.infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260809054503_InitialCreate")] @@ -25,7 +25,7 @@ namespace Mws.Infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -71,7 +71,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("documents", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -102,7 +102,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("projects", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -122,7 +122,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_members", (string)null); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -175,7 +175,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("tasks", (string)null); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -210,9 +210,9 @@ namespace Mws.Infrastructure.Migrations b.ToTable("users", (string)null); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { - b.HasOne("Mws.Domain.Documents.Document", "Parent") + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") .WithMany("Children") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Restrict); @@ -220,15 +220,15 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Parent"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { - b.HasOne("Mws.Domain.Projects.Project", "Project") + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") .WithMany("Members") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Mws.Domain.Users.User", "User") + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -239,9 +239,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { - b.HasOne("Mws.Domain.Users.User", "Assignee") + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") .WithMany() .HasForeignKey("AssigneeId") .OnDelete(DeleteBehavior.SetNull); @@ -249,12 +249,12 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Assignee"); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Navigation("Children"); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Navigation("Members"); }); diff --git a/mws.infrastructure/Migrations/20260809054503_InitialCreate.cs b/infrastructure/Migrations/20260809054503_InitialCreate.cs similarity index 99% rename from mws.infrastructure/Migrations/20260809054503_InitialCreate.cs rename to infrastructure/Migrations/20260809054503_InitialCreate.cs index da5c5f7..f3416c9 100644 --- a/mws.infrastructure/Migrations/20260809054503_InitialCreate.cs +++ b/infrastructure/Migrations/20260809054503_InitialCreate.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { /// public partial class InitialCreate : Migration diff --git a/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs b/infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs similarity index 85% rename from mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs rename to infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs index b3123a3..1569700 100644 --- a/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs +++ b/infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mws.Infrastructure.Persistence; +using mws.backend.dotnet.infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260811133605_AddRolesAndPermissions")] @@ -25,7 +25,7 @@ namespace Mws.Infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -71,7 +71,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("documents", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -102,7 +102,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("projects", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -122,7 +122,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_members", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -150,7 +150,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("roles", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { b.Property("RoleId") .HasColumnType("uuid"); @@ -176,7 +176,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("role_permissions", (string)null); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -229,7 +229,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("tasks", (string)null); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -272,9 +272,9 @@ namespace Mws.Infrastructure.Migrations b.ToTable("users", (string)null); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { - b.HasOne("Mws.Domain.Documents.Document", "Parent") + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") .WithMany("Children") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Restrict); @@ -282,15 +282,15 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Parent"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { - b.HasOne("Mws.Domain.Projects.Project", "Project") + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") .WithMany("Members") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Mws.Domain.Users.User", "User") + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -301,9 +301,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany("Permissions") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -312,9 +312,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { - b.HasOne("Mws.Domain.Users.User", "Assignee") + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") .WithMany() .HasForeignKey("AssigneeId") .OnDelete(DeleteBehavior.SetNull); @@ -322,9 +322,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Assignee"); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Restrict) @@ -333,17 +333,17 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Navigation("Children"); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Navigation("Members"); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Navigation("Permissions"); }); diff --git a/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs b/infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs similarity index 98% rename from mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs rename to infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs index 02534cd..a18b315 100644 --- a/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs +++ b/infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { /// public partial class AddRolesAndPermissions : Migration diff --git a/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs b/infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs similarity index 85% rename from mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs rename to infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs index 432a2e5..5a0bfc1 100644 --- a/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs +++ b/infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mws.Infrastructure.Persistence; +using mws.backend.dotnet.infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260812124332_AddMemberDocumentPermissions")] @@ -25,7 +25,7 @@ namespace Mws.Infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -71,7 +71,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("documents", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -102,7 +102,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("projects", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -134,7 +134,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_members", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -162,7 +162,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("roles", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { b.Property("RoleId") .HasColumnType("uuid"); @@ -188,7 +188,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("role_permissions", (string)null); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -241,7 +241,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("tasks", (string)null); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -284,9 +284,9 @@ namespace Mws.Infrastructure.Migrations b.ToTable("users", (string)null); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { - b.HasOne("Mws.Domain.Documents.Document", "Parent") + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") .WithMany("Children") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Restrict); @@ -294,15 +294,15 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Parent"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { - b.HasOne("Mws.Domain.Projects.Project", "Project") + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") .WithMany("Members") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Mws.Domain.Users.User", "User") + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -313,9 +313,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany("Permissions") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -324,9 +324,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { - b.HasOne("Mws.Domain.Users.User", "Assignee") + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") .WithMany() .HasForeignKey("AssigneeId") .OnDelete(DeleteBehavior.SetNull); @@ -334,9 +334,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Assignee"); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Restrict) @@ -345,17 +345,17 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Navigation("Children"); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Navigation("Members"); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Navigation("Permissions"); }); diff --git a/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs b/infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs similarity index 97% rename from mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs rename to infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs index 40de99b..edabd2f 100644 --- a/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs +++ b/infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs @@ -2,7 +2,7 @@ #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { /// public partial class AddMemberDocumentPermissions : Migration diff --git a/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs b/infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs similarity index 84% rename from mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs rename to infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs index eca9b28..4e275d8 100644 --- a/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs +++ b/infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mws.Infrastructure.Persistence; +using mws.backend.dotnet.infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260812130925_SplitProjectMemberPermissions")] @@ -25,7 +25,7 @@ namespace Mws.Infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -71,7 +71,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("documents", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -102,7 +102,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("projects", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -122,7 +122,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_members", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMemberPermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -151,7 +151,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_member_permissions", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -179,7 +179,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("roles", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { b.Property("RoleId") .HasColumnType("uuid"); @@ -205,7 +205,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("role_permissions", (string)null); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -258,7 +258,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("tasks", (string)null); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -301,9 +301,9 @@ namespace Mws.Infrastructure.Migrations b.ToTable("users", (string)null); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { - b.HasOne("Mws.Domain.Documents.Document", "Parent") + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") .WithMany("Children") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Restrict); @@ -311,15 +311,15 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Parent"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { - b.HasOne("Mws.Domain.Projects.Project", "Project") + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") .WithMany("Members") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Mws.Domain.Users.User", "User") + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -330,18 +330,18 @@ namespace Mws.Infrastructure.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMemberPermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => { - b.HasOne("Mws.Domain.Projects.ProjectMember", null) + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) .WithMany() .HasForeignKey("ProjectId", "UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany("Permissions") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -350,9 +350,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { - b.HasOne("Mws.Domain.Users.User", "Assignee") + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") .WithMany() .HasForeignKey("AssigneeId") .OnDelete(DeleteBehavior.SetNull); @@ -360,9 +360,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Assignee"); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Restrict) @@ -371,17 +371,17 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Navigation("Children"); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Navigation("Members"); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Navigation("Permissions"); }); diff --git a/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs b/infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs similarity index 98% rename from mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs rename to infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs index ae6ee38..864de73 100644 --- a/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs +++ b/infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { /// public partial class SplitProjectMemberPermissions : Migration diff --git a/mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs b/infrastructure/Migrations/20260819131445_AddMasterData.Designer.cs similarity index 74% rename from mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs rename to infrastructure/Migrations/20260819131445_AddMasterData.Designer.cs index ec9e4cd..cb6a70c 100644 --- a/mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/infrastructure/Migrations/20260819131445_AddMasterData.Designer.cs @@ -2,27 +2,30 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mws.Infrastructure.Persistence; +using mws.backend.dotnet.infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Mws.Infrastructure.Migrations +namespace mws.backend.dotnet.infrastructure.Migrations { [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : ModelSnapshot + [Migration("20260819131445_AddMasterData")] + partial class AddMasterData { - protected override void BuildModel(ModelBuilder modelBuilder) + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -68,7 +71,48 @@ namespace Mws.Infrastructure.Migrations b.ToTable("documents", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Group", "Value") + .IsUnique(); + + b.ToTable("master_data", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -99,7 +143,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("projects", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -119,7 +163,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_members", (string)null); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMemberPermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => { b.Property("ProjectId") .HasColumnType("uuid"); @@ -148,7 +192,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("project_member_permissions", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -176,7 +220,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("roles", (string)null); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { b.Property("RoleId") .HasColumnType("uuid"); @@ -202,7 +246,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("role_permissions", (string)null); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -255,7 +299,7 @@ namespace Mws.Infrastructure.Migrations b.ToTable("tasks", (string)null); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -298,9 +342,9 @@ namespace Mws.Infrastructure.Migrations b.ToTable("users", (string)null); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { - b.HasOne("Mws.Domain.Documents.Document", "Parent") + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") .WithMany("Children") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Restrict); @@ -308,15 +352,15 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Parent"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => { - b.HasOne("Mws.Domain.Projects.Project", "Project") + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") .WithMany("Members") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Mws.Domain.Users.User", "User") + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -327,18 +371,18 @@ namespace Mws.Infrastructure.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Mws.Domain.Projects.ProjectMemberPermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => { - b.HasOne("Mws.Domain.Projects.ProjectMember", null) + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) .WithMany() .HasForeignKey("ProjectId", "UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany("Permissions") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -347,9 +391,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => { - b.HasOne("Mws.Domain.Users.User", "Assignee") + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") .WithMany() .HasForeignKey("AssigneeId") .OnDelete(DeleteBehavior.SetNull); @@ -357,9 +401,9 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Assignee"); }); - modelBuilder.Entity("Mws.Domain.Users.User", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => { - b.HasOne("Mws.Domain.Roles.Role", "Role") + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Restrict) @@ -368,17 +412,17 @@ namespace Mws.Infrastructure.Migrations b.Navigation("Role"); }); - modelBuilder.Entity("Mws.Domain.Documents.Document", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Navigation("Children"); }); - modelBuilder.Entity("Mws.Domain.Projects.Project", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => { b.Navigation("Members"); }); - modelBuilder.Entity("Mws.Domain.Roles.Role", b => + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => { b.Navigation("Permissions"); }); diff --git a/infrastructure/Migrations/20260819131445_AddMasterData.cs b/infrastructure/Migrations/20260819131445_AddMasterData.cs new file mode 100644 index 0000000..2d80655 --- /dev/null +++ b/infrastructure/Migrations/20260819131445_AddMasterData.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + /// + public partial class AddMasterData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "master_data", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Group = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Value = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_master_data", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_master_data_Group_Value", + table: "master_data", + columns: new[] { "Group", "Value" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "master_data"); + } + } +} diff --git a/infrastructure/Migrations/20260819140941_AddUserRolesTable.Designer.cs b/infrastructure/Migrations/20260819140941_AddUserRolesTable.Designer.cs new file mode 100644 index 0000000..b061935 --- /dev/null +++ b/infrastructure/Migrations/20260819140941_AddUserRolesTable.Designer.cs @@ -0,0 +1,457 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using mws.backend.dotnet.infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260819140941_AddUserRolesTable")] + partial class AddUserRolesTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Group", "Value") + .IsUnique(); + + b.ToTable("master_data", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("ProjectId", "UserId", "Screen"); + + b.ToTable("project_member_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("RoleId", "Screen"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Navigation("Permissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/infrastructure/Migrations/20260819140941_AddUserRolesTable.cs b/infrastructure/Migrations/20260819140941_AddUserRolesTable.cs new file mode 100644 index 0000000..8ced314 --- /dev/null +++ b/infrastructure/Migrations/20260819140941_AddUserRolesTable.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + /// + public partial class AddUserRolesTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_users_roles_RoleId", + table: "users"); + + migrationBuilder.DropIndex( + name: "IX_users_RoleId", + table: "users"); + + migrationBuilder.DropColumn( + name: "RoleId", + table: "users"); + + migrationBuilder.CreateTable( + name: "user_roles", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_roles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_user_roles_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_user_roles_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_RoleId", + table: "user_roles", + column: "RoleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "user_roles"); + + migrationBuilder.AddColumn( + name: "RoleId", + table: "users", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_users_RoleId", + table: "users", + column: "RoleId"); + + migrationBuilder.AddForeignKey( + name: "FK_users_roles_RoleId", + table: "users", + column: "RoleId", + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/infrastructure/Migrations/20260819161034_AddProjectAuditFields.Designer.cs b/infrastructure/Migrations/20260819161034_AddProjectAuditFields.Designer.cs new file mode 100644 index 0000000..1eccb14 --- /dev/null +++ b/infrastructure/Migrations/20260819161034_AddProjectAuditFields.Designer.cs @@ -0,0 +1,485 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using mws.backend.dotnet.infrastructure.Persistence; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260819161034_AddProjectAuditFields")] + partial class AddProjectAuditFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Group", "Value") + .IsUnique(); + + b.ToTable("master_data", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("ProjectId", "UserId", "Screen"); + + b.ToTable("project_member_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("RoleId", "Screen"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedByUser"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Navigation("Permissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/infrastructure/Migrations/20260819161034_AddProjectAuditFields.cs b/infrastructure/Migrations/20260819161034_AddProjectAuditFields.cs new file mode 100644 index 0000000..cd6a69a --- /dev/null +++ b/infrastructure/Migrations/20260819161034_AddProjectAuditFields.cs @@ -0,0 +1,99 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + /// + public partial class AddProjectAuditFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedBy", + table: "projects", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "UpdatedBy", + table: "projects", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_projects_CreatedBy", + table: "projects", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_projects_UpdatedBy", + table: "projects", + column: "UpdatedBy"); + + migrationBuilder.Sql( + """ + UPDATE projects + SET "CreatedBy" = COALESCE( + (SELECT pm."UserId" FROM project_members pm WHERE pm."ProjectId" = projects."Id" AND pm."Role" = 'Owner' LIMIT 1), + (SELECT pm."UserId" FROM project_members pm WHERE pm."ProjectId" = projects."Id" LIMIT 1), + (SELECT "Id" FROM users ORDER BY "CreatedAt" LIMIT 1) + ) + WHERE "CreatedBy" = '00000000-0000-0000-0000-000000000000'; + """); + + migrationBuilder.AlterColumn( + name: "CreatedBy", + table: "projects", + type: "uuid", + nullable: false); + + migrationBuilder.AddForeignKey( + name: "FK_projects_users_CreatedBy", + table: "projects", + column: "CreatedBy", + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_projects_users_UpdatedBy", + table: "projects", + column: "UpdatedBy", + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_projects_users_CreatedBy", + table: "projects"); + + migrationBuilder.DropForeignKey( + name: "FK_projects_users_UpdatedBy", + table: "projects"); + + migrationBuilder.DropIndex( + name: "IX_projects_CreatedBy", + table: "projects"); + + migrationBuilder.DropIndex( + name: "IX_projects_UpdatedBy", + table: "projects"); + + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "projects"); + + migrationBuilder.DropColumn( + name: "UpdatedBy", + table: "projects"); + } + } +} diff --git a/infrastructure/Migrations/AppDbContextModelSnapshot.cs b/infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d611d38 --- /dev/null +++ b/infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,482 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using mws.backend.dotnet.infrastructure.Persistence; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Group", "Value") + .IsUnique(); + + b.ToTable("master_data", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("ProjectId", "UserId", "Screen"); + + b.ToTable("project_member_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("RoleId", "Screen"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedByUser"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Navigation("Permissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/mws.infrastructure/Persistence/AppDbContext.cs b/infrastructure/Persistence/AppDbContext.cs similarity index 64% rename from mws.infrastructure/Persistence/AppDbContext.cs rename to infrastructure/Persistence/AppDbContext.cs index 212cbd4..41d39f6 100644 --- a/mws.infrastructure/Persistence/AppDbContext.cs +++ b/infrastructure/Persistence/AppDbContext.cs @@ -1,15 +1,17 @@ using Microsoft.EntityFrameworkCore; -using Mws.Domain.Documents; -using Mws.Domain.Projects; -using Mws.Domain.Roles; -using Mws.Domain.Tasks; -using Mws.Domain.Users; +using mws.backend.dotnet.domain.Documents; +using mws.backend.dotnet.domain.MasterData; +using mws.backend.dotnet.domain.Projects; +using mws.backend.dotnet.domain.Roles; +using mws.backend.dotnet.domain.Tasks; +using mws.backend.dotnet.domain.Users; -namespace Mws.Infrastructure.Persistence; +namespace mws.backend.dotnet.infrastructure.Persistence; public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet Users => Set(); + public DbSet UserRoles => Set(); public DbSet Projects => Set(); public DbSet ProjectMembers => Set(); public DbSet ProjectMemberPermissions => Set(); @@ -17,6 +19,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Tasks => Set(); public DbSet Roles => Set(); public DbSet RolePermissions => Set(); + public DbSet MasterData => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs b/infrastructure/Persistence/Configuration/DocumentConfiguration.cs similarity index 86% rename from mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs rename to infrastructure/Persistence/Configuration/DocumentConfiguration.cs index ee30608..97486d0 100644 --- a/mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs +++ b/infrastructure/Persistence/Configuration/DocumentConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Documents; +using mws.backend.dotnet.domain.Documents; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class DocumentConfiguration : IEntityTypeConfiguration { diff --git a/infrastructure/Persistence/Configuration/MasterDataEntryConfiguration.cs b/infrastructure/Persistence/Configuration/MasterDataEntryConfiguration.cs new file mode 100644 index 0000000..09f3fc9 --- /dev/null +++ b/infrastructure/Persistence/Configuration/MasterDataEntryConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using mws.backend.dotnet.domain.MasterData; + +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; + +public class MasterDataEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("master_data"); + e.HasKey(m => m.Id); + e.Property(m => m.Group).HasMaxLength(100).IsRequired(); + e.Property(m => m.Label).HasMaxLength(200).IsRequired(); + e.Property(m => m.Value).HasMaxLength(100).IsRequired(); + e.HasIndex(m => new { m.Group, m.Value }).IsUnique(); + } +} diff --git a/infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/infrastructure/Persistence/Configuration/ProjectConfiguration.cs new file mode 100644 index 0000000..4ec8877 --- /dev/null +++ b/infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using mws.backend.dotnet.domain.Projects; + +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; + +public class ProjectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("projects"); + e.HasKey(p => p.Id); + e.Property(p => p.Name).HasMaxLength(200).IsRequired(); + e.Property(p => p.Description).HasMaxLength(2000); + e.Property(p => p.Status).HasConversion().HasMaxLength(20); + + e.HasIndex(p => p.CreatedBy); + e.HasIndex(p => p.UpdatedBy); + + e.HasOne(p => p.CreatedByUser) + .WithMany() + .HasForeignKey(p => p.CreatedBy) + .OnDelete(DeleteBehavior.Restrict); + + e.HasOne(p => p.UpdatedByUser) + .WithMany() + .HasForeignKey(p => p.UpdatedBy) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs similarity index 86% rename from mws.infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs rename to infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs index 2238a85..ef1cab1 100644 --- a/mws.infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs +++ b/infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Projects; +using mws.backend.dotnet.domain.Projects; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class ProjectMemberConfiguration : IEntityTypeConfiguration { diff --git a/mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs b/infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs similarity index 84% rename from mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs rename to infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs index cb4c5b4..c4ab2a1 100644 --- a/mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs +++ b/infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Projects; +using mws.backend.dotnet.domain.Projects; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class ProjectMemberPermissionConfiguration : IEntityTypeConfiguration { diff --git a/mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs b/infrastructure/Persistence/Configuration/RoleConfiguration.cs similarity index 78% rename from mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs rename to infrastructure/Persistence/Configuration/RoleConfiguration.cs index b597ca7..fddedfa 100644 --- a/mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs +++ b/infrastructure/Persistence/Configuration/RoleConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Roles; +using mws.backend.dotnet.domain.Roles; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class RoleConfiguration : IEntityTypeConfiguration { diff --git a/mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs b/infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs similarity index 83% rename from mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs rename to infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs index c3040c0..460a311 100644 --- a/mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs +++ b/infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Roles; +using mws.backend.dotnet.domain.Roles; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class RolePermissionConfiguration : IEntityTypeConfiguration { diff --git a/mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs b/infrastructure/Persistence/Configuration/TaskConfiguration.cs similarity index 88% rename from mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs rename to infrastructure/Persistence/Configuration/TaskConfiguration.cs index fdde103..61ff180 100644 --- a/mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs +++ b/infrastructure/Persistence/Configuration/TaskConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Tasks; +using mws.backend.dotnet.domain.Tasks; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class TaskConfiguration : IEntityTypeConfiguration { diff --git a/mws.infrastructure/Persistence/Configuration/UserConfiguration.cs b/infrastructure/Persistence/Configuration/UserConfiguration.cs similarity index 70% rename from mws.infrastructure/Persistence/Configuration/UserConfiguration.cs rename to infrastructure/Persistence/Configuration/UserConfiguration.cs index 47de922..24c21a5 100644 --- a/mws.infrastructure/Persistence/Configuration/UserConfiguration.cs +++ b/infrastructure/Persistence/Configuration/UserConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Users; +using mws.backend.dotnet.domain.Users; -namespace Mws.Infrastructure.Persistence.Configuration; +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; public class UserConfiguration : IEntityTypeConfiguration { @@ -14,10 +14,5 @@ public class UserConfiguration : IEntityTypeConfiguration e.HasIndex(u => u.Username).IsUnique(); e.Property(u => u.PasswordHash).HasMaxLength(200).IsRequired(); e.Property(u => u.DisplayName).HasMaxLength(150).IsRequired(); - - e.HasOne(u => u.Role) - .WithMany() - .HasForeignKey(u => u.RoleId) - .OnDelete(DeleteBehavior.Restrict); } } diff --git a/infrastructure/Persistence/Configuration/UserRoleConfiguration.cs b/infrastructure/Persistence/Configuration/UserRoleConfiguration.cs new file mode 100644 index 0000000..4fdde9e --- /dev/null +++ b/infrastructure/Persistence/Configuration/UserRoleConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using mws.backend.dotnet.domain.Users; + +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; + +public class UserRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("user_roles"); + e.HasKey(ur => new { ur.UserId, ur.RoleId }); + + e.HasOne(ur => ur.User) + .WithMany(u => u.UserRoles) + .HasForeignKey(ur => ur.UserId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(ur => ur.Role) + .WithMany(r => r.UserRoles) + .HasForeignKey(ur => ur.RoleId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/mws.infrastructure/Persistence/DbSeeder.cs b/infrastructure/Persistence/DbSeeder.cs similarity index 53% rename from mws.infrastructure/Persistence/DbSeeder.cs rename to infrastructure/Persistence/DbSeeder.cs index 96cd442..214e9ea 100644 --- a/mws.infrastructure/Persistence/DbSeeder.cs +++ b/infrastructure/Persistence/DbSeeder.cs @@ -1,88 +1,45 @@ using Microsoft.EntityFrameworkCore; -using Mws.Application.Auth; -using Mws.Application.Permissions; -using Mws.Domain.Documents; -using Mws.Domain.Projects; -using Mws.Domain.Roles; -using Mws.Domain.Tasks; -using Mws.Domain.Users; -using TaskPriority = Mws.Domain.Tasks.TaskPriority; -using TaskStatus = Mws.Domain.Tasks.TaskStatus; +using mws.backend.dotnet.application.Auth; +using mws.backend.dotnet.application.Permissions; +using mws.backend.dotnet.domain.Documents; +using mws.backend.dotnet.domain.MasterData; +using mws.backend.dotnet.domain.Projects; +using mws.backend.dotnet.domain.Roles; +using mws.backend.dotnet.domain.Tasks; +using mws.backend.dotnet.domain.Users; +using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority; +using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus; -namespace Mws.Infrastructure.Persistence; +namespace mws.backend.dotnet.infrastructure.Persistence; public static class DbSeeder { public static async Task SeedAsync(AppDbContext db, IPasswordHasher passwordHasher) { - if (await db.Users.AnyAsync()) + await SeedMasterDataAsync(db); + await EnsureRolesAsync(db); + + var admin = await EnsureUserAsync(db, passwordHasher, "admin", "Admin", "Admin"); + var alice = await EnsureUserAsync(db, passwordHasher, "alice", "Alice", "Member"); + var bob = await EnsureUserAsync(db, passwordHasher, "bob", "Bob", "Member"); + await EnsureUserAsync(db, passwordHasher, "namdh", "Nam DH", "Member"); + + if (await db.Projects.AnyAsync()) { return; } var now = DateTime.UtcNow; - var adminRole = new Role { Id = Guid.NewGuid(), Name = "Admin", IsSystem = true, CreatedAt = now, UpdatedAt = now }; - var memberRole = new Role { Id = Guid.NewGuid(), Name = "Member", IsSystem = true, CreatedAt = now, UpdatedAt = now }; - - adminRole.Permissions = ScreenCatalog.Screens.Select(s => new RolePermission - { - RoleId = adminRole.Id, Screen = s.Key, CanView = true, CanCreate = true, CanEdit = true, CanDelete = true, - }).ToList(); - - memberRole.Permissions = ScreenCatalog.Screens - .Where(s => s.Key is not ("accounts" or "roles")) - .Select(s => new RolePermission - { - RoleId = memberRole.Id, Screen = s.Key, CanView = true, CanCreate = true, CanEdit = true, CanDelete = false, - }).ToList(); - - db.Roles.AddRange(adminRole, memberRole); - await db.SaveChangesAsync(); - - var admin = new User - { - Id = Guid.NewGuid(), - Username = "admin", - PasswordHash = passwordHasher.Hash("password"), - DisplayName = "Admin", - RoleId = adminRole.Id, - CreatedAt = now, - UpdatedAt = now, - }; - - var alice = new User - { - Id = Guid.NewGuid(), - Username = "alice", - PasswordHash = passwordHasher.Hash("password"), - DisplayName = "Alice", - RoleId = memberRole.Id, - CreatedAt = now, - UpdatedAt = now, - }; - - var bob = new User - { - Id = Guid.NewGuid(), - Username = "bob", - PasswordHash = passwordHasher.Hash("password"), - DisplayName = "Bob", - RoleId = memberRole.Id, - CreatedAt = now, - UpdatedAt = now, - }; - - db.Users.AddRange(admin, alice, bob); - await db.SaveChangesAsync(); - var project = new Project { Id = Guid.NewGuid(), Name = "MWS", Description = "My Workspace - internal tool for the team.", Status = ProjectStatus.Active, + CreatedBy = admin.Id, CreatedAt = now, + UpdatedBy = admin.Id, UpdatedAt = now, }; @@ -175,4 +132,92 @@ public static class DbSeeder await db.SaveChangesAsync(); } + + private static async Task EnsureRolesAsync(AppDbContext db) + { + var now = DateTime.UtcNow; + + if (!await db.Roles.AnyAsync(r => r.Name == "Admin")) + { + var adminRole = new Role { Id = Guid.NewGuid(), Name = "Admin", IsSystem = true, CreatedAt = now, UpdatedAt = now }; + adminRole.Permissions = ScreenCatalog.Screens.Select(s => new RolePermission + { + RoleId = adminRole.Id, Screen = s.Key, CanView = true, CanCreate = true, CanEdit = true, CanDelete = true, + }).ToList(); + db.Roles.Add(adminRole); + await db.SaveChangesAsync(); + } + + if (!await db.Roles.AnyAsync(r => r.Name == "Member")) + { + var memberRole = new Role { Id = Guid.NewGuid(), Name = "Member", IsSystem = true, CreatedAt = now, UpdatedAt = now }; + memberRole.Permissions = ScreenCatalog.Screens + .Where(s => s.Key is not ("users" or "permissions" or "masterdata")) + .Select(s => new RolePermission + { + RoleId = memberRole.Id, Screen = s.Key, CanView = true, CanCreate = true, CanEdit = true, CanDelete = false, + }).ToList(); + db.Roles.Add(memberRole); + await db.SaveChangesAsync(); + } + } + + private static async Task EnsureUserAsync(AppDbContext db, IPasswordHasher passwordHasher, string username, string displayName, string roleName) + { + var existing = await db.Users.Include(u => u.UserRoles).FirstOrDefaultAsync(u => u.Username == username); + if (existing != null) + { + return existing; + } + + var role = await db.Roles.FirstAsync(r => r.Name == roleName); + var now = DateTime.UtcNow; + var user = new User + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = passwordHasher.Hash("password"), + DisplayName = displayName, + UserRoles = new List { new UserRole { RoleId = role.Id } }, + CreatedAt = now, + UpdatedAt = now, + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + private static async Task SeedMasterDataAsync(AppDbContext db) + { + if (await db.MasterData.AnyAsync()) + { + return; + } + + var now = DateTime.UtcNow; + + MasterDataEntry Entry(string group, string label, string value, int sortOrder) => new() + { + Id = Guid.NewGuid(), Group = group, Label = label, Value = value, SortOrder = sortOrder, + IsActive = true, CreatedAt = now, UpdatedAt = now, + }; + + db.MasterData.AddRange( + Entry("task_status", "Todo", nameof(TaskStatus.Todo), 1), + Entry("task_status", "In Progress", nameof(TaskStatus.InProgress), 2), + Entry("task_status", "Done", nameof(TaskStatus.Done), 3), + Entry("task_status", "Cancelled", nameof(TaskStatus.Cancelled), 4), + Entry("task_priority", "Low", nameof(TaskPriority.Low), 1), + Entry("task_priority", "Medium", nameof(TaskPriority.Medium), 2), + Entry("task_priority", "High", nameof(TaskPriority.High), 3), + Entry("project_status", "Active", nameof(ProjectStatus.Active), 1), + Entry("project_status", "Archived", nameof(ProjectStatus.Archived), 2), + Entry("member_role", "Owner", nameof(MemberRole.Owner), 1), + Entry("member_role", "Member", nameof(MemberRole.Member), 2), + Entry("document_type", "Folder", nameof(DocumentType.Folder), 1), + Entry("document_type", "Document", nameof(DocumentType.Document), 2)); + + await db.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/infrastructure/Persistence/PaginationExtensions.cs b/infrastructure/Persistence/PaginationExtensions.cs new file mode 100644 index 0000000..dbe467c --- /dev/null +++ b/infrastructure/Persistence/PaginationExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using mws.backend.dotnet.application.Common; + +namespace mws.backend.dotnet.infrastructure.Persistence; + +public static class PaginationExtensions +{ + public const int DefaultPageSize = 20; + public const int MaxPageSize = 100; + + public static async Task> ToPagedResultAsync( + this IQueryable query, int page, int pageSize, CancellationToken ct = default) + { + page = page < 1 ? 1 : page; + pageSize = pageSize < 1 ? DefaultPageSize : Math.Min(pageSize, MaxPageSize); + + var totalCount = await query.CountAsync(ct); + var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct); + + return new PagedResult { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }; + } +} diff --git a/mws.infrastructure/Persistence/Repositories/DocumentRepository.cs b/infrastructure/Persistence/Repositories/DocumentRepository.cs similarity index 91% rename from mws.infrastructure/Persistence/Repositories/DocumentRepository.cs rename to infrastructure/Persistence/Repositories/DocumentRepository.cs index ff56bb0..4623f38 100644 --- a/mws.infrastructure/Persistence/Repositories/DocumentRepository.cs +++ b/infrastructure/Persistence/Repositories/DocumentRepository.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using Mws.Application.Common.Repositories; -using Mws.Domain.Documents; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.Documents; -namespace Mws.Infrastructure.Persistence.Repositories; +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; public class DocumentRepository(AppDbContext db) : RepositoryBase(db), IDocumentRepository { diff --git a/infrastructure/Persistence/Repositories/MasterDataRepository.cs b/infrastructure/Persistence/Repositories/MasterDataRepository.cs new file mode 100644 index 0000000..f2cf748 --- /dev/null +++ b/infrastructure/Persistence/Repositories/MasterDataRepository.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.MasterData; + +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; + +public class MasterDataRepository(AppDbContext db) : RepositoryBase(db), IMasterDataRepository +{ + public Task> 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) + .ToPagedResultAsync(page, pageSize, ct); + + public Task> GetActiveByGroupAsync(string group, CancellationToken ct = default) => + Set.Where(m => m.Group == group && m.IsActive) + .OrderBy(m => m.SortOrder).ThenBy(m => m.Label) + .ToListAsync(ct); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Set.FirstOrDefaultAsync(m => m.Id == id, ct); + + public Task ExistsAsync(string group, string value, Guid? excludeId, CancellationToken ct = default) => + Set.AnyAsync(m => m.Group == group && m.Value == value && (excludeId == null || m.Id != excludeId), ct); +} diff --git a/mws.infrastructure/Persistence/Repositories/ProjectRepository.cs b/infrastructure/Persistence/Repositories/ProjectRepository.cs similarity index 79% rename from mws.infrastructure/Persistence/Repositories/ProjectRepository.cs rename to infrastructure/Persistence/Repositories/ProjectRepository.cs index 59349fa..35573d3 100644 --- a/mws.infrastructure/Persistence/Repositories/ProjectRepository.cs +++ b/infrastructure/Persistence/Repositories/ProjectRepository.cs @@ -1,8 +1,9 @@ using Microsoft.EntityFrameworkCore; -using Mws.Application.Common.Repositories; -using Mws.Domain.Projects; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.Projects; -namespace Mws.Infrastructure.Persistence.Repositories; +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; public class ProjectRepository(AppDbContext db) : RepositoryBase(db), IProjectRepository { @@ -10,16 +11,19 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase(db), I Set.FirstOrDefaultAsync(p => p.Id == id, ct); public Task GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default) => - Set.FirstOrDefaultAsync(p => p.Id == projectId && p.Members.Any(m => m.UserId == userId), ct); + Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser) + .FirstOrDefaultAsync(p => p.Id == projectId && p.Members.Any(m => m.UserId == userId), ct); - public Task> GetForUserAsync(Guid userId, CancellationToken ct = default) => - Set.Where(p => p.Members.Any(m => m.UserId == userId)) + public Task> 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) - .ToListAsync(ct); + .ToPagedResultAsync(page, pageSize, ct); public Task> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default) { - var query = Set.Where(p => p.Members.Any(m => m.UserId == userId)); + var query = Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser) + .Where(p => p.Members.Any(m => m.UserId == userId)); if (!string.IsNullOrWhiteSpace(term)) { var lower = term.Trim().ToLower(); @@ -47,13 +51,13 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase(db), I public Task 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> GetMembersWithUserAsync(Guid projectId, CancellationToken ct = default) => + public Task> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default) => Db.ProjectMembers .Where(m => m.ProjectId == projectId) .Include(m => m.User) .OrderByDescending(m => m.Role) .ThenBy(m => m.User.DisplayName) - .ToListAsync(ct); + .ToPagedResultAsync(page, pageSize, ct); public Task CountOwnersAsync(Guid projectId, CancellationToken ct = default) => Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct); diff --git a/mws.infrastructure/Persistence/Repositories/RepositoryBase.cs b/infrastructure/Persistence/Repositories/RepositoryBase.cs similarity index 71% rename from mws.infrastructure/Persistence/Repositories/RepositoryBase.cs rename to infrastructure/Persistence/Repositories/RepositoryBase.cs index c830a29..d00368b 100644 --- a/mws.infrastructure/Persistence/Repositories/RepositoryBase.cs +++ b/infrastructure/Persistence/Repositories/RepositoryBase.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; -using Mws.Application.Common.Repositories; +using mws.backend.dotnet.application.Common.Repositories; -namespace Mws.Infrastructure.Persistence.Repositories; +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; public abstract class RepositoryBase(AppDbContext db) : IRepository where T : class { diff --git a/mws.infrastructure/Persistence/Repositories/RoleRepository.cs b/infrastructure/Persistence/Repositories/RoleRepository.cs similarity index 64% rename from mws.infrastructure/Persistence/Repositories/RoleRepository.cs rename to infrastructure/Persistence/Repositories/RoleRepository.cs index d2ad747..2e335f8 100644 --- a/mws.infrastructure/Persistence/Repositories/RoleRepository.cs +++ b/infrastructure/Persistence/Repositories/RoleRepository.cs @@ -1,13 +1,14 @@ using Microsoft.EntityFrameworkCore; -using Mws.Application.Common.Repositories; -using Mws.Domain.Roles; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.Roles; -namespace Mws.Infrastructure.Persistence.Repositories; +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; public class RoleRepository(AppDbContext db) : RepositoryBase(db), IRoleRepository { - public Task> GetAllWithPermissionsAsync(CancellationToken ct = default) => - Set.Include(r => r.Permissions).OrderBy(r => r.Name).ToListAsync(ct); + public Task> GetAllWithPermissionsAsync(int page, int pageSize, CancellationToken ct = default) => + Set.Include(r => r.Permissions).OrderBy(r => r.Name).ToPagedResultAsync(page, pageSize, ct); public Task GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default) => Set.Include(r => r.Permissions).FirstOrDefaultAsync(r => r.Id == id, ct); @@ -24,5 +25,8 @@ public class RoleRepository(AppDbContext db) : RepositoryBase(db), IRoleRe public Task> GetPermissionsAsync(Guid roleId, CancellationToken ct = default) => Db.RolePermissions.Where(p => p.RoleId == roleId).ToDictionaryAsync(p => p.Screen, ct); + public Task> GetPermissionsForRolesAsync(IEnumerable roleIds, CancellationToken ct = default) => + Db.RolePermissions.Where(p => roleIds.Contains(p.RoleId)).ToListAsync(ct); + public void RemovePermissions(IEnumerable permissions) => Db.RolePermissions.RemoveRange(permissions); } diff --git a/mws.infrastructure/Persistence/Repositories/TaskRepository.cs b/infrastructure/Persistence/Repositories/TaskRepository.cs similarity index 79% rename from mws.infrastructure/Persistence/Repositories/TaskRepository.cs rename to infrastructure/Persistence/Repositories/TaskRepository.cs index 643d24a..49cdfcd 100644 --- a/mws.infrastructure/Persistence/Repositories/TaskRepository.cs +++ b/infrastructure/Persistence/Repositories/TaskRepository.cs @@ -1,10 +1,11 @@ using Microsoft.EntityFrameworkCore; -using Mws.Application.Common.Repositories; -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.application.Common.Repositories; +using mws.backend.dotnet.domain.Tasks; +using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority; +using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus; -namespace Mws.Infrastructure.Persistence.Repositories; +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; public class TaskRepository(AppDbContext db) : RepositoryBase(db), ITaskRepository { @@ -14,8 +15,8 @@ public class TaskRepository(AppDbContext db) : RepositoryBase(db), ITa public Task GetWithAssigneeAsync(Guid id, CancellationToken ct = default) => Set.Include(t => t.Assignee).FirstOrDefaultAsync(t => t.Id == id, ct); - public Task> GetForProjectAsync( - Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default) + public Task> GetForProjectAsync( + Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, int page, int pageSize, CancellationToken ct = default) { var query = Set.Where(t => t.ProjectId == projectId); @@ -32,7 +33,7 @@ public class TaskRepository(AppDbContext db) : RepositoryBase(db), ITa query = query.Where(t => t.AssigneeId == assigneeId); } - return query.Include(t => t.Assignee).OrderByDescending(t => t.UpdatedAt).ToListAsync(ct); + return query.Include(t => t.Assignee).OrderByDescending(t => t.UpdatedAt).ToPagedResultAsync(page, pageSize, ct); } public Task> SearchWithAssigneeAsync(List projectIds, string? term, int take, CancellationToken ct = default) diff --git a/infrastructure/Persistence/Repositories/UserRepository.cs b/infrastructure/Persistence/Repositories/UserRepository.cs new file mode 100644 index 0000000..bd64130 --- /dev/null +++ b/infrastructure/Persistence/Repositories/UserRepository.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.Users; + +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; + +public class UserRepository(AppDbContext db) : RepositoryBase(db), IUserRepository +{ + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Set.FirstOrDefaultAsync(u => u.Id == id, ct); + + public Task GetByIdWithRoleAsync(Guid id, CancellationToken ct = default) => + Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).FirstOrDefaultAsync(u => u.Id == id, ct); + + public Task GetByUsernameWithRoleAsync(string username, CancellationToken ct = default) => + Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).SingleOrDefaultAsync(u => u.Username == username, ct); + + public Task ExistsByUsernameAsync(string username, CancellationToken ct = default) => + Set.AnyAsync(u => u.Username == username, ct); + + public Task ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default) => + Db.UserRoles.AnyAsync(ur => ur.RoleId == roleId, ct); + + public Task> GetRoleIdsAsync(Guid userId, CancellationToken ct = default) => + Db.UserRoles.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).ToListAsync(ct); + + public Task> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default) + { + var query = Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).AsQueryable(); + if (!string.IsNullOrWhiteSpace(term)) + { + var lower = term.Trim().ToLower(); + query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower)); + } + + query = query.OrderBy(u => u.DisplayName); + if (take is { } n) + { + query = query.Take(n); + } + + return query.ToListAsync(ct); + } + + public Task> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default) + { + var query = Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).AsQueryable(); + if (!string.IsNullOrWhiteSpace(term)) + { + var lower = term.Trim().ToLower(); + query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower)); + } + + return query.OrderBy(u => u.DisplayName).ToPagedResultAsync(page, pageSize, ct); + } +} diff --git a/mws.infrastructure/Persistence/UnitOfWork.cs b/infrastructure/Persistence/UnitOfWork.cs similarity index 67% rename from mws.infrastructure/Persistence/UnitOfWork.cs rename to infrastructure/Persistence/UnitOfWork.cs index 13a6aaf..1e29df1 100644 --- a/mws.infrastructure/Persistence/UnitOfWork.cs +++ b/infrastructure/Persistence/UnitOfWork.cs @@ -1,8 +1,8 @@ -using Mws.Application.Common; -using Mws.Application.Common.Repositories; -using Mws.Infrastructure.Persistence.Repositories; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.infrastructure.Persistence.Repositories; -namespace Mws.Infrastructure.Persistence; +namespace mws.backend.dotnet.infrastructure.Persistence; public class UnitOfWork : IUnitOfWork { @@ -16,6 +16,7 @@ public class UnitOfWork : IUnitOfWork Tasks = new TaskRepository(db); Documents = new DocumentRepository(db); Roles = new RoleRepository(db); + MasterData = new MasterDataRepository(db); } public IUserRepository Users { get; } @@ -23,6 +24,7 @@ public class UnitOfWork : IUnitOfWork public ITaskRepository Tasks { get; } public IDocumentRepository Documents { get; } public IRoleRepository Roles { get; } + public IMasterDataRepository MasterData { get; } public Task SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); } diff --git a/mws.infrastructure/mws.infrastructure.csproj b/infrastructure/infrastructure.csproj similarity index 75% rename from mws.infrastructure/mws.infrastructure.csproj rename to infrastructure/infrastructure.csproj index f883ec5..a512d5a 100644 --- a/mws.infrastructure/mws.infrastructure.csproj +++ b/infrastructure/infrastructure.csproj @@ -4,11 +4,12 @@ net10.0 enable enable + mws.backend.dotnet.infrastructure - - + + diff --git a/mws.api/Controllers/AccountsController.cs b/mws.api/Controllers/AccountsController.cs deleted file mode 100644 index f49eb87..0000000 --- a/mws.api/Controllers/AccountsController.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MediatR; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Mws.Application.Accounts; - -namespace Mws.Api.Controllers; - -[ApiController] -[Route("api/accounts")] -[Authorize] -public class AccountsController(ISender sender) : ControllerBase -{ - [HttpGet] - public async Task>> GetAll([FromQuery] string? q, CancellationToken ct) - { - return Ok(await sender.Send(new GetAccountsQuery(User.GetUserId(), q), ct)); - } - - [HttpPost] - public async Task> Create([FromBody] CreateAccountRequest request, CancellationToken ct) - { - return Ok(await sender.Send(new CreateAccountCommand(User.GetUserId(), request), ct)); - } - - [HttpPut("{id:guid}")] - public async Task> Update(Guid id, [FromBody] UpdateAccountRequest request, CancellationToken ct) - { - return Ok(await sender.Send(new UpdateAccountCommand(User.GetUserId(), id, request), ct)); - } - - [HttpDelete("{id:guid}")] - public async Task Delete(Guid id, CancellationToken ct) - { - await sender.Send(new DeleteAccountCommand(User.GetUserId(), id), ct); - return NoContent(); - } - - [HttpPost("{id:guid}/reset-password")] - public async Task ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct) - { - await sender.Send(new ResetPasswordCommand(User.GetUserId(), id, request), ct); - return NoContent(); - } -} diff --git a/mws.api/Controllers/MenuController.cs b/mws.api/Controllers/MenuController.cs deleted file mode 100644 index ef03ee3..0000000 --- a/mws.api/Controllers/MenuController.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Mws.Application.Permissions; - -namespace Mws.Api.Controllers; - -[ApiController] -[Route("api/menu")] -[Authorize] -public class MenuController(IPermissionService permissions) : ControllerBase -{ - [HttpGet] - public async Task>> GetMenu(CancellationToken ct) - { - return Ok(await permissions.GetMenuAsync(User.GetUserId(), ct)); - } -} diff --git a/mws.api/Controllers/RolesController.cs b/mws.api/Controllers/RolesController.cs deleted file mode 100644 index 1ca1ea5..0000000 --- a/mws.api/Controllers/RolesController.cs +++ /dev/null @@ -1,49 +0,0 @@ -using MediatR; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Mws.Application.Permissions; -using Mws.Application.Roles; - -namespace Mws.Api.Controllers; - -[ApiController] -[Route("api/roles")] -[Authorize] -public class RolesController(ISender sender, IPermissionService permissions) : ControllerBase -{ - [HttpGet] - public async Task>> GetAll(CancellationToken ct) - { - await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct); - return Ok(await sender.Send(new GetRolesQuery(), ct)); - } - - [HttpGet("{id:guid}")] - public async Task> Get(Guid id, CancellationToken ct) - { - await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct); - return Ok(await sender.Send(new GetRoleQuery(id), ct)); - } - - [HttpPost] - public async Task> Create([FromBody] SaveRoleRequest request, CancellationToken ct) - { - await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Create, ct); - return Ok(await sender.Send(new CreateRoleCommand(request), ct)); - } - - [HttpPut("{id:guid}")] - public async Task> Update(Guid id, [FromBody] SaveRoleRequest request, CancellationToken ct) - { - await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Edit, ct); - return Ok(await sender.Send(new UpdateRoleCommand(id, request), ct)); - } - - [HttpDelete("{id:guid}")] - public async Task Delete(Guid id, CancellationToken ct) - { - await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Delete, ct); - await sender.Send(new DeleteRoleCommand(id), ct); - return NoContent(); - } -} diff --git a/mws.api/Controllers/UsersController.cs b/mws.api/Controllers/UsersController.cs deleted file mode 100644 index f056a04..0000000 --- a/mws.api/Controllers/UsersController.cs +++ /dev/null @@ -1,20 +0,0 @@ -using AutoMapper; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Mws.Application.Common; -using Mws.Application.Auth; - -namespace Mws.Api.Controllers; - -[ApiController] -[Route("api/users")] -[Authorize] -public class UsersController(IUnitOfWork uow, IMapper mapper) : ControllerBase -{ - [HttpGet] - public async Task>> GetAll([FromQuery] string? q, CancellationToken ct) - { - var users = await uow.Users.SearchWithRoleAsync(q, 50, ct); - return Ok(mapper.Map>(users)); - } -} \ No newline at end of file diff --git a/mws.api/mws.api.csproj b/mws.api/mws.api.csproj deleted file mode 100644 index 2d3e4e5..0000000 --- a/mws.api/mws.api.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - mws.backend.dotnet - - - - - - - - - - - - - - diff --git a/mws.application/Accounts/Commands/CreateAccount.cs b/mws.application/Accounts/Commands/CreateAccount.cs deleted file mode 100644 index 68cf461..0000000 --- a/mws.application/Accounts/Commands/CreateAccount.cs +++ /dev/null @@ -1,54 +0,0 @@ -using AutoMapper; -using MediatR; -using Mws.Application.Auth; -using Mws.Application.Common; -using Mws.Application.Permissions; -using Mws.Domain.Users; - -namespace Mws.Application.Accounts; - -public record CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest; - -public class CreateAccountHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) - : IRequestHandler -{ - private const string Screen = "accounts"; - - public async Task Handle(CreateAccountCommand 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"); - } - - var role = await uow.Roles.GetByIdAsync(request.RoleId, 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(), - RoleId = role.Id, - IsActive = true, - CreatedAt = now, - UpdatedAt = now, - }; - - uow.Users.Add(user); - await uow.SaveChangesAsync(ct); - user.Role = role; - return mapper.Map(user); - } -} diff --git a/mws.application/Accounts/Commands/DeleteAccount.cs b/mws.application/Accounts/Commands/DeleteAccount.cs deleted file mode 100644 index 0e0d488..0000000 --- a/mws.application/Accounts/Commands/DeleteAccount.cs +++ /dev/null @@ -1,34 +0,0 @@ -using MediatR; -using Mws.Application.Common; -using Mws.Application.Permissions; - -namespace Mws.Application.Accounts; - -public record DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest; - -public class DeleteAccountHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler -{ - private const string Screen = "accounts"; - - public async Task Handle(DeleteAccountCommand command, CancellationToken ct) - { - await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Delete, ct); - - var user = await uow.Users.GetByIdAsync(command.Id, ct) - ?? throw new NotFoundException("Account 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 an account that is the sole owner of a project"); - } - } - - uow.Users.Remove(user); - await uow.SaveChangesAsync(ct); - } -} diff --git a/mws.application/Accounts/Commands/UpdateAccount.cs b/mws.application/Accounts/Commands/UpdateAccount.cs deleted file mode 100644 index 34fdcf1..0000000 --- a/mws.application/Accounts/Commands/UpdateAccount.cs +++ /dev/null @@ -1,35 +0,0 @@ -using AutoMapper; -using MediatR; -using Mws.Application.Common; -using Mws.Application.Permissions; - -namespace Mws.Application.Accounts; - -public record UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest; - -public class UpdateAccountHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) - : IRequestHandler -{ - private const string Screen = "accounts"; - - public async Task Handle(UpdateAccountCommand command, CancellationToken ct) - { - await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct); - - var user = await uow.Users.GetByIdWithRoleAsync(command.Id, ct) - ?? throw new NotFoundException("Account not found"); - - var request = command.Request; - var role = await uow.Roles.GetByIdAsync(request.RoleId, ct) - ?? throw new BadRequestException("Role not found"); - - user.DisplayName = request.DisplayName.Trim(); - user.RoleId = role.Id; - user.Role = role; - user.IsActive = request.IsActive; - user.UpdatedAt = DateTime.UtcNow; - - await uow.SaveChangesAsync(ct); - return mapper.Map(user); - } -} diff --git a/mws.application/Accounts/Queries/GetAccounts.cs b/mws.application/Accounts/Queries/GetAccounts.cs deleted file mode 100644 index d4af4b4..0000000 --- a/mws.application/Accounts/Queries/GetAccounts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AutoMapper; -using MediatR; -using Mws.Application.Common; -using Mws.Application.Permissions; - -namespace Mws.Application.Accounts; - -public record GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest>; - -public class GetAccountsHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) - : IRequestHandler> -{ - private const string Screen = "accounts"; - - public async Task> Handle(GetAccountsQuery query, CancellationToken ct) - { - await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct); - - var users = await uow.Users.SearchWithRoleAsync(query.Term, null, ct); - return mapper.Map>(users); - } -} diff --git a/mws.application/Common/MappingProfile.cs b/mws.application/Common/MappingProfile.cs deleted file mode 100644 index 752d9b0..0000000 --- a/mws.application/Common/MappingProfile.cs +++ /dev/null @@ -1,33 +0,0 @@ -using AutoMapper; -using Mws.Application.Accounts; -using Mws.Application.Auth; -using Mws.Application.Documents; -using Mws.Application.Projects; -using Mws.Application.Roles; -using Mws.Application.Tasks; -using Mws.Domain.Documents; -using Mws.Domain.Roles; -using Mws.Domain.Tasks; -using Mws.Domain.Users; - -namespace Mws.Application.Common; - -public class MappingProfile : Profile -{ - public MappingProfile() - { - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap() - .ForMember(d => d.Children, opt => opt.Ignore()); - CreateMap() - .ForMember(d => d.AssigneeName, opt => opt.MapFrom(s => s.Assignee == null ? null : s.Assignee.DisplayName)); - CreateMap() - .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString())); - CreateMap(); - } -} diff --git a/mws.application/Permissions/Contracts.cs b/mws.application/Permissions/Contracts.cs deleted file mode 100644 index 9f4a8ad..0000000 --- a/mws.application/Permissions/Contracts.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Mws.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; } -} diff --git a/mws.application/Permissions/PermissionService.cs b/mws.application/Permissions/PermissionService.cs deleted file mode 100644 index 700f198..0000000 --- a/mws.application/Permissions/PermissionService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Mws.Application.Common; - -namespace Mws.Application.Permissions; - -public class PermissionService(IUnitOfWork uow) : IPermissionService -{ - public async Task> GetMenuAsync(Guid userId, CancellationToken ct = default) - { - var roleId = await uow.Users.GetRoleIdAsync(userId, ct); - var permissions = await uow.Roles.GetPermissionsAsync(roleId, ct); - - return ScreenCatalog.Screens.Select(s => - { - permissions.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 roleId = await uow.Users.GetRoleIdAsync(userId, ct); - var permission = await uow.Roles.GetPermissionAsync(roleId, screen, ct); - - var allowed = action switch - { - PermissionAction.View => permission?.CanView ?? false, - PermissionAction.Create => permission?.CanCreate ?? false, - PermissionAction.Edit => permission?.CanEdit ?? false, - PermissionAction.Delete => permission?.CanDelete ?? false, - _ => false, - }; - - if (!allowed) - { - throw new ForbiddenException($"Not permitted to {action} on {screen}"); - } - } -} diff --git a/mws.application/Projects/Queries/GetProjects.cs b/mws.application/Projects/Queries/GetProjects.cs deleted file mode 100644 index ce71d6f..0000000 --- a/mws.application/Projects/Queries/GetProjects.cs +++ /dev/null @@ -1,16 +0,0 @@ -using AutoMapper; -using MediatR; -using Mws.Application.Common; - -namespace Mws.Application.Projects; - -public record GetProjectsQuery(Guid UserId) : IRequest>; - -public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> -{ - public async Task> Handle(GetProjectsQuery query, CancellationToken ct) - { - var projects = await uow.Projects.GetForUserAsync(query.UserId, ct); - return mapper.Map>(projects); - } -} diff --git a/mws.application/Roles/Queries/GetRoles.cs b/mws.application/Roles/Queries/GetRoles.cs deleted file mode 100644 index b4dc3b9..0000000 --- a/mws.application/Roles/Queries/GetRoles.cs +++ /dev/null @@ -1,16 +0,0 @@ -using AutoMapper; -using MediatR; -using Mws.Application.Common; - -namespace Mws.Application.Roles; - -public record GetRolesQuery : IRequest>; - -public class GetRolesHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> -{ - public async Task> Handle(GetRolesQuery query, CancellationToken ct) - { - var roles = await uow.Roles.GetAllWithPermissionsAsync(ct); - return mapper.Map>(roles); - } -} diff --git a/mws.application/Tasks/Queries/GetTasks.cs b/mws.application/Tasks/Queries/GetTasks.cs deleted file mode 100644 index 517bcbf..0000000 --- a/mws.application/Tasks/Queries/GetTasks.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AutoMapper; -using MediatR; -using Mws.Application.Common; -using TaskPriority = Mws.Domain.Tasks.TaskPriority; -using TaskStatus = Mws.Domain.Tasks.TaskStatus; - -namespace Mws.Application.Tasks; - -public record GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId) - : IRequest>; - -public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> -{ - public async Task> 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, ct); - return mapper.Map>(tasks); - } -} diff --git a/mws.backend.dotnet.sln b/mws.backend.dotnet.sln index 298d142..06a717b 100644 --- a/mws.backend.dotnet.sln +++ b/mws.backend.dotnet.sln @@ -1,12 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mws.api", "mws.api\mws.api.csproj", "{9099030B-4019-410C-AEBE-83099B58ABA6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{9099030B-4019-410C-AEBE-83099B58ABA6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mws.application", "mws.application\mws.application.csproj", "{D876692E-C85A-49CB-8897-18D6C884CB0F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "application", "application\application.csproj", "{D876692E-C85A-49CB-8897-18D6C884CB0F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mws.domain", "mws.domain\mws.domain.csproj", "{338B6456-F229-4738-85EE-3AD2D84052C6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "domain", "domain\domain.csproj", "{338B6456-F229-4738-85EE-3AD2D84052C6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mws.infrastructure", "mws.infrastructure\mws.infrastructure.csproj", "{FEFFBC10-B99E-4A56-9F32-6D925D192606}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "infrastructure", "infrastructure\infrastructure.csproj", "{FEFFBC10-B99E-4A56-9F32-6D925D192606}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs deleted file mode 100644 index 84a12d7..0000000 --- a/mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Mws.Domain.Projects; - -namespace Mws.Infrastructure.Persistence.Configuration; - -public class ProjectConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder e) - { - e.ToTable("projects"); - e.HasKey(p => p.Id); - e.Property(p => p.Name).HasMaxLength(200).IsRequired(); - e.Property(p => p.Description).HasMaxLength(2000); - e.Property(p => p.Status).HasConversion().HasMaxLength(20); - } -} diff --git a/mws.infrastructure/Persistence/Repositories/UserRepository.cs b/mws.infrastructure/Persistence/Repositories/UserRepository.cs deleted file mode 100644 index 4b6e244..0000000 --- a/mws.infrastructure/Persistence/Repositories/UserRepository.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Mws.Application.Common.Repositories; -using Mws.Domain.Users; - -namespace Mws.Infrastructure.Persistence.Repositories; - -public class UserRepository(AppDbContext db) : RepositoryBase(db), IUserRepository -{ - public Task GetByIdAsync(Guid id, CancellationToken ct = default) => - Set.FirstOrDefaultAsync(u => u.Id == id, ct); - - public Task GetByIdWithRoleAsync(Guid id, CancellationToken ct = default) => - Set.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == id, ct); - - public Task GetByUsernameWithRoleAsync(string username, CancellationToken ct = default) => - Set.Include(u => u.Role).SingleOrDefaultAsync(u => u.Username == username, ct); - - public Task ExistsByUsernameAsync(string username, CancellationToken ct = default) => - Set.AnyAsync(u => u.Username == username, ct); - - public Task ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default) => - Set.AnyAsync(u => u.RoleId == roleId, ct); - - public Task GetRoleIdAsync(Guid userId, CancellationToken ct = default) => - Set.Where(u => u.Id == userId).Select(u => u.RoleId).SingleOrDefaultAsync(ct); - - public Task> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default) - { - var query = Set.Include(u => u.Role).AsQueryable(); - if (!string.IsNullOrWhiteSpace(term)) - { - var lower = term.Trim().ToLower(); - query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower)); - } - - query = query.OrderBy(u => u.DisplayName); - if (take is { } n) - { - query = query.Take(n); - } - - return query.ToListAsync(ct); - } -}