From f72aaa23291585de7902505b7f89265a3aba1828 Mon Sep 17 00:00:00 2001 From: namdh861 Date: Thu, 13 Aug 2026 23:10:22 +0700 Subject: [PATCH] Commit MWS backend source tree The ASP.NET Core solution (mws.api/mws.application/mws.domain/ mws.infrastructure) existed only as untracked working files. Adding it to version control, plus a .gitignore for build output and local tooling directories, so the CQRS/mediator refactor plan has a real git history to branch and diff against. --- .dockerignore | 4 + .gitignore | 7 + CLAUDE.md | 76 + Dockerfile | 19 + .../plans/2026-08-13-cqrs-mediator.md | 2319 +++++++++++++++++ mws.api/ClaimsExtensions.cs | 19 + mws.api/Controllers/AccountsController.cs | 43 + mws.api/Controllers/AuthController.cs | 18 + mws.api/Controllers/DocumentsController.cs | 56 + mws.api/Controllers/MenuController.cs | 17 + .../Controllers/ProjectMembersController.cs | 37 + mws.api/Controllers/ProjectsController.cs | 55 + mws.api/Controllers/RolesController.cs | 48 + mws.api/Controllers/TasksController.cs | 68 + mws.api/Controllers/UsersController.cs | 20 + mws.api/Middleware/ApiExceptionMiddleware.cs | 46 + mws.api/Program.cs | 122 + mws.api/Properties/launchSettings.json | 23 + mws.api/appsettings.Development.json | 22 + mws.api/appsettings.json | 8 + mws.api/mws.api.csproj | 21 + mws.application/Accounts/AccountService.cs | 116 + mws.application/Accounts/Contracts.cs | 32 + mws.application/Accounts/IAccountService.cs | 10 + mws.application/Auth/AuthService.cs | 35 + mws.application/Auth/Contracts.cs | 33 + mws.application/Common/Exceptions.cs | 9 + mws.application/Common/IUnitOfWork.cs | 14 + mws.application/Common/MappingProfile.cs | 33 + .../Repositories/IDocumentRepository.cs | 15 + .../Common/Repositories/IProjectRepository.cs | 28 + .../Common/Repositories/IRepository.cs | 7 + .../Common/Repositories/IRoleRepository.cs | 14 + .../Common/Repositories/ITaskRepository.cs | 16 + .../Common/Repositories/IUserRepository.cs | 14 + mws.application/Documents/Contracts.cs | 49 + mws.application/Documents/DocumentService.cs | 229 ++ mws.application/Documents/IDocumentService.cs | 14 + mws.application/Permissions/Contracts.cs | 20 + .../Permissions/IPermissionService.cs | 7 + .../Permissions/PermissionService.cs | 47 + mws.application/Permissions/ScreenCatalog.cs | 18 + mws.application/Projects/Contracts.cs | 77 + mws.application/Projects/IProjectService.cs | 22 + .../Projects/ProjectMemberService.cs | 151 ++ mws.application/Projects/ProjectService.cs | 127 + mws.application/Roles/Contracts.cs | 24 + mws.application/Roles/IRoleService.cs | 10 + mws.application/Roles/RoleService.cs | 118 + mws.application/Tasks/Contracts.cs | 40 + mws.application/Tasks/ITaskService.cs | 15 + mws.application/Tasks/TaskService.cs | 129 + mws.application/mws.application.csproj | 17 + mws.backend.dotnet.sln | 34 + mws.domain/Documents/Document.cs | 24 + mws.domain/Projects/Project.cs | 19 + mws.domain/Projects/ProjectMember.cs | 17 + .../Projects/ProjectMemberPermission.cs | 17 + mws.domain/Roles/Role.cs | 12 + mws.domain/Roles/RolePermission.cs | 13 + mws.domain/Tasks/TaskItem.cs | 33 + mws.domain/Users/User.cs | 17 + mws.domain/mws.domain.csproj | 9 + .../Authentication/BcryptPasswordHasher.cs | 10 + .../Authentication/JwtOptions.cs | 9 + .../Authentication/JwtTokenService.cs | 32 + mws.infrastructure/DependencyInjection.cs | 53 + .../20260809054503_InitialCreate.Designer.cs | 264 ++ .../20260809054503_InitialCreate.cs | 180 ++ ...1133605_AddRolesAndPermissions.Designer.cs | 353 +++ .../20260811133605_AddRolesAndPermissions.cs | 111 + ...2_AddMemberDocumentPermissions.Designer.cs | 365 +++ ...0812124332_AddMemberDocumentPermissions.cs | 65 + ..._SplitProjectMemberPermissions.Designer.cs | 391 +++ ...812130925_SplitProjectMemberPermissions.cs | 105 + .../Migrations/AppDbContextModelSnapshot.cs | 388 +++ .../Persistence/AppDbContext.cs | 25 + .../Configuration/DocumentConfiguration.cs | 25 + .../Configuration/ProjectConfiguration.cs | 17 + .../ProjectMemberConfiguration.cs | 25 + .../ProjectMemberPermissionConfiguration.cs | 20 + .../Configuration/RoleConfiguration.cs | 16 + .../RolePermissionConfiguration.cs | 20 + .../Configuration/TaskConfiguration.cs | 27 + .../Configuration/UserConfiguration.cs | 23 + mws.infrastructure/Persistence/DbSeeder.cs | 178 ++ .../Repositories/DocumentRepository.cs | 41 + .../Repositories/ProjectRepository.cs | 88 + .../Repositories/RepositoryBase.cs | 13 + .../Repositories/RoleRepository.cs | 28 + .../Repositories/TaskRepository.cs | 60 + .../Repositories/UserRepository.cs | 44 + mws.infrastructure/Persistence/UnitOfWork.cs | 28 + mws.infrastructure/mws.infrastructure.csproj | 21 + 94 files changed, 7758 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 docs/superpowers/plans/2026-08-13-cqrs-mediator.md create mode 100644 mws.api/ClaimsExtensions.cs create mode 100644 mws.api/Controllers/AccountsController.cs create mode 100644 mws.api/Controllers/AuthController.cs create mode 100644 mws.api/Controllers/DocumentsController.cs create mode 100644 mws.api/Controllers/MenuController.cs create mode 100644 mws.api/Controllers/ProjectMembersController.cs create mode 100644 mws.api/Controllers/ProjectsController.cs create mode 100644 mws.api/Controllers/RolesController.cs create mode 100644 mws.api/Controllers/TasksController.cs create mode 100644 mws.api/Controllers/UsersController.cs create mode 100644 mws.api/Middleware/ApiExceptionMiddleware.cs create mode 100644 mws.api/Program.cs create mode 100644 mws.api/Properties/launchSettings.json create mode 100644 mws.api/appsettings.Development.json create mode 100644 mws.api/appsettings.json create mode 100644 mws.api/mws.api.csproj create mode 100644 mws.application/Accounts/AccountService.cs create mode 100644 mws.application/Accounts/Contracts.cs create mode 100644 mws.application/Accounts/IAccountService.cs create mode 100644 mws.application/Auth/AuthService.cs create mode 100644 mws.application/Auth/Contracts.cs create mode 100644 mws.application/Common/Exceptions.cs create mode 100644 mws.application/Common/IUnitOfWork.cs create mode 100644 mws.application/Common/MappingProfile.cs create mode 100644 mws.application/Common/Repositories/IDocumentRepository.cs create mode 100644 mws.application/Common/Repositories/IProjectRepository.cs create mode 100644 mws.application/Common/Repositories/IRepository.cs create mode 100644 mws.application/Common/Repositories/IRoleRepository.cs create mode 100644 mws.application/Common/Repositories/ITaskRepository.cs create mode 100644 mws.application/Common/Repositories/IUserRepository.cs create mode 100644 mws.application/Documents/Contracts.cs create mode 100644 mws.application/Documents/DocumentService.cs create mode 100644 mws.application/Documents/IDocumentService.cs create mode 100644 mws.application/Permissions/Contracts.cs create mode 100644 mws.application/Permissions/IPermissionService.cs create mode 100644 mws.application/Permissions/PermissionService.cs create mode 100644 mws.application/Permissions/ScreenCatalog.cs create mode 100644 mws.application/Projects/Contracts.cs create mode 100644 mws.application/Projects/IProjectService.cs create mode 100644 mws.application/Projects/ProjectMemberService.cs create mode 100644 mws.application/Projects/ProjectService.cs create mode 100644 mws.application/Roles/Contracts.cs create mode 100644 mws.application/Roles/IRoleService.cs create mode 100644 mws.application/Roles/RoleService.cs create mode 100644 mws.application/Tasks/Contracts.cs create mode 100644 mws.application/Tasks/ITaskService.cs create mode 100644 mws.application/Tasks/TaskService.cs create mode 100644 mws.application/mws.application.csproj create mode 100644 mws.backend.dotnet.sln create mode 100644 mws.domain/Documents/Document.cs create mode 100644 mws.domain/Projects/Project.cs create mode 100644 mws.domain/Projects/ProjectMember.cs create mode 100644 mws.domain/Projects/ProjectMemberPermission.cs create mode 100644 mws.domain/Roles/Role.cs create mode 100644 mws.domain/Roles/RolePermission.cs create mode 100644 mws.domain/Tasks/TaskItem.cs create mode 100644 mws.domain/Users/User.cs create mode 100644 mws.domain/mws.domain.csproj create mode 100644 mws.infrastructure/Authentication/BcryptPasswordHasher.cs create mode 100644 mws.infrastructure/Authentication/JwtOptions.cs create mode 100644 mws.infrastructure/Authentication/JwtTokenService.cs create mode 100644 mws.infrastructure/DependencyInjection.cs create mode 100644 mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs create mode 100644 mws.infrastructure/Migrations/20260809054503_InitialCreate.cs create mode 100644 mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs create mode 100644 mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs create mode 100644 mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs create mode 100644 mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs create mode 100644 mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs create mode 100644 mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs create mode 100644 mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs create mode 100644 mws.infrastructure/Persistence/AppDbContext.cs create mode 100644 mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs create mode 100644 mws.infrastructure/Persistence/Configuration/UserConfiguration.cs create mode 100644 mws.infrastructure/Persistence/DbSeeder.cs create mode 100644 mws.infrastructure/Persistence/Repositories/DocumentRepository.cs create mode 100644 mws.infrastructure/Persistence/Repositories/ProjectRepository.cs create mode 100644 mws.infrastructure/Persistence/Repositories/RepositoryBase.cs create mode 100644 mws.infrastructure/Persistence/Repositories/RoleRepository.cs create mode 100644 mws.infrastructure/Persistence/Repositories/TaskRepository.cs create mode 100644 mws.infrastructure/Persistence/Repositories/UserRepository.cs create mode 100644 mws.infrastructure/Persistence/UnitOfWork.cs create mode 100644 mws.infrastructure/mws.infrastructure.csproj diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dc536a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +bin/ +obj/ +*.user +.vs/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8468a9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +.vs/ +.idea/ +.DS_Store +.claude/ +.serena/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9288029 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +Backend for My Workspace (MWS) — ASP.NET Core 10 Web API with JWT auth, EF Core, and PostgreSQL. + +## Commands + +```bash +# build everything +dotnet build Mws.slnx + +# run the API (auto-migrates + seeds on startup) +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 + +# apply migrations explicitly (otherwise the app does it on boot) +dotnet ef database update --project Mws.Infrastructure --startup-project Mws.Api + +# run the docker image +docker build -t mws.api . +docker run --rm -p 8080:8080 mws.api +``` + +No test project exists. Add one under `Mws.Tests/` (xUnit) when tests are needed — none is expected today. + +## Architecture + +Clean-lite, 4 projects in [Mws.slnx](Mws.slnx). 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.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 +``` + +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`. + +### 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. + +### 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`. + +### Config + +| Setting | Purpose | Notes | +| --- | --- | --- | +| `Jwt:Secret` | HMAC signing key | ≥32 chars required, else falls back to dev key. Set `MWS_JWT_SECRET` env var in prod. | +| `Jwt:Issuer` / `Jwt:Audience` | Token validation | Defaults `Mws` / `Mws.Clients`. | +| `Cors:Origins` | Allowed origins (semicolon-separated) | Default `http://localhost:5173` (Vite frontend). | + +### 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. +- All async service methods take `CancellationToken ct = default` and forward it. +- Enums serialized as strings globally (`JsonStringEnumConverter` in `Program.cs`). + +## Frontend + +The companion frontend lives at `../project/mws/` (Vite/React). API root in dev: `http://localhost:5xxx` → backend at `http://localhost:5xxx` per CORS config — confirm port mapping if backend URL changes. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77e3ad1 --- /dev/null +++ b/Dockerfile @@ -0,0 +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/ +RUN dotnet restore + +COPY app/ app/ +RUN dotnet publish app/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 diff --git a/docs/superpowers/plans/2026-08-13-cqrs-mediator.md b/docs/superpowers/plans/2026-08-13-cqrs-mediator.md new file mode 100644 index 0000000..78178ac --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-cqrs-mediator.md @@ -0,0 +1,2319 @@ +# CQRS/Mediator Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the 9 `IXService`/`XService` application-layer classes with MediatR commands/queries + handlers, one per operation, so controllers depend only on `ISender`. + +**Architecture:** Each old service method becomes a `record XCommand`/`XQuery : IRequest` plus an `XHandler : IRequestHandler<...>` colocated in one file under a `Commands/` or `Queries/` folder per module. Handler bodies are the old method bodies moved verbatim. `IPermissionService`, `ITokenService`, `IPasswordHasher` stay plain injected services (not mediator requests) since they're cross-cutting helpers called from inside other handlers/controllers, not endpoint operations themselves. + +**Tech Stack:** .NET 10, ASP.NET Core, EF Core/Npgsql, AutoMapper (unchanged), MediatR (new). + +**Spec:** [docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md](../specs/2026-08-13-cqrs-mediator-design.md) + +## Global Constraints + +- No behavior change: every handler's logic must match the old service method's logic exactly (same exceptions, same order of checks, same repository calls). +- No test project exists in this repo — verification is `dotnet build mws.backend.dotnet.sln` (must show `0 Error(s)`) plus a manual curl smoke test in the final task. +- Solution file is `mws.backend.dotnet.sln` at repo root; actual project folders are lowercase (`mws.api/`, `mws.application/`, `mws.domain/`, `mws.infrastructure/`) despite `CLAUDE.md` referring to PascalCase paths — use the lowercase paths, they are what's on disk. +- `IPermissionService`, `ITokenService`, `IPasswordHasher` registrations and implementations are NOT touched by this plan. +- AutoMapper (`IMapper`) usage is unchanged — same `mapper.Map(...)` calls, just moved into handlers. +- Commit after each task (one module = one commit), so the branch is bisectable if something regresses. + +--- + +### Task 1: Add MediatR, wire DI, convert Auth module + +**Files:** +- Modify: `mws.application/mws.application.csproj` (add MediatR package) +- Modify: `mws.infrastructure/DependencyInjection.cs` (add `AddMediatR`, remove `AddScoped`) +- Create: `mws.application/Auth/Commands/Login.cs` +- Delete: `mws.application/Auth/AuthService.cs` +- Modify: `mws.api/Controllers/AuthController.cs` + +**Interfaces:** +- Produces: `Mws.Application.Auth.LoginCommand(LoginRequest Request) : IRequest` — every later task follows this same record-wraps-existing-Request-DTO pattern. +- Consumes: `Mws.Application.Auth.LoginRequest`, `LoginResponse`, `UserDto` (unchanged, from `Auth/Contracts.cs`); `IUnitOfWork`, `IPasswordHasher`, `ITokenService` (unchanged). + +- [ ] **Step 1: Add the MediatR package** + +Run: `dotnet add mws.application/mws.application.csproj package MediatR` + +This resolves and pins the latest stable MediatR version in `mws.application.csproj`. + +- [ ] **Step 2: Create the Login command + handler** + +Create `mws.application/Auth/Commands/Login.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Auth; + +public record LoginCommand(LoginRequest Request) : IRequest; + +public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper) + : IRequestHandler +{ + public async Task Handle(LoginCommand command, CancellationToken ct) + { + var username = command.Request.Username.Trim(); + var user = await uow.Users.GetByUsernameWithRoleAsync(username, ct); + + if (user is null || !passwordHasher.Verify(command.Request.Password, user.PasswordHash)) + { + throw new UnauthorizedException("Invalid username or password"); + } + + if (!user.IsActive) + { + throw new ForbiddenException("Account disabled"); + } + + return new LoginResponse + { + Token = tokenService.CreateToken(user.Id, user.Username), + User = mapper.Map(user), + }; + } +} +``` + +- [ ] **Step 3: Delete the old service file** + +Delete `mws.application/Auth/AuthService.cs` (it contained both `IAuthService` and `AuthService` — both are now replaced by `LoginCommand`/`LoginHandler`). + +- [ ] **Step 4: Update `AuthController`** + +Replace the full contents of `mws.api/Controllers/AuthController.cs`: + +```csharp +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Auth; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/auth")] +[AllowAnonymous] +public class AuthController(ISender sender) : ControllerBase +{ + [HttpPost("login")] + public async Task> Login([FromBody] LoginRequest request, CancellationToken ct) + { + var response = await sender.Send(new LoginCommand(request), ct); + return Ok(response); + } +} +``` + +- [ ] **Step 5: Wire MediatR and drop the old registration in DI** + +In `mws.infrastructure/DependencyInjection.cs`, add the MediatR registration right after `services.AddAutoMapper(...)`: + +```csharp + services.AddAutoMapper(cfg => { }, typeof(MappingProfile).Assembly); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(IUnitOfWork).Assembly)); +``` + +Remove the line `services.AddScoped();`. + +Remove the now-unused `using Mws.Application.Auth;` only if nothing else in the file still needs it — it does (`JwtOptions`, `ITokenService` wiring live in `Mws.Application.Auth` too), so leave the using in place. + +- [ ] **Step 6: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 7: Commit** + +```bash +git add mws.application/mws.application.csproj mws.application/Auth mws.infrastructure/DependencyInjection.cs mws.api/Controllers/AuthController.cs +git commit -m "$(cat <<'EOF' +Convert Auth module to MediatR command + +Adds the MediatR package and wires AddMediatR in DI. Replaces +IAuthService/AuthService with LoginCommand/LoginHandler, the pattern +the remaining modules will follow. +EOF +)" +``` + +--- + +### Task 2: Convert Accounts module + +**Files:** +- Create: `mws.application/Accounts/Queries/GetAccounts.cs` +- Create: `mws.application/Accounts/Commands/CreateAccount.cs` +- Create: `mws.application/Accounts/Commands/UpdateAccount.cs` +- Create: `mws.application/Accounts/Commands/DeleteAccount.cs` +- Create: `mws.application/Accounts/Commands/ResetPassword.cs` +- Delete: `mws.application/Accounts/IAccountService.cs` +- Delete: `mws.application/Accounts/AccountService.cs` +- Modify: `mws.api/Controllers/AccountsController.cs` +- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped`) + +**Interfaces:** +- Consumes: `IUnitOfWork`, `IPasswordHasher` (`Mws.Application.Auth`), `IPermissionService`/`PermissionAction` (`Mws.Application.Permissions`) — all unchanged. `AccountDto`, `CreateAccountRequest`, `UpdateAccountRequest`, `ResetPasswordRequest` from `Accounts/Contracts.cs` — unchanged. +- Produces: `GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest>`, `CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest`, `UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest`, `DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest`, `ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest`. + +- [ ] **Step 1: Create the query** + +Create `mws.application/Accounts/Queries/GetAccounts.cs`: + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Create the CreateAccount command** + +Create `mws.application/Accounts/Commands/CreateAccount.cs`: + +```csharp +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); + } +} +``` + +- [ ] **Step 3: Create the UpdateAccount command** + +Create `mws.application/Accounts/Commands/UpdateAccount.cs`: + +```csharp +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); + } +} +``` + +- [ ] **Step 4: Create the DeleteAccount command** + +Create `mws.application/Accounts/Commands/DeleteAccount.cs`: + +```csharp +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); + } +} +``` + +- [ ] **Step 5: Create the ResetPassword command** + +Create `mws.application/Accounts/Commands/ResetPassword.cs`: + +```csharp +using MediatR; +using Mws.Application.Auth; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Accounts; + +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"; + + public async Task Handle(ResetPasswordCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct); + + if (string.IsNullOrWhiteSpace(command.Request.NewPassword)) + { + throw new BadRequestException("New password is required"); + } + + var user = await uow.Users.GetByIdAsync(command.Id, ct) + ?? throw new NotFoundException("Account not found"); + + user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword); + user.UpdatedAt = DateTime.UtcNow; + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 6: Delete the old service files** + +Delete `mws.application/Accounts/IAccountService.cs` and `mws.application/Accounts/AccountService.cs`. + +- [ ] **Step 7: Update `AccountsController`** + +Replace the full contents of `mws.api/Controllers/AccountsController.cs`: + +```csharp +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(); + } +} +``` + +- [ ] **Step 8: Remove the old DI registration** + +In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped();`. + +- [ ] **Step 9: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 10: Commit** + +```bash +git add mws.application/Accounts mws.api/Controllers/AccountsController.cs mws.infrastructure/DependencyInjection.cs +git commit -m "$(cat <<'EOF' +Convert Accounts module to MediatR commands/queries + +Replaces IAccountService/AccountService with one command/query per +operation, following the pattern set in the Auth module. +EOF +)" +``` + +--- + +### Task 3: Convert Roles module + +**Files:** +- Create: `mws.application/Roles/RolePermissionBuilder.cs` +- Create: `mws.application/Roles/Queries/GetRoles.cs` +- Create: `mws.application/Roles/Queries/GetRole.cs` +- Create: `mws.application/Roles/Commands/CreateRole.cs` +- Create: `mws.application/Roles/Commands/UpdateRole.cs` +- Create: `mws.application/Roles/Commands/DeleteRole.cs` +- Delete: `mws.application/Roles/IRoleService.cs` +- Delete: `mws.application/Roles/RoleService.cs` +- Modify: `mws.api/Controllers/RolesController.cs` +- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped`) + +**Interfaces:** +- Produces: `RolePermissionBuilder.Build(List) : List` — shared by `CreateRoleHandler` and `UpdateRoleHandler`, replacing the old `RoleService.BuildPermissions` private method. +- Produces: `GetRolesQuery : IRequest>`, `GetRoleQuery(Guid Id) : IRequest`, `CreateRoleCommand(SaveRoleRequest Request) : IRequest`, `UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest`, `DeleteRoleCommand(Guid Id) : IRequest`. +- Consumes: `IPermissionService.EnsureAsync` stays called directly from `RolesController`, unchanged — the permission check is NOT moved into the handlers (it wasn't in the old `RoleService` either; it lived in the controller). + +- [ ] **Step 1: Create the shared permission-builder helper** + +Create `mws.application/Roles/RolePermissionBuilder.cs`: + +```csharp +using Mws.Application.Permissions; +using Mws.Domain.Roles; + +namespace Mws.Application.Roles; + +internal static class RolePermissionBuilder +{ + public static List Build(List entries) + { + var byScreen = entries.Where(e => ScreenCatalog.Keys.Contains(e.Screen)).ToDictionary(e => e.Screen); + + return ScreenCatalog.Keys.Select(key => + { + byScreen.TryGetValue(key, out var entry); + return new RolePermission + { + Screen = key, + CanView = entry?.CanView ?? false, + CanCreate = entry?.CanCreate ?? false, + CanEdit = entry?.CanEdit ?? false, + CanDelete = entry?.CanDelete ?? false, + }; + }).ToList(); + } +} +``` + +- [ ] **Step 2: Create the queries** + +Create `mws.application/Roles/Queries/GetRoles.cs`: + +```csharp +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); + } +} +``` + +Create `mws.application/Roles/Queries/GetRole.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Roles; + +public record GetRoleQuery(Guid Id) : IRequest; + +public class GetRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetRoleQuery query, CancellationToken ct) + { + var role = await uow.Roles.GetByIdWithPermissionsAsync(query.Id, ct) + ?? throw new NotFoundException("Role not found"); + return mapper.Map(role); + } +} +``` + +- [ ] **Step 3: Create the CreateRole command** + +Create `mws.application/Roles/Commands/CreateRole.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Roles; + +namespace Mws.Application.Roles; + +public record CreateRoleCommand(SaveRoleRequest Request) : IRequest; + +public class CreateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(CreateRoleCommand command, CancellationToken ct) + { + var name = command.Request.Name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Role name is required"); + } + + if (await uow.Roles.ExistsByNameAsync(name, null, ct)) + { + throw new BadRequestException("Role name already exists"); + } + + var now = DateTime.UtcNow; + var role = new Role + { + Id = Guid.NewGuid(), + Name = name, + IsSystem = false, + CreatedAt = now, + UpdatedAt = now, + Permissions = RolePermissionBuilder.Build(command.Request.Permissions), + }; + + uow.Roles.Add(role); + await uow.SaveChangesAsync(ct); + return mapper.Map(role); + } +} +``` + +- [ ] **Step 4: Create the UpdateRole command** + +Create `mws.application/Roles/Commands/UpdateRole.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Roles; + +public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest; + +public class UpdateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(UpdateRoleCommand command, CancellationToken ct) + { + var role = await uow.Roles.GetByIdWithPermissionsAsync(command.Id, ct) + ?? throw new NotFoundException("Role not found"); + + var name = command.Request.Name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Role name is required"); + } + + if (await uow.Roles.ExistsByNameAsync(name, command.Id, ct)) + { + throw new BadRequestException("Role name already exists"); + } + + role.Name = name; + role.UpdatedAt = DateTime.UtcNow; + + uow.Roles.RemovePermissions(role.Permissions.ToList()); + role.Permissions = RolePermissionBuilder.Build(command.Request.Permissions); + foreach (var p in role.Permissions) + { + p.RoleId = role.Id; + } + + await uow.SaveChangesAsync(ct); + return mapper.Map(role); + } +} +``` + +- [ ] **Step 5: Create the DeleteRole command** + +Create `mws.application/Roles/Commands/DeleteRole.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Roles; + +public record DeleteRoleCommand(Guid Id) : IRequest; + +public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(DeleteRoleCommand command, CancellationToken ct) + { + var role = await uow.Roles.GetByIdAsync(command.Id, ct) + ?? throw new NotFoundException("Role not found"); + + if (role.IsSystem) + { + throw new BadRequestException("Cannot delete a system role"); + } + + if (await uow.Users.ExistsByRoleIdAsync(command.Id, ct)) + { + throw new BadRequestException("Cannot delete a role that is assigned to accounts"); + } + + uow.Roles.Remove(role); + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 6: Delete the old service files** + +Delete `mws.application/Roles/IRoleService.cs` and `mws.application/Roles/RoleService.cs`. + +- [ ] **Step 7: Update `RolesController`** + +Replace the full contents of `mws.api/Controllers/RolesController.cs` (the inline `IPermissionService.EnsureAsync` calls stay exactly as they were): + +```csharp +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(); + } +} +``` + +- [ ] **Step 8: Remove the old DI registration** + +In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped();`. + +- [ ] **Step 9: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 10: Commit** + +```bash +git add mws.application/Roles mws.api/Controllers/RolesController.cs mws.infrastructure/DependencyInjection.cs +git commit -m "$(cat <<'EOF' +Convert Roles module to MediatR commands/queries + +Replaces IRoleService/RoleService. The permission-builder logic moves +to a shared RolePermissionBuilder static helper used by both the +create and update handlers. RolesController keeps its inline +IPermissionService checks unchanged. +EOF +)" +``` + +--- + +### Task 4: Convert Projects module (project CRUD, not membership) + +**Files:** +- Create: `mws.application/Projects/ProjectAccess.cs` +- Create: `mws.application/Projects/Queries/GetProjects.cs` +- Create: `mws.application/Projects/Queries/GetProject.cs` +- Create: `mws.application/Projects/Queries/SearchProjects.cs` +- Create: `mws.application/Projects/Queries/GetProjectOverview.cs` +- Create: `mws.application/Projects/Commands/CreateProject.cs` +- Create: `mws.application/Projects/Commands/UpdateProject.cs` +- Create: `mws.application/Projects/Commands/ArchiveProject.cs` +- Modify: `mws.application/Projects/IProjectService.cs` (remove `IProjectService`, keep `IProjectMemberService` — Task 5 removes it) +- Delete: `mws.application/Projects/ProjectService.cs` +- Modify: `mws.api/Controllers/ProjectsController.cs` +- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped`) + +**Interfaces:** +- Produces: `ProjectAccess.GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) : Task` — shared by `GetProjectHandler`, `UpdateProjectHandler`, `ArchiveProjectHandler`, `GetProjectOverviewHandler`. +- Produces: `GetProjectsQuery(Guid UserId) : IRequest>`, `GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest`, `SearchProjectsQuery(Guid UserId, string Term) : IRequest>`, `GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest`, `CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest`, `UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest`, `ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest`. +- Note: `IProjectMemberService` (Task 5's target) stays in `IProjectService.cs` for this task — do not delete it yet, `ProjectMembersController` still depends on it until Task 5. + +- [ ] **Step 1: Create the shared project-access helper** + +Create `mws.application/Projects/ProjectAccess.cs`: + +```csharp +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +internal static class ProjectAccess +{ + public static async Task GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) + { + return await uow.Projects.GetForUserAsync(userId, projectId, ct) + ?? throw new NotFoundException("Project not found"); + } +} +``` + +- [ ] **Step 2: Create the queries** + +Create `mws.application/Projects/Queries/GetProjects.cs`: + +```csharp +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); + } +} +``` + +Create `mws.application/Projects/Queries/GetProject.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Projects; + +public record GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest; + +public class GetProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetProjectQuery query, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct); + return mapper.Map(project); + } +} +``` + +Create `mws.application/Projects/Queries/SearchProjects.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Projects; + +public record SearchProjectsQuery(Guid UserId, string Term) : IRequest>; + +public class SearchProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(SearchProjectsQuery query, CancellationToken ct) + { + var projects = await uow.Projects.SearchForUserAsync(query.UserId, query.Term, ct); + return mapper.Map>(projects); + } +} +``` + +Create `mws.application/Projects/Queries/GetProjectOverview.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Documents; + +namespace Mws.Application.Projects; + +public record GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest; + +public class GetProjectOverviewHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetProjectOverviewQuery query, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct); + + var memberCount = await uow.Projects.CountMembersAsync(query.ProjectId, ct); + var documentCount = await uow.Documents.CountByTypeForProjectAsync(query.ProjectId, DocumentType.Document, ct); + + var taskCounts = await uow.Tasks.CountByStatusForProjectAsync(query.ProjectId, ct); + var taskCountsByStatus = taskCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value); + + var recentTasks = await uow.Tasks.GetRecentForProjectAsync(query.ProjectId, 5, ct); + var recentDocuments = await uow.Documents.GetRecentForProjectAsync(query.ProjectId, DocumentType.Document, 5, ct); + + return new ProjectOverviewDto + { + Project = mapper.Map(project), + MemberCount = memberCount, + DocumentCount = documentCount, + TaskCountsByStatus = taskCountsByStatus, + RecentTasks = mapper.Map>(recentTasks), + RecentDocuments = mapper.Map>(recentDocuments), + }; + } +} +``` + +- [ ] **Step 3: Create the CreateProject command** + +Create `mws.application/Projects/Commands/CreateProject.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest; + +public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(CreateProjectCommand command, CancellationToken ct) + { + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BadRequestException("Project name is required"); + } + + var now = DateTime.UtcNow; + var project = new Project + { + Id = Guid.NewGuid(), + Name = request.Name.Trim(), + Description = request.Description, + Status = ProjectStatus.Active, + CreatedAt = now, + UpdatedAt = now, + }; + + project.Members.Add(new ProjectMember + { + ProjectId = project.Id, + UserId = command.UserId, + Role = MemberRole.Owner, + }); + + uow.Projects.Add(project); + await uow.SaveChangesAsync(ct); + return mapper.Map(project); + } +} +``` + +- [ ] **Step 4: Create the UpdateProject command** + +Create `mws.application/Projects/Commands/UpdateProject.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest; + +public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(UpdateProjectCommand command, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); + + if (MemberRole.Owner != await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct)) + { + throw new ForbiddenException("Only the project owner can update the project"); + } + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BadRequestException("Project name is required"); + } + + project.Name = request.Name.Trim(); + project.Description = request.Description; + project.Status = request.Status; + project.UpdatedAt = DateTime.UtcNow; + + await uow.SaveChangesAsync(ct); + return mapper.Map(project); + } +} +``` + +- [ ] **Step 5: Create the ArchiveProject command** + +Create `mws.application/Projects/Commands/ArchiveProject.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest; + +public class ArchiveProjectHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(ArchiveProjectCommand command, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); + + if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can archive the project"); + } + + project.Status = ProjectStatus.Archived; + project.UpdatedAt = DateTime.UtcNow; + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 6: Remove `IProjectService` from `IProjectService.cs`, keep `IProjectMemberService`** + +Replace the full contents of `mws.application/Projects/IProjectService.cs`: + +```csharp +namespace Mws.Application.Projects; + +public interface IProjectMemberService +{ + Task> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default); + Task AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default); + Task UpdateMemberDocumentPermissionsAsync(Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default); + Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default); +} +``` + +- [ ] **Step 7: Delete `ProjectService.cs`** + +Delete `mws.application/Projects/ProjectService.cs`. + +- [ ] **Step 8: Update `ProjectsController`** + +Replace the full contents of `mws.api/Controllers/ProjectsController.cs`: + +```csharp +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Projects; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/projects")] +[Authorize] +public class ProjectsController(ISender sender) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(CancellationToken ct) + { + return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId()), ct)); + } + + [HttpGet("search")] + public async Task>> Search([FromQuery] string? q, CancellationToken ct) + { + return Ok(await sender.Send(new SearchProjectsQuery(User.GetUserId(), q ?? string.Empty), ct)); + } + + [HttpGet("{projectId:guid}")] + public async Task> Get(Guid projectId, CancellationToken ct) + { + return Ok(await sender.Send(new GetProjectQuery(User.GetUserId(), projectId), ct)); + } + + [HttpGet("{projectId:guid}/overview")] + public async Task> Overview(Guid projectId, CancellationToken ct) + { + return Ok(await sender.Send(new GetProjectOverviewQuery(User.GetUserId(), projectId), ct)); + } + + [HttpPost] + public async Task> Create([FromBody] CreateProjectRequest request, CancellationToken ct) + { + var result = await sender.Send(new CreateProjectCommand(User.GetUserId(), request), ct); + return CreatedAtAction(nameof(Get), new { projectId = result.Id }, result); + } + + [HttpPut("{projectId:guid}")] + public async Task> Update(Guid projectId, [FromBody] UpdateProjectRequest request, CancellationToken ct) + { + return Ok(await sender.Send(new UpdateProjectCommand(User.GetUserId(), projectId, request), ct)); + } + + [HttpDelete("{projectId:guid}")] + public async Task Delete(Guid projectId, CancellationToken ct) + { + await sender.Send(new ArchiveProjectCommand(User.GetUserId(), projectId), ct); + return NoContent(); + } +} +``` + +- [ ] **Step 9: Remove the old DI registration** + +In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped();`. Leave `services.AddScoped();` in place — Task 5 removes it. + +- [ ] **Step 10: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 11: Commit** + +```bash +git add mws.application/Projects mws.api/Controllers/ProjectsController.cs mws.infrastructure/DependencyInjection.cs +git commit -m "$(cat <<'EOF' +Convert Projects module to MediatR commands/queries + +Replaces IProjectService/ProjectService. IProjectMemberService is +left in place for now — ProjectMembersController still depends on it +until the next task converts it too. +EOF +)" +``` + +--- + +### Task 5: Convert ProjectMembers module + +**Files:** +- Create: `mws.application/Projects/Queries/GetProjectMembers.cs` +- Create: `mws.application/Projects/Commands/AddProjectMember.cs` +- Create: `mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs` +- Create: `mws.application/Projects/Commands/RemoveProjectMember.cs` +- Delete: `mws.application/Projects/IProjectService.cs` (now contains only `IProjectMemberService`, no longer needed) +- Delete: `mws.application/Projects/ProjectMemberService.cs` +- Modify: `mws.api/Controllers/ProjectMembersController.cs` +- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped`) + +**Interfaces:** +- Produces: `GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest>`, `AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest`, `UpdateMemberDocumentPermissionsCommand(Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest`, `RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest`. +- Consumes: `IUnitOfWork.Projects.GetMemberRoleAsync(projectId, userId, ct)` called directly (not wrapped in a helper — it was already a one-line delegate in the old service). + +- [ ] **Step 1: Create the query** + +Create `mws.application/Projects/Queries/GetProjectMembers.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest>; + +public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler> +{ + public async Task> Handle(GetProjectMembersQuery query, CancellationToken ct) + { + var isMember = await uow.Projects.IsMemberAsync(query.ProjectId, query.UserId, ct); + if (!isMember) + { + throw new NotFoundException("Project not found"); + } + + var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, ct); + var permissions = await uow.Projects.GetMemberPermissionsAsync(query.ProjectId, ProjectPermissionScreens.Documents, ct); + + return members.Select(m => + { + permissions.TryGetValue(m.UserId, out var p); + return new ProjectMemberDto + { + UserId = m.UserId, + Username = m.User.Username, + DisplayName = m.User.DisplayName, + Role = m.Role, + CanViewDocuments = m.Role == MemberRole.Owner || (p?.CanView ?? false), + CanCreateDocuments = m.Role == MemberRole.Owner || (p?.CanCreate ?? false), + CanEditDocuments = m.Role == MemberRole.Owner || (p?.CanEdit ?? false), + CanDeleteDocuments = m.Role == MemberRole.Owner || (p?.CanDelete ?? false), + }; + }).ToList(); + } +} +``` + +- [ ] **Step 2: Create the AddProjectMember command** + +Create `mws.application/Projects/Commands/AddProjectMember.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest; + +public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(AddProjectMemberCommand command, CancellationToken ct) + { + if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can add members"); + } + + var request = command.Request; + var user = await uow.Users.GetByIdAsync(request.UserId, ct) + ?? throw new NotFoundException("User not found"); + + var already = await uow.Projects.IsMemberAsync(command.ProjectId, request.UserId, ct); + if (already) + { + throw new BadRequestException("User is already a member of this project"); + } + + var role = request.Role is MemberRole.Owner or MemberRole.Member ? request.Role : MemberRole.Member; + var member = new ProjectMember + { + ProjectId = command.ProjectId, + UserId = request.UserId, + Role = role, + }; + + uow.Projects.AddMember(member); + uow.Projects.AddMemberPermission(new ProjectMemberPermission + { + ProjectId = command.ProjectId, + UserId = request.UserId, + Screen = ProjectPermissionScreens.Documents, + CanView = true, + CanCreate = role == MemberRole.Owner, + CanEdit = role == MemberRole.Owner, + CanDelete = role == MemberRole.Owner, + }); + await uow.SaveChangesAsync(ct); + + return new ProjectMemberDto + { + UserId = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Role = member.Role, + CanViewDocuments = true, + CanCreateDocuments = role == MemberRole.Owner, + CanEditDocuments = role == MemberRole.Owner, + CanDeleteDocuments = role == MemberRole.Owner, + }; + } +} +``` + +- [ ] **Step 3: Create the UpdateMemberDocumentPermissions command** + +Create `mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record UpdateMemberDocumentPermissionsCommand( + Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest; + +public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow) + : IRequestHandler +{ + public async Task Handle(UpdateMemberDocumentPermissionsCommand command, CancellationToken ct) + { + if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can change member permissions"); + } + + var member = await uow.Projects.GetMemberWithUserAsync(command.ProjectId, command.MemberUserId, ct) + ?? throw new NotFoundException("Member not found in project"); + + if (member.Role == MemberRole.Owner) + { + throw new BadRequestException("Owner permissions cannot be changed"); + } + + var permission = await uow.Projects.GetMemberPermissionAsync(command.ProjectId, command.MemberUserId, ProjectPermissionScreens.Documents, ct); + if (permission is null) + { + permission = new ProjectMemberPermission { ProjectId = command.ProjectId, UserId = command.MemberUserId, Screen = ProjectPermissionScreens.Documents }; + uow.Projects.AddMemberPermission(permission); + } + + var request = command.Request; + permission.CanView = request.CanViewDocuments; + permission.CanCreate = request.CanCreateDocuments; + permission.CanEdit = request.CanEditDocuments; + permission.CanDelete = request.CanDeleteDocuments; + await uow.SaveChangesAsync(ct); + + return new ProjectMemberDto + { + UserId = member.UserId, + Username = member.User.Username, + DisplayName = member.User.DisplayName, + Role = member.Role, + CanViewDocuments = permission.CanView, + CanCreateDocuments = permission.CanCreate, + CanEditDocuments = permission.CanEdit, + CanDeleteDocuments = permission.CanDelete, + }; + } +} +``` + +- [ ] **Step 4: Create the RemoveProjectMember command** + +Create `mws.application/Projects/Commands/RemoveProjectMember.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest; + +public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(RemoveProjectMemberCommand command, CancellationToken ct) + { + if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can remove members"); + } + + var member = await uow.Projects.GetMemberAsync(command.ProjectId, command.MemberUserId, ct) + ?? throw new NotFoundException("Member not found in project"); + + var owners = await uow.Projects.CountOwnersAsync(command.ProjectId, ct); + + if (member.Role == MemberRole.Owner && owners <= 1) + { + throw new BadRequestException("Cannot remove the last owner of the project"); + } + + uow.Projects.RemoveMember(member); + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 5: Delete the old service files** + +Delete `mws.application/Projects/IProjectService.cs` (it now contains only `IProjectMemberService`, which is fully replaced) and `mws.application/Projects/ProjectMemberService.cs`. + +- [ ] **Step 6: Update `ProjectMembersController`** + +Replace the full contents of `mws.api/Controllers/ProjectMembersController.cs`: + +```csharp +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Projects; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/projects/{projectId:guid}/members")] +[Authorize] +public class ProjectMembersController(ISender sender) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(Guid projectId, CancellationToken ct) + { + return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId), ct)); + } + + [HttpPost] + public async Task> Add(Guid projectId, [FromBody] AddMemberRequest request, CancellationToken ct) + { + return Ok(await sender.Send(new AddProjectMemberCommand(User.GetUserId(), projectId, request), ct)); + } + + [HttpPut("{userId:guid}/document-permissions")] + public async Task> UpdateDocumentPermissions( + Guid projectId, Guid userId, [FromBody] UpdateMemberDocumentPermissionsRequest request, CancellationToken ct) + { + return Ok(await sender.Send(new UpdateMemberDocumentPermissionsCommand(User.GetUserId(), projectId, userId, request), ct)); + } + + [HttpDelete("{userId:guid}")] + public async Task Remove(Guid projectId, Guid userId, CancellationToken ct) + { + await sender.Send(new RemoveProjectMemberCommand(User.GetUserId(), projectId, userId), ct); + return NoContent(); + } +} +``` + +- [ ] **Step 7: Remove the old DI registration** + +In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped();`. + +- [ ] **Step 8: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 9: Commit** + +```bash +git add mws.application/Projects mws.api/Controllers/ProjectMembersController.cs mws.infrastructure/DependencyInjection.cs +git commit -m "$(cat <<'EOF' +Convert ProjectMembers module to MediatR commands/queries + +Replaces IProjectMemberService/ProjectMemberService, completing the +Projects module conversion. +EOF +)" +``` + +--- + +### Task 6: Convert Documents module + +**Files:** +- Create: `mws.application/Documents/DocumentAccess.cs` +- Create: `mws.application/Documents/Queries/GetDocumentTree.cs` +- Create: `mws.application/Documents/Queries/GetDocument.cs` +- Create: `mws.application/Documents/Queries/SearchDocuments.cs` +- Create: `mws.application/Documents/Commands/CreateDocument.cs` +- Create: `mws.application/Documents/Commands/UpdateDocument.cs` +- Create: `mws.application/Documents/Commands/MoveDocument.cs` +- Create: `mws.application/Documents/Commands/DeleteDocument.cs` +- Delete: `mws.application/Documents/IDocumentService.cs` +- Delete: `mws.application/Documents/DocumentService.cs` +- Modify: `mws.api/Controllers/DocumentsController.cs` +- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped`) + +**Interfaces:** +- Produces: `DocumentAccess.EnsureDocumentPermissionAsync(uow, userId, projectId, action, ct)`, `DocumentAccess.HasDocumentPermissionAsync(uow, member, action, ct) : Task`, `DocumentAccess.GetDocumentForUserAsync(uow, userId, documentId, ct) : Task`, `DocumentAccess.DeleteDescendantsAsync(uow, parentId, ct)` — shared across every Documents handler below. +- Produces: `GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest>`, `GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest`, `SearchDocumentsQuery(Guid UserId, string Term) : IRequest>`, `CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest`, `UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest`, `MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest`, `DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest`. + +- [ ] **Step 1: Create the shared document-access helper** + +Create `mws.application/Documents/DocumentAccess.cs`: + +```csharp +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Documents; +using Mws.Domain.Projects; + +namespace Mws.Application.Documents; + +internal static class DocumentAccess +{ + public static async Task EnsureDocumentPermissionAsync( + IUnitOfWork uow, Guid userId, Guid projectId, PermissionAction action, CancellationToken ct) + { + var member = await uow.Projects.GetMemberAsync(projectId, userId, ct) + ?? throw new NotFoundException("Project not found"); + + if (!await HasDocumentPermissionAsync(uow, member, action, ct)) + { + throw new ForbiddenException("You do not have permission to access documents in this project"); + } + } + + public static async Task HasDocumentPermissionAsync( + IUnitOfWork uow, ProjectMember member, PermissionAction action, CancellationToken ct) + { + if (member.Role == MemberRole.Owner) + { + return true; + } + + var permission = await uow.Projects.GetMemberPermissionAsync(member.ProjectId, member.UserId, ProjectPermissionScreens.Documents, ct); + + return action switch + { + PermissionAction.View => permission?.CanView ?? false, + PermissionAction.Create => permission?.CanCreate ?? false, + PermissionAction.Edit => permission?.CanEdit ?? false, + PermissionAction.Delete => permission?.CanDelete ?? false, + _ => false, + }; + } + + public static async Task GetDocumentForUserAsync(IUnitOfWork uow, Guid userId, Guid documentId, CancellationToken ct) + { + var doc = await uow.Documents.GetByIdAsync(documentId, ct) + ?? throw new NotFoundException("Document not found"); + + var member = await uow.Projects.GetMemberAsync(doc.ProjectId, userId, ct); + + if (member is null) + { + throw new NotFoundException("Document not found"); + } + + if (!await HasDocumentPermissionAsync(uow, member, PermissionAction.View, ct)) + { + throw new ForbiddenException("You do not have permission to view this document"); + } + + return doc; + } + + public static async Task DeleteDescendantsAsync(IUnitOfWork uow, Guid parentId, CancellationToken ct) + { + var children = await uow.Documents.GetChildrenAsync(parentId, ct); + foreach (var child in children) + { + await DeleteDescendantsAsync(uow, child.Id, ct); + uow.Documents.Remove(child); + } + } +} +``` + +- [ ] **Step 2: Create the queries** + +Create `mws.application/Documents/Queries/GetDocumentTree.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Documents; + +public record GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest>; + +public class GetDocumentTreeHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetDocumentTreeQuery query, CancellationToken ct) + { + await DocumentAccess.EnsureDocumentPermissionAsync(uow, query.UserId, query.ProjectId, PermissionAction.View, ct); + + var docs = await uow.Documents.GetTreeForProjectAsync(query.ProjectId, ct); + + var nodes = docs.ToDictionary(d => d.Id, d => mapper.Map(d)); + + var roots = new List(); + foreach (var node in nodes.Values) + { + if (node.ParentId is { } parentId && nodes.TryGetValue(parentId, out var parent)) + { + parent.Children.Add(node); + } + else + { + roots.Add(node); + } + } + + return roots; + } +} +``` + +Create `mws.application/Documents/Queries/GetDocument.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Documents; + +public record GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest; + +public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetDocumentQuery query, CancellationToken ct) + { + var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct); + return mapper.Map(doc); + } +} +``` + +Create `mws.application/Documents/Queries/SearchDocuments.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Documents; + +public record SearchDocumentsQuery(Guid UserId, string Term) : IRequest>; + +public class SearchDocumentsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(SearchDocumentsQuery query, CancellationToken ct) + { + var projectIds = await uow.Projects.GetDocumentViewableProjectIdsAsync(query.UserId, ct); + var results = await uow.Documents.SearchAsync(projectIds, query.Term, 20, ct); + return mapper.Map>(results); + } +} +``` + +- [ ] **Step 3: Create the CreateDocument command** + +Create `mws.application/Documents/Commands/CreateDocument.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Documents; + +namespace Mws.Application.Documents; + +public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest; + +public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(CreateDocumentCommand command, CancellationToken ct) + { + await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, command.ProjectId, PermissionAction.Create, ct); + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Title is required"); + } + + if (request.ParentId is { } parentId) + { + var parent = await uow.Documents.GetInProjectAsync(parentId, command.ProjectId, ct) + ?? throw new BadRequestException("Parent document not found"); + if (parent.Type != DocumentType.Folder) + { + throw new BadRequestException("Parent must be a folder"); + } + } + + if (request.Type == DocumentType.Folder && !string.IsNullOrWhiteSpace(request.Content)) + { + throw new BadRequestException("Folders cannot have content"); + } + + var now = DateTime.UtcNow; + var doc = new Document + { + Id = Guid.NewGuid(), + ProjectId = command.ProjectId, + ParentId = request.ParentId, + Title = request.Title.Trim(), + Type = request.Type, + Content = request.Type == DocumentType.Document ? request.Content : null, + CreatedBy = command.UserId, + CreatedAt = now, + UpdatedBy = command.UserId, + UpdatedAt = now, + }; + + uow.Documents.Add(doc); + await uow.SaveChangesAsync(ct); + return mapper.Map(doc); + } +} +``` + +- [ ] **Step 4: Create the UpdateDocument command** + +Create `mws.application/Documents/Commands/UpdateDocument.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Documents; + +namespace Mws.Application.Documents; + +public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest; + +public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(UpdateDocumentCommand command, CancellationToken ct) + { + var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct); + await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Edit, ct); + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Title is required"); + } + + doc.Title = request.Title.Trim(); + if (doc.Type == DocumentType.Document) + { + doc.Content = request.Content; + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = command.UserId; + } + else + { + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = command.UserId; + } + + await uow.SaveChangesAsync(ct); + return mapper.Map(doc); + } +} +``` + +- [ ] **Step 5: Create the MoveDocument command** + +Create `mws.application/Documents/Commands/MoveDocument.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Documents; + +namespace Mws.Application.Documents; + +public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest; + +public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(MoveDocumentCommand command, CancellationToken ct) + { + var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct); + await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Edit, ct); + + if (command.NewParentId == command.DocumentId) + { + throw new BadRequestException("A document cannot be moved into itself"); + } + + if (command.NewParentId is { } parentId) + { + var parent = await uow.Documents.GetByIdAsync(parentId, ct) + ?? throw new NotFoundException("Parent folder not found"); + if (parent.ProjectId != doc.ProjectId) + { + throw new BadRequestException("Parent must belong to the same project"); + } + if (parent.Type != DocumentType.Folder) + { + throw new BadRequestException("Parent must be a folder"); + } + + var cursor = parent.ParentId; + while (cursor is not null) + { + if (cursor == command.DocumentId) + { + throw new BadRequestException("A folder cannot be moved into its own descendant"); + } + cursor = await uow.Documents.GetParentIdAsync(cursor.Value, ct); + } + } + + doc.ParentId = command.NewParentId; + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = command.UserId; + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 6: Create the DeleteDocument command** + +Create `mws.application/Documents/Commands/DeleteDocument.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Documents; + +public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest; + +public class DeleteDocumentHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(DeleteDocumentCommand command, CancellationToken ct) + { + var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct); + await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Delete, ct); + + await DocumentAccess.DeleteDescendantsAsync(uow, command.DocumentId, ct); + uow.Documents.Remove(doc); + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 7: Delete the old service files** + +Delete `mws.application/Documents/IDocumentService.cs` and `mws.application/Documents/DocumentService.cs`. + +- [ ] **Step 8: Update `DocumentsController`** + +Replace the full contents of `mws.api/Controllers/DocumentsController.cs`: + +```csharp +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Documents; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api")] +[Authorize] +public class DocumentsController(ISender sender) : ControllerBase +{ + [HttpGet("documents/search")] + public async Task>> Search([FromQuery] string? q, CancellationToken ct) + { + return Ok(await sender.Send(new SearchDocumentsQuery(User.GetUserId(), q ?? string.Empty), ct)); + } + + [HttpGet("projects/{projectId:guid}/documents")] + public async Task>> GetTree(Guid projectId, CancellationToken ct) + { + return Ok(await sender.Send(new GetDocumentTreeQuery(User.GetUserId(), projectId), ct)); + } + + [HttpPost("projects/{projectId:guid}/documents")] + public async Task> Create(Guid projectId, [FromBody] CreateDocumentRequest request, CancellationToken ct) + { + var result = await sender.Send(new CreateDocumentCommand(User.GetUserId(), projectId, request), ct); + return CreatedAtAction(nameof(Get), new { id = result.Id }, result); + } + + [HttpGet("documents/{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct)); + } + + [HttpPut("documents/{id:guid}")] + public async Task> Update(Guid id, [FromBody] UpdateDocumentRequest request, CancellationToken ct) + { + return Ok(await sender.Send(new UpdateDocumentCommand(User.GetUserId(), id, request), ct)); + } + + [HttpPut("documents/{id:guid}/move")] + public async Task> Move(Guid id, [FromBody] MoveDocumentRequest request, CancellationToken ct) + { + await sender.Send(new MoveDocumentCommand(User.GetUserId(), id, request.NewParentId), ct); + return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct)); + } + + [HttpDelete("documents/{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await sender.Send(new DeleteDocumentCommand(User.GetUserId(), id), ct); + return NoContent(); + } +} +``` + +- [ ] **Step 9: Remove the old DI registration** + +In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped();`. + +- [ ] **Step 10: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 11: Commit** + +```bash +git add mws.application/Documents mws.api/Controllers/DocumentsController.cs mws.infrastructure/DependencyInjection.cs +git commit -m "$(cat <<'EOF' +Convert Documents module to MediatR commands/queries + +Replaces IDocumentService/DocumentService. The permission-check and +descendant-deletion logic moves to a shared DocumentAccess static +helper used by all seven handlers. +EOF +)" +``` + +--- + +### Task 7: Convert Tasks module + +**Files:** +- Create: `mws.application/Tasks/TaskAccess.cs` +- Create: `mws.application/Tasks/Queries/GetTasks.cs` +- Create: `mws.application/Tasks/Queries/GetTask.cs` +- Create: `mws.application/Tasks/Queries/SearchTasks.cs` +- Create: `mws.application/Tasks/Commands/CreateTask.cs` +- Create: `mws.application/Tasks/Commands/UpdateTask.cs` +- Create: `mws.application/Tasks/Commands/DeleteTask.cs` +- Delete: `mws.application/Tasks/ITaskService.cs` +- Delete: `mws.application/Tasks/TaskService.cs` +- Modify: `mws.api/Controllers/TasksController.cs` +- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped`) + +**Interfaces:** +- Produces: `TaskAccess.GetDtoAsync(uow, mapper, id, ct) : Task`, `TaskAccess.GetTaskForUserAsync(uow, userId, taskId, ct) : Task`, `TaskAccess.EnsureMemberAccessAsync(uow, userId, projectId, ct)` — shared across the handlers below. +- Produces: `GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId) : IRequest>`, `GetTaskQuery(Guid UserId, Guid TaskId) : IRequest`, `SearchTasksQuery(Guid UserId, string Term) : IRequest>`, `CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest`, `UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest`, `DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest`. + +- [ ] **Step 1: Create the shared task-access helper** + +Create `mws.application/Tasks/TaskAccess.cs`: + +```csharp +using AutoMapper; +using Mws.Application.Common; +using Mws.Domain.Tasks; + +namespace Mws.Application.Tasks; + +internal static class TaskAccess +{ + public static async Task GetDtoAsync(IUnitOfWork uow, IMapper mapper, Guid id, CancellationToken ct) + { + var task = await uow.Tasks.GetWithAssigneeAsync(id, ct) + ?? throw new NotFoundException("Task not found"); + return mapper.Map(task); + } + + public static async Task GetTaskForUserAsync(IUnitOfWork uow, Guid userId, Guid taskId, CancellationToken ct) + { + var task = await uow.Tasks.GetByIdAsync(taskId, ct) + ?? throw new NotFoundException("Task not found"); + + if (!await uow.Projects.IsMemberAsync(task.ProjectId, userId, ct)) + { + throw new NotFoundException("Task not found"); + } + return task; + } + + public static async Task EnsureMemberAccessAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) + { + if (!await uow.Projects.IsMemberAsync(projectId, userId, ct)) + { + throw new NotFoundException("Project not found"); + } + } +} +``` + +- [ ] **Step 2: Create the queries** + +Create `mws.application/Tasks/Queries/GetTasks.cs`: + +```csharp +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); + } +} +``` + +Create `mws.application/Tasks/Queries/GetTask.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record GetTaskQuery(Guid UserId, Guid TaskId) : IRequest; + +public class GetTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetTaskQuery query, CancellationToken ct) + { + var task = await TaskAccess.GetTaskForUserAsync(uow, query.UserId, query.TaskId, ct); + return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct); + } +} +``` + +Create `mws.application/Tasks/Queries/SearchTasks.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record SearchTasksQuery(Guid UserId, string Term) : IRequest>; + +public class SearchTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(SearchTasksQuery query, CancellationToken ct) + { + var projectIds = await uow.Projects.GetProjectIdsForUserAsync(query.UserId, ct); + var tasks = await uow.Tasks.SearchWithAssigneeAsync(projectIds, query.Term, 20, ct); + return mapper.Map>(tasks); + } +} +``` + +- [ ] **Step 3: Create the CreateTask command** + +Create `mws.application/Tasks/Commands/CreateTask.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Tasks; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.Application.Tasks; + +public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest; + +public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(CreateTaskCommand command, CancellationToken ct) + { + await TaskAccess.EnsureMemberAccessAsync(uow, command.UserId, command.ProjectId, ct); + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Task title is required"); + } + + if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(command.ProjectId, request.AssigneeId.Value, ct)) + { + throw new BadRequestException("Assignee must be a member of the project"); + } + + var now = DateTime.UtcNow; + var task = new TaskItem + { + Id = Guid.NewGuid(), + ProjectId = command.ProjectId, + Title = request.Title.Trim(), + Description = request.Description, + Status = request.Status ?? TaskStatus.Todo, + Priority = request.Priority ?? TaskPriority.Medium, + AssigneeId = request.AssigneeId, + DueDate = request.DueDate, + CreatedBy = command.UserId, + CreatedAt = now, + UpdatedAt = now, + }; + + uow.Tasks.Add(task); + await uow.SaveChangesAsync(ct); + return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct); + } +} +``` + +- [ ] **Step 4: Create the UpdateTask command** + +Create `mws.application/Tasks/Commands/UpdateTask.cs`: + +```csharp +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest; + +public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(UpdateTaskCommand command, CancellationToken ct) + { + var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct); + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Task title is required"); + } + + if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(task.ProjectId, request.AssigneeId.Value, ct)) + { + throw new BadRequestException("Assignee must be a member of the project"); + } + + task.Title = request.Title.Trim(); + task.Description = request.Description; + task.Status = request.Status; + task.Priority = request.Priority; + task.AssigneeId = request.AssigneeId; + task.DueDate = request.DueDate; + task.UpdatedAt = DateTime.UtcNow; + + await uow.SaveChangesAsync(ct); + return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct); + } +} +``` + +- [ ] **Step 5: Create the DeleteTask command** + +Create `mws.application/Tasks/Commands/DeleteTask.cs`: + +```csharp +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest; + +public class DeleteTaskHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(DeleteTaskCommand command, CancellationToken ct) + { + var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct); + uow.Tasks.Remove(task); + await uow.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 6: Delete the old service files** + +Delete `mws.application/Tasks/ITaskService.cs` and `mws.application/Tasks/TaskService.cs`. + +- [ ] **Step 7: Update `TasksController`** + +Replace the full contents of `mws.api/Controllers/TasksController.cs`: + +```csharp +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; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api")] +[Authorize] +public class TasksController(ISender sender) : ControllerBase +{ + [HttpGet("tasks/search")] + public async Task>> Search([FromQuery] string? q, CancellationToken ct) + { + return Ok(await sender.Send(new SearchTasksQuery(User.GetUserId(), q ?? string.Empty), ct)); + } + + [HttpGet("projects/{projectId:guid}/tasks")] + public async Task>> GetAll( + Guid projectId, + [FromQuery] string? status, + [FromQuery] string? priority, + [FromQuery] Guid? assigneeId, + CancellationToken ct) + { + var statusValue = ParseOptional(status); + var priorityValue = ParseOptional(priority); + return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId), ct)); + } + + [HttpPost("projects/{projectId:guid}/tasks")] + public async Task> Create(Guid projectId, [FromBody] CreateTaskRequest request, CancellationToken ct) + { + var result = await sender.Send(new CreateTaskCommand(User.GetUserId(), projectId, request), ct); + return CreatedAtAction(nameof(Get), new { id = result.Id }, result); + } + + [HttpGet("tasks/{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + return Ok(await sender.Send(new GetTaskQuery(User.GetUserId(), id), ct)); + } + + [HttpPut("tasks/{id:guid}")] + public async Task> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) + { + return Ok(await sender.Send(new UpdateTaskCommand(User.GetUserId(), id, request), ct)); + } + + [HttpDelete("tasks/{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await sender.Send(new DeleteTaskCommand(User.GetUserId(), id), ct); + return NoContent(); + } + + private static TEnum? ParseOptional(string? value) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + return Enum.TryParse(value, ignoreCase: true, out var result) ? result : throw new BadRequestException($"Invalid {typeof(TEnum).Name}: {value}"); + } +} +``` + +- [ ] **Step 8: Remove the old DI registration** + +In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped();`. + +- [ ] **Step 9: Build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 10: Commit** + +```bash +git add mws.application/Tasks mws.api/Controllers/TasksController.cs mws.infrastructure/DependencyInjection.cs +git commit -m "$(cat <<'EOF' +Convert Tasks module to MediatR commands/queries + +Replaces ITaskService/TaskService, completing the CQRS/mediator +refactor. All 9 old service interfaces are now gone except +IPermissionService, ITokenService, and IPasswordHasher, which stay +plain injected services by design (see spec). +EOF +)" +``` + +--- + +### Task 8: Final verification + +**Files:** none (verification only). + +**Interfaces:** none — this task exercises the full request pipeline end to end. + +- [ ] **Step 1: Confirm no old service interfaces remain** + +Run: `grep -rl "IAuthService\|IAccountService\|IRoleService\|IProjectService\|IProjectMemberService\|IDocumentService\|ITaskService" mws.application mws.api mws.infrastructure` +Expected: no output (empty match). If anything matches, it's a leftover reference from an earlier task — fix it before continuing. + +- [ ] **Step 2: Full solution build** + +Run: `dotnet build mws.backend.dotnet.sln` +Expected: `Build succeeded`, `0 Error(s)` (existing 2 nullable warnings in `Program.cs` are pre-existing and unrelated — fine to see those, not new ones). + +- [ ] **Step 3: Start the API** + +Run: `dotnet run --project mws.api` (in the background, or a separate terminal) — it auto-migrates and seeds on startup per `CLAUDE.md`. Wait for `Now listening on: http://localhost:5xxx` (check `mws.api/Properties/launchSettings.json` for the exact port). + +- [ ] **Step 4: Smoke-test the command path — login** + +Run: `curl -s -X POST http://localhost:/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"password"}'` +Expected: JSON body with a `token` field and a `user` object — confirms `LoginCommand`/`LoginHandler` and the MediatR DI wiring both work. + +- [ ] **Step 5: Smoke-test a query + a command that goes through `IUnitOfWork` — projects** + +Using the token from Step 4: +```bash +TOKEN="" +curl -s http://localhost:/api/projects -H "Authorization: Bearer $TOKEN" +curl -s -X POST http://localhost:/api/projects -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name":"CQRS smoke test"}' +``` +Expected: first call returns the seeded demo project as a JSON array; second call returns 200 with the new project's DTO — confirms `GetProjectsQuery` and `CreateProjectCommand` both round-trip through EF Core correctly. + +- [ ] **Step 6: Smoke-test the untouched permission path — menu** + +Run: `curl -s http://localhost:/api/menu -H "Authorization: Bearer $TOKEN"` +Expected: JSON array of menu items with CRUD flags — confirms `IPermissionService` (left as a plain injected service) still resolves and works alongside the new MediatR-based handlers. + +- [ ] **Step 7: Stop the API** + +Stop the `dotnet run` process (Ctrl-C, or kill the background job). + +No commit for this task — it's verification only, nothing changed. diff --git a/mws.api/ClaimsExtensions.cs b/mws.api/ClaimsExtensions.cs new file mode 100644 index 0000000..7d35d34 --- /dev/null +++ b/mws.api/ClaimsExtensions.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Mws.Api; + +public static class ClaimsExtensions +{ + public static Guid GetUserId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub"); + if (value is not null && Guid.TryParse(value, out var id)) + { + return id; + } + throw new UnauthorizedAccessException("Invalid user identity"); + } +} \ No newline at end of file diff --git a/mws.api/Controllers/AccountsController.cs b/mws.api/Controllers/AccountsController.cs new file mode 100644 index 0000000..2416612 --- /dev/null +++ b/mws.api/Controllers/AccountsController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Accounts; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/accounts")] +[Authorize] +public class AccountsController(IAccountService accountService) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll([FromQuery] string? q, CancellationToken ct) + { + return Ok(await accountService.GetAccountsAsync(User.GetUserId(), q, ct)); + } + + [HttpPost] + public async Task> Create([FromBody] CreateAccountRequest request, CancellationToken ct) + { + return Ok(await accountService.CreateAccountAsync(User.GetUserId(), request, ct)); + } + + [HttpPut("{id:guid}")] + public async Task> Update(Guid id, [FromBody] UpdateAccountRequest request, CancellationToken ct) + { + return Ok(await accountService.UpdateAccountAsync(User.GetUserId(), id, request, ct)); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await accountService.DeleteAccountAsync(User.GetUserId(), id, ct); + return NoContent(); + } + + [HttpPost("{id:guid}/reset-password")] + public async Task ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct) + { + await accountService.ResetPasswordAsync(User.GetUserId(), id, request, ct); + return NoContent(); + } +} diff --git a/mws.api/Controllers/AuthController.cs b/mws.api/Controllers/AuthController.cs new file mode 100644 index 0000000..79c62e9 --- /dev/null +++ b/mws.api/Controllers/AuthController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Auth; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/auth")] +[AllowAnonymous] +public class AuthController(IAuthService authService) : ControllerBase +{ + [HttpPost("login")] + public async Task> Login([FromBody] LoginRequest request, CancellationToken ct) + { + var response = await authService.LoginAsync(request, ct); + return Ok(response); + } +} \ No newline at end of file diff --git a/mws.api/Controllers/DocumentsController.cs b/mws.api/Controllers/DocumentsController.cs new file mode 100644 index 0000000..7b4d24f --- /dev/null +++ b/mws.api/Controllers/DocumentsController.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Documents; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api")] +[Authorize] +public class DocumentsController(IDocumentService documentService) : ControllerBase +{ + [HttpGet("documents/search")] + public async Task>> Search([FromQuery] string? q, CancellationToken ct) + { + return Ok(await documentService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct)); + } + + [HttpGet("projects/{projectId:guid}/documents")] + public async Task>> GetTree(Guid projectId, CancellationToken ct) + { + return Ok(await documentService.GetTreeAsync(User.GetUserId(), projectId, ct)); + } + + [HttpPost("projects/{projectId:guid}/documents")] + public async Task> Create(Guid projectId, [FromBody] CreateDocumentRequest request, CancellationToken ct) + { + var result = await documentService.CreateAsync(User.GetUserId(), projectId, request, ct); + return CreatedAtAction(nameof(Get), new { id = result.Id }, result); + } + + [HttpGet("documents/{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + return Ok(await documentService.GetAsync(User.GetUserId(), id, ct)); + } + + [HttpPut("documents/{id:guid}")] + public async Task> Update(Guid id, [FromBody] UpdateDocumentRequest request, CancellationToken ct) + { + return Ok(await documentService.UpdateAsync(User.GetUserId(), id, request, ct)); + } + + [HttpPut("documents/{id:guid}/move")] + public async Task> Move(Guid id, [FromBody] MoveDocumentRequest request, CancellationToken ct) + { + await documentService.MoveAsync(User.GetUserId(), id, request.NewParentId, ct); + return Ok(await documentService.GetAsync(User.GetUserId(), id, ct)); + } + + [HttpDelete("documents/{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await documentService.DeleteAsync(User.GetUserId(), id, ct); + return NoContent(); + } +} \ No newline at end of file diff --git a/mws.api/Controllers/MenuController.cs b/mws.api/Controllers/MenuController.cs new file mode 100644 index 0000000..ef03ee3 --- /dev/null +++ b/mws.api/Controllers/MenuController.cs @@ -0,0 +1,17 @@ +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/ProjectMembersController.cs b/mws.api/Controllers/ProjectMembersController.cs new file mode 100644 index 0000000..afa80c5 --- /dev/null +++ b/mws.api/Controllers/ProjectMembersController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Projects; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/projects/{projectId:guid}/members")] +[Authorize] +public class ProjectMembersController(IProjectMemberService memberService) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(Guid projectId, CancellationToken ct) + { + return Ok(await memberService.GetMembersAsync(User.GetUserId(), projectId, ct)); + } + + [HttpPost] + public async Task> Add(Guid projectId, [FromBody] AddMemberRequest request, CancellationToken ct) + { + return Ok(await memberService.AddMemberAsync(User.GetUserId(), projectId, request, ct)); + } + + [HttpPut("{userId:guid}/document-permissions")] + public async Task> UpdateDocumentPermissions( + Guid projectId, Guid userId, [FromBody] UpdateMemberDocumentPermissionsRequest request, CancellationToken ct) + { + return Ok(await memberService.UpdateMemberDocumentPermissionsAsync(User.GetUserId(), projectId, userId, request, ct)); + } + + [HttpDelete("{userId:guid}")] + public async Task Remove(Guid projectId, Guid userId, CancellationToken ct) + { + await memberService.RemoveMemberAsync(User.GetUserId(), projectId, userId, ct); + return NoContent(); + } +} \ No newline at end of file diff --git a/mws.api/Controllers/ProjectsController.cs b/mws.api/Controllers/ProjectsController.cs new file mode 100644 index 0000000..25f91b9 --- /dev/null +++ b/mws.api/Controllers/ProjectsController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mws.Application.Projects; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api/projects")] +[Authorize] +public class ProjectsController(IProjectService projectService) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(CancellationToken ct) + { + return Ok(await projectService.GetProjectsAsync(User.GetUserId(), ct)); + } + + [HttpGet("search")] + public async Task>> Search([FromQuery] string? q, CancellationToken ct) + { + return Ok(await projectService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct)); + } + + [HttpGet("{projectId:guid}")] + public async Task> Get(Guid projectId, CancellationToken ct) + { + return Ok(await projectService.GetProjectAsync(User.GetUserId(), projectId, ct)); + } + + [HttpGet("{projectId:guid}/overview")] + public async Task> Overview(Guid projectId, CancellationToken ct) + { + return Ok(await projectService.GetOverviewAsync(User.GetUserId(), projectId, ct)); + } + + [HttpPost] + public async Task> Create([FromBody] CreateProjectRequest request, CancellationToken ct) + { + var result = await projectService.CreateProjectAsync(User.GetUserId(), request, ct); + return CreatedAtAction(nameof(Get), new { projectId = result.Id }, result); + } + + [HttpPut("{projectId:guid}")] + public async Task> Update(Guid projectId, [FromBody] UpdateProjectRequest request, CancellationToken ct) + { + return Ok(await projectService.UpdateProjectAsync(User.GetUserId(), projectId, request, ct)); + } + + [HttpDelete("{projectId:guid}")] + public async Task Delete(Guid projectId, CancellationToken ct) + { + await projectService.ArchiveProjectAsync(User.GetUserId(), projectId, ct); + return NoContent(); + } +} \ No newline at end of file diff --git a/mws.api/Controllers/RolesController.cs b/mws.api/Controllers/RolesController.cs new file mode 100644 index 0000000..a89e36e --- /dev/null +++ b/mws.api/Controllers/RolesController.cs @@ -0,0 +1,48 @@ +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(IRoleService roleService, IPermissionService permissions) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(CancellationToken ct) + { + await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct); + return Ok(await roleService.GetRolesAsync(ct)); + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct); + return Ok(await roleService.GetRoleAsync(id, ct)); + } + + [HttpPost] + public async Task> Create([FromBody] SaveRoleRequest request, CancellationToken ct) + { + await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Create, ct); + return Ok(await roleService.CreateRoleAsync(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 roleService.UpdateRoleAsync(id, request, ct)); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Delete, ct); + await roleService.DeleteRoleAsync(id, ct); + return NoContent(); + } +} diff --git a/mws.api/Controllers/TasksController.cs b/mws.api/Controllers/TasksController.cs new file mode 100644 index 0000000..3be77b3 --- /dev/null +++ b/mws.api/Controllers/TasksController.cs @@ -0,0 +1,68 @@ +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; + +namespace Mws.Api.Controllers; + +[ApiController] +[Route("api")] +[Authorize] +public class TasksController(ITaskService taskService) : ControllerBase +{ + [HttpGet("tasks/search")] + public async Task>> Search([FromQuery] string? q, CancellationToken ct) + { + return Ok(await taskService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct)); + } + + [HttpGet("projects/{projectId:guid}/tasks")] + public async Task>> GetAll( + Guid projectId, + [FromQuery] string? status, + [FromQuery] string? priority, + [FromQuery] Guid? assigneeId, + CancellationToken ct) + { + var statusValue = ParseOptional(status); + var priorityValue = ParseOptional(priority); + return Ok(await taskService.GetTasksAsync(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId, ct)); + } + + [HttpPost("projects/{projectId:guid}/tasks")] + public async Task> Create(Guid projectId, [FromBody] CreateTaskRequest request, CancellationToken ct) + { + var result = await taskService.CreateAsync(User.GetUserId(), projectId, request, ct); + return CreatedAtAction(nameof(Get), new { id = result.Id }, result); + } + + [HttpGet("tasks/{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + return Ok(await taskService.GetAsync(User.GetUserId(), id, ct)); + } + + [HttpPut("tasks/{id:guid}")] + public async Task> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) + { + return Ok(await taskService.UpdateAsync(User.GetUserId(), id, request, ct)); + } + + [HttpDelete("tasks/{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await taskService.DeleteAsync(User.GetUserId(), id, ct); + return NoContent(); + } + + private static TEnum? ParseOptional(string? value) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + return Enum.TryParse(value, ignoreCase: true, out var result) ? result : throw new BadRequestException($"Invalid {typeof(TEnum).Name}: {value}"); + } +} \ No newline at end of file diff --git a/mws.api/Controllers/UsersController.cs b/mws.api/Controllers/UsersController.cs new file mode 100644 index 0000000..f056a04 --- /dev/null +++ b/mws.api/Controllers/UsersController.cs @@ -0,0 +1,20 @@ +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/Middleware/ApiExceptionMiddleware.cs b/mws.api/Middleware/ApiExceptionMiddleware.cs new file mode 100644 index 0000000..46d03d1 --- /dev/null +++ b/mws.api/Middleware/ApiExceptionMiddleware.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Mws.Application.Common; + +namespace Mws.Api.Middleware; + +public class ApiExceptionMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + await HandleAsync(context, ex); + } + } + + private async Task HandleAsync(HttpContext context, Exception ex) + { + var (statusCode, message) = ex switch + { + BadRequestException => (StatusCodes.Status400BadRequest, ex.Message), + UnauthorizedException => (StatusCodes.Status401Unauthorized, ex.Message), + ForbiddenException => (StatusCodes.Status403Forbidden, ex.Message), + NotFoundException => (StatusCodes.Status404NotFound, ex.Message), + _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred"), + }; + + if (statusCode == StatusCodes.Status500InternalServerError) + { + logger.LogError(ex, "Unhandled exception in {Path}", context.Request.Path); + } + + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new { message })); + } +} + +public static class ApiExceptionMiddlewareExtensions +{ + public static IApplicationBuilder UseApiExceptionMiddleware(this IApplicationBuilder app) + => app.UseMiddleware(); +} \ No newline at end of file diff --git a/mws.api/Program.cs b/mws.api/Program.cs new file mode 100644 index 0000000..404f425 --- /dev/null +++ b/mws.api/Program.cs @@ -0,0 +1,122 @@ +using System.Text; +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; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add( + new System.Text.Json.Serialization.JsonStringEnumConverter()); + }); + +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "MWS API", + Version = "v1" + }); + + options.AddSecurityDefinition("bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "JWT Authorization header using Bearer scheme." + }); + + options.AddSecurityRequirement(document => + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("bearer", document)] = [] + }); +}); + +builder.Services.AddInfrastructure(builder.Configuration); + +var jwtSection = builder.Configuration.GetSection("Jwt"); +var jwtSecret = jwtSection["Secret"]; +var jwtIssuer = jwtSection["Issuer"]; +var jwtAudience = jwtSection["Audience"]; + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)) + }; + + options.MapInboundClaims = false; + }); + +builder.Services.AddAuthorization(); + +builder.Services.AddCors(options => +{ + var origins = builder.Configuration["Cors:Origins"]; + options.AddPolicy("Frontend", policy => + { + policy + .AllowAnyHeader() + .AllowAnyMethod() + .WithOrigins(origins.Split(';', StringSplitOptions.RemoveEmptyEntries)); + }); +}); + +var app = builder.Build(); + +app.UseApiExceptionMiddleware(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + await DbSeeder.SeedAsync(db, scope.ServiceProvider.GetRequiredService()); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint( + "/swagger/v1/swagger.json", + "MWS API v1"); + + options.EnablePersistAuthorization(); + }); +} + +app.UseHttpsRedirection(); + +app.UseCors("Frontend"); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/mws.api/Properties/launchSettings.json b/mws.api/Properties/launchSettings.json new file mode 100644 index 0000000..3c9b724 --- /dev/null +++ b/mws.api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:2000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:3000;http://localhost:2000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/mws.api/appsettings.Development.json b/mws.api/appsettings.Development.json new file mode 100644 index 0000000..ba58a21 --- /dev/null +++ b/mws.api/appsettings.Development.json @@ -0,0 +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": "*" +} \ No newline at end of file diff --git a/mws.api/appsettings.json b/mws.api/appsettings.json new file mode 100644 index 0000000..1b2d3ba --- /dev/null +++ b/mws.api/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/mws.api/mws.api.csproj b/mws.api/mws.api.csproj new file mode 100644 index 0000000..2d3e4e5 --- /dev/null +++ b/mws.api/mws.api.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + mws.backend.dotnet + + + + + + + + + + + + + + diff --git a/mws.application/Accounts/AccountService.cs b/mws.application/Accounts/AccountService.cs new file mode 100644 index 0000000..ed5af07 --- /dev/null +++ b/mws.application/Accounts/AccountService.cs @@ -0,0 +1,116 @@ +using AutoMapper; +using Mws.Application.Auth; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Users; + +namespace Mws.Application.Accounts; + +public class AccountService(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) : IAccountService +{ + private const string Screen = "accounts"; + + public async Task> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default) + { + await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.View, ct); + + var users = await uow.Users.SearchWithRoleAsync(term, null, ct); + return mapper.Map>(users); + } + + public async Task CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default) + { + await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Create, ct); + + 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); + } + + public async Task UpdateAccountAsync(Guid actorUserId, Guid id, UpdateAccountRequest request, CancellationToken ct = default) + { + await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Edit, ct); + + var user = await uow.Users.GetByIdWithRoleAsync(id, ct) + ?? throw new NotFoundException("Account not found"); + + 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); + } + + public async Task DeleteAccountAsync(Guid actorUserId, Guid id, CancellationToken ct = default) + { + await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Delete, ct); + + var user = await uow.Users.GetByIdAsync(id, ct) + ?? throw new NotFoundException("Account not found"); + + var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(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); + } + + public async Task ResetPasswordAsync(Guid actorUserId, Guid id, ResetPasswordRequest request, CancellationToken ct = default) + { + await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Edit, ct); + + if (string.IsNullOrWhiteSpace(request.NewPassword)) + { + throw new BadRequestException("New password is required"); + } + + var user = await uow.Users.GetByIdAsync(id, ct) + ?? throw new NotFoundException("Account not found"); + + user.PasswordHash = passwordHasher.Hash(request.NewPassword); + user.UpdatedAt = DateTime.UtcNow; + await uow.SaveChangesAsync(ct); + } +} diff --git a/mws.application/Accounts/Contracts.cs b/mws.application/Accounts/Contracts.cs new file mode 100644 index 0000000..b269886 --- /dev/null +++ b/mws.application/Accounts/Contracts.cs @@ -0,0 +1,32 @@ +namespace Mws.Application.Accounts; + +public class CreateAccountRequest +{ + 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 class UpdateAccountRequest +{ + public string DisplayName { get; set; } = string.Empty; + public Guid RoleId { get; set; } + public bool IsActive { get; set; } = true; +} + +public class ResetPasswordRequest +{ + public string NewPassword { get; set; } = string.Empty; +} + +public class AccountDto +{ + 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 bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/mws.application/Accounts/IAccountService.cs b/mws.application/Accounts/IAccountService.cs new file mode 100644 index 0000000..ca982fe --- /dev/null +++ b/mws.application/Accounts/IAccountService.cs @@ -0,0 +1,10 @@ +namespace Mws.Application.Accounts; + +public interface IAccountService +{ + Task> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default); + Task CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default); + Task UpdateAccountAsync(Guid actorUserId, Guid id, UpdateAccountRequest request, CancellationToken ct = default); + Task DeleteAccountAsync(Guid actorUserId, Guid id, CancellationToken ct = default); + Task ResetPasswordAsync(Guid actorUserId, Guid id, ResetPasswordRequest request, CancellationToken ct = default); +} diff --git a/mws.application/Auth/AuthService.cs b/mws.application/Auth/AuthService.cs new file mode 100644 index 0000000..1ca89e1 --- /dev/null +++ b/mws.application/Auth/AuthService.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using Mws.Application.Common; + +namespace Mws.Application.Auth; + +public interface IAuthService +{ + Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); +} + +public class AuthService(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper) + : IAuthService +{ + public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) + { + var username = request.Username.Trim(); + var user = await uow.Users.GetByUsernameWithRoleAsync(username, cancellationToken); + + if (user is null || !passwordHasher.Verify(request.Password, user.PasswordHash)) + { + throw new UnauthorizedException("Invalid username or password"); + } + + if (!user.IsActive) + { + throw new ForbiddenException("Account disabled"); + } + + return new LoginResponse + { + Token = tokenService.CreateToken(user.Id, user.Username), + User = mapper.Map(user), + }; + } +} \ No newline at end of file diff --git a/mws.application/Auth/Contracts.cs b/mws.application/Auth/Contracts.cs new file mode 100644 index 0000000..7df4124 --- /dev/null +++ b/mws.application/Auth/Contracts.cs @@ -0,0 +1,33 @@ +namespace Mws.Application.Auth; + +public class LoginRequest +{ + public string Username { get; set; } = string.Empty; + 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; + public UserDto User { get; set; } = null!; +} + +public interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string password, string hash); +} + +public interface ITokenService +{ + string CreateToken(Guid userId, string username); +} diff --git a/mws.application/Common/Exceptions.cs b/mws.application/Common/Exceptions.cs new file mode 100644 index 0000000..48ebb76 --- /dev/null +++ b/mws.application/Common/Exceptions.cs @@ -0,0 +1,9 @@ +namespace Mws.Application.Common; + +public class NotFoundException(string message) : Exception(message); + +public class ForbiddenException(string message) : Exception(message); + +public class UnauthorizedException(string message) : Exception(message); + +public class BadRequestException(string message) : Exception(message); \ No newline at end of file diff --git a/mws.application/Common/IUnitOfWork.cs b/mws.application/Common/IUnitOfWork.cs new file mode 100644 index 0000000..9056372 --- /dev/null +++ b/mws.application/Common/IUnitOfWork.cs @@ -0,0 +1,14 @@ +using Mws.Application.Common.Repositories; + +namespace Mws.Application.Common; + +public interface IUnitOfWork +{ + IUserRepository Users { get; } + IProjectRepository Projects { get; } + ITaskRepository Tasks { get; } + IDocumentRepository Documents { get; } + IRoleRepository Roles { get; } + + Task SaveChangesAsync(CancellationToken ct = default); +} diff --git a/mws.application/Common/MappingProfile.cs b/mws.application/Common/MappingProfile.cs new file mode 100644 index 0000000..752d9b0 --- /dev/null +++ b/mws.application/Common/MappingProfile.cs @@ -0,0 +1,33 @@ +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/Common/Repositories/IDocumentRepository.cs b/mws.application/Common/Repositories/IDocumentRepository.cs new file mode 100644 index 0000000..5f30e23 --- /dev/null +++ b/mws.application/Common/Repositories/IDocumentRepository.cs @@ -0,0 +1,15 @@ +using Mws.Domain.Documents; + +namespace Mws.Application.Common.Repositories; + +public interface IDocumentRepository : IRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetTreeForProjectAsync(Guid projectId, CancellationToken ct = default); + Task GetInProjectAsync(Guid parentId, Guid projectId, CancellationToken ct = default); + Task GetParentIdAsync(Guid documentId, CancellationToken ct = default); + Task> GetChildrenAsync(Guid parentId, CancellationToken ct = default); + Task> SearchAsync(List projectIds, string? term, int take, CancellationToken ct = default); + Task CountByTypeForProjectAsync(Guid projectId, DocumentType type, CancellationToken ct = default); + Task> GetRecentForProjectAsync(Guid projectId, DocumentType type, int take, CancellationToken ct = default); +} diff --git a/mws.application/Common/Repositories/IProjectRepository.cs b/mws.application/Common/Repositories/IProjectRepository.cs new file mode 100644 index 0000000..ae5310a --- /dev/null +++ b/mws.application/Common/Repositories/IProjectRepository.cs @@ -0,0 +1,28 @@ +using Mws.Domain.Projects; + +namespace Mws.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> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default); + Task CountMembersAsync(Guid projectId, CancellationToken ct = default); + + Task IsMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default); + 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 CountOwnersAsync(Guid projectId, CancellationToken ct = default); + Task> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default); + Task> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default); + Task> GetDocumentViewableProjectIdsAsync(Guid userId, CancellationToken ct = default); + void AddMember(ProjectMember member); + void RemoveMember(ProjectMember member); + + Task GetMemberPermissionAsync(Guid projectId, Guid userId, string screen, CancellationToken ct = default); + Task> GetMemberPermissionsAsync(Guid projectId, string screen, CancellationToken ct = default); + void AddMemberPermission(ProjectMemberPermission permission); +} diff --git a/mws.application/Common/Repositories/IRepository.cs b/mws.application/Common/Repositories/IRepository.cs new file mode 100644 index 0000000..06855fb --- /dev/null +++ b/mws.application/Common/Repositories/IRepository.cs @@ -0,0 +1,7 @@ +namespace Mws.Application.Common.Repositories; + +public interface IRepository where T : class +{ + void Add(T entity); + void Remove(T entity); +} diff --git a/mws.application/Common/Repositories/IRoleRepository.cs b/mws.application/Common/Repositories/IRoleRepository.cs new file mode 100644 index 0000000..58939c0 --- /dev/null +++ b/mws.application/Common/Repositories/IRoleRepository.cs @@ -0,0 +1,14 @@ +using Mws.Domain.Roles; + +namespace Mws.Application.Common.Repositories; + +public interface IRoleRepository : IRepository +{ + Task> GetAllWithPermissionsAsync(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); + void RemovePermissions(IEnumerable permissions); +} diff --git a/mws.application/Common/Repositories/ITaskRepository.cs b/mws.application/Common/Repositories/ITaskRepository.cs new file mode 100644 index 0000000..68878d2 --- /dev/null +++ b/mws.application/Common/Repositories/ITaskRepository.cs @@ -0,0 +1,16 @@ +using Mws.Domain.Tasks; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.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> 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/mws.application/Common/Repositories/IUserRepository.cs new file mode 100644 index 0000000..2f6ce18 --- /dev/null +++ b/mws.application/Common/Repositories/IUserRepository.cs @@ -0,0 +1,14 @@ +using Mws.Domain.Users; + +namespace Mws.Application.Common.Repositories; + +public interface IUserRepository : IRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task GetByIdWithRoleAsync(Guid id, CancellationToken ct = default); + 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> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default); +} diff --git a/mws.application/Documents/Contracts.cs b/mws.application/Documents/Contracts.cs new file mode 100644 index 0000000..c8cf0e4 --- /dev/null +++ b/mws.application/Documents/Contracts.cs @@ -0,0 +1,49 @@ +using Mws.Domain.Documents; + +namespace Mws.Application.Documents; + +public class CreateDocumentRequest +{ + public string Title { get; set; } = string.Empty; + public DocumentType Type { get; set; } = DocumentType.Document; + public Guid? ParentId { get; set; } + public string? Content { get; set; } +} + +public class UpdateDocumentRequest +{ + public string Title { get; set; } = string.Empty; + public string? Content { get; set; } +} + +public class MoveDocumentRequest +{ + public Guid? NewParentId { get; set; } +} + +public class DocumentDto +{ + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public Guid? ParentId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Content { get; set; } + public DocumentType Type { get; set; } + public Guid CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public Guid? UpdatedBy { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class DocumentNodeDto +{ + public Guid Id { get; set; } + public Guid? ParentId { get; set; } + public string Title { get; set; } = string.Empty; + public DocumentType Type { get; set; } + public Guid CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public Guid? UpdatedBy { get; set; } + public DateTime UpdatedAt { get; set; } + public List Children { get; set; } = []; +} \ No newline at end of file diff --git a/mws.application/Documents/DocumentService.cs b/mws.application/Documents/DocumentService.cs new file mode 100644 index 0000000..75bce90 --- /dev/null +++ b/mws.application/Documents/DocumentService.cs @@ -0,0 +1,229 @@ +using AutoMapper; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Documents; +using Mws.Domain.Projects; + +namespace Mws.Application.Documents; + +public class DocumentService(IUnitOfWork uow, IMapper mapper) : IDocumentService +{ + public async Task> GetTreeAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + await EnsureDocumentPermissionAsync(userId, projectId, PermissionAction.View, ct); + + var docs = await uow.Documents.GetTreeForProjectAsync(projectId, ct); + + var nodes = docs.ToDictionary(d => d.Id, d => mapper.Map(d)); + + var roots = new List(); + foreach (var node in nodes.Values) + { + if (node.ParentId is { } parentId && nodes.TryGetValue(parentId, out var parent)) + { + parent.Children.Add(node); + } + else + { + roots.Add(node); + } + } + + return roots; + } + + public async Task GetAsync(Guid userId, Guid documentId, CancellationToken ct = default) + { + var doc = await GetDocumentForUserAsync(userId, documentId, ct); + return mapper.Map(doc); + } + + public async Task CreateAsync(Guid userId, Guid projectId, CreateDocumentRequest request, CancellationToken ct = default) + { + await EnsureDocumentPermissionAsync(userId, projectId, PermissionAction.Create, ct); + + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Title is required"); + } + + if (request.ParentId is { } parentId) + { + var parent = await uow.Documents.GetInProjectAsync(parentId, projectId, ct) + ?? throw new BadRequestException("Parent document not found"); + if (parent.Type != DocumentType.Folder) + { + throw new BadRequestException("Parent must be a folder"); + } + } + + if (request.Type == DocumentType.Folder && !string.IsNullOrWhiteSpace(request.Content)) + { + throw new BadRequestException("Folders cannot have content"); + } + + var now = DateTime.UtcNow; + var doc = new Document + { + Id = Guid.NewGuid(), + ProjectId = projectId, + ParentId = request.ParentId, + Title = request.Title.Trim(), + Type = request.Type, + Content = request.Type == DocumentType.Document ? request.Content : null, + CreatedBy = userId, + CreatedAt = now, + UpdatedBy = userId, + UpdatedAt = now, + }; + + uow.Documents.Add(doc); + await uow.SaveChangesAsync(ct); + return mapper.Map(doc); + } + + public async Task UpdateAsync(Guid userId, Guid documentId, UpdateDocumentRequest request, CancellationToken ct = default) + { + var doc = await GetDocumentForUserAsync(userId, documentId, ct); + await EnsureDocumentPermissionAsync(userId, doc.ProjectId, PermissionAction.Edit, ct); + + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Title is required"); + } + + doc.Title = request.Title.Trim(); + if (doc.Type == DocumentType.Document) + { + doc.Content = request.Content; + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = userId; + } + else + { + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = userId; + } + + await uow.SaveChangesAsync(ct); + return mapper.Map(doc); + } + + public async Task MoveAsync(Guid userId, Guid documentId, Guid? newParentId, CancellationToken ct = default) + { + var doc = await GetDocumentForUserAsync(userId, documentId, ct); + await EnsureDocumentPermissionAsync(userId, doc.ProjectId, PermissionAction.Edit, ct); + + if (newParentId == documentId) + { + throw new BadRequestException("A document cannot be moved into itself"); + } + + if (newParentId is { } parentId) + { + var parent = await uow.Documents.GetByIdAsync(parentId, ct) + ?? throw new NotFoundException("Parent folder not found"); + if (parent.ProjectId != doc.ProjectId) + { + throw new BadRequestException("Parent must belong to the same project"); + } + if (parent.Type != DocumentType.Folder) + { + throw new BadRequestException("Parent must be a folder"); + } + + var cursor = parent.ParentId; + while (cursor is not null) + { + if (cursor == documentId) + { + throw new BadRequestException("A folder cannot be moved into its own descendant"); + } + cursor = await uow.Documents.GetParentIdAsync(cursor.Value, ct); + } + } + + doc.ParentId = newParentId; + doc.UpdatedAt = DateTime.UtcNow; + doc.UpdatedBy = userId; + await uow.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Guid userId, Guid documentId, CancellationToken ct = default) + { + var doc = await GetDocumentForUserAsync(userId, documentId, ct); + await EnsureDocumentPermissionAsync(userId, doc.ProjectId, PermissionAction.Delete, ct); + + await DeleteDescendantsAsync(documentId, ct); + uow.Documents.Remove(doc); + await uow.SaveChangesAsync(ct); + } + + public async Task> SearchAsync(Guid userId, string term, CancellationToken ct = default) + { + var projectIds = await uow.Projects.GetDocumentViewableProjectIdsAsync(userId, ct); + var results = await uow.Documents.SearchAsync(projectIds, term, 20, ct); + return mapper.Map>(results); + } + + private async Task DeleteDescendantsAsync(Guid parentId, CancellationToken ct) + { + var children = await uow.Documents.GetChildrenAsync(parentId, ct); + foreach (var child in children) + { + await DeleteDescendantsAsync(child.Id, ct); + uow.Documents.Remove(child); + } + } + + private async Task EnsureDocumentPermissionAsync( + Guid userId, Guid projectId, PermissionAction action, CancellationToken ct = default) + { + var member = await uow.Projects.GetMemberAsync(projectId, userId, ct) + ?? throw new NotFoundException("Project not found"); + + if (!await HasDocumentPermissionAsync(member, action, ct)) + { + throw new ForbiddenException("You do not have permission to access documents in this project"); + } + } + + private async Task HasDocumentPermissionAsync(ProjectMember member, PermissionAction action, CancellationToken ct) + { + if (member.Role == MemberRole.Owner) + { + return true; + } + + var permission = await uow.Projects.GetMemberPermissionAsync(member.ProjectId, member.UserId, ProjectPermissionScreens.Documents, ct); + + return action switch + { + PermissionAction.View => permission?.CanView ?? false, + PermissionAction.Create => permission?.CanCreate ?? false, + PermissionAction.Edit => permission?.CanEdit ?? false, + PermissionAction.Delete => permission?.CanDelete ?? false, + _ => false, + }; + } + + private async Task GetDocumentForUserAsync(Guid userId, Guid documentId, CancellationToken ct = default) + { + var doc = await uow.Documents.GetByIdAsync(documentId, ct) + ?? throw new NotFoundException("Document not found"); + + var member = await uow.Projects.GetMemberAsync(doc.ProjectId, userId, ct); + + if (member is null) + { + throw new NotFoundException("Document not found"); + } + + if (!await HasDocumentPermissionAsync(member, PermissionAction.View, ct)) + { + throw new ForbiddenException("You do not have permission to view this document"); + } + + return doc; + } +} diff --git a/mws.application/Documents/IDocumentService.cs b/mws.application/Documents/IDocumentService.cs new file mode 100644 index 0000000..79a3418 --- /dev/null +++ b/mws.application/Documents/IDocumentService.cs @@ -0,0 +1,14 @@ +using Mws.Application.Common; + +namespace Mws.Application.Documents; + +public interface IDocumentService +{ + Task> GetTreeAsync(Guid userId, Guid projectId, CancellationToken ct = default); + Task GetAsync(Guid userId, Guid documentId, CancellationToken ct = default); + Task CreateAsync(Guid userId, Guid projectId, CreateDocumentRequest request, CancellationToken ct = default); + Task UpdateAsync(Guid userId, Guid documentId, UpdateDocumentRequest request, CancellationToken ct = default); + Task MoveAsync(Guid userId, Guid documentId, Guid? newParentId, CancellationToken ct = default); + Task DeleteAsync(Guid userId, Guid documentId, CancellationToken ct = default); + Task> SearchAsync(Guid userId, string term, CancellationToken ct = default); +} \ No newline at end of file diff --git a/mws.application/Permissions/Contracts.cs b/mws.application/Permissions/Contracts.cs new file mode 100644 index 0000000..9f4a8ad --- /dev/null +++ b/mws.application/Permissions/Contracts.cs @@ -0,0 +1,20 @@ +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/IPermissionService.cs b/mws.application/Permissions/IPermissionService.cs new file mode 100644 index 0000000..d8e9e18 --- /dev/null +++ b/mws.application/Permissions/IPermissionService.cs @@ -0,0 +1,7 @@ +namespace Mws.Application.Permissions; + +public interface IPermissionService +{ + Task> GetMenuAsync(Guid userId, CancellationToken ct = default); + Task EnsureAsync(Guid userId, string screen, PermissionAction action, CancellationToken ct = default); +} diff --git a/mws.application/Permissions/PermissionService.cs b/mws.application/Permissions/PermissionService.cs new file mode 100644 index 0000000..700f198 --- /dev/null +++ b/mws.application/Permissions/PermissionService.cs @@ -0,0 +1,47 @@ +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/Permissions/ScreenCatalog.cs b/mws.application/Permissions/ScreenCatalog.cs new file mode 100644 index 0000000..13c2d64 --- /dev/null +++ b/mws.application/Permissions/ScreenCatalog.cs @@ -0,0 +1,18 @@ +namespace Mws.Application.Permissions; + +public record ScreenDefinition(string Key, string Label, string Path); + +public static class ScreenCatalog +{ + public static readonly IReadOnlyList Screens = + [ + new("dashboard", "Dashboard", "/"), + new("projects", "Projects", "/projects"), + new("tasks", "Tasks", "/tasks"), + new("documents", "Documents", "/documents"), + new("accounts", "Accounts", "/accounts"), + new("roles", "Roles", "/roles"), + ]; + + public static readonly IReadOnlySet Keys = Screens.Select(s => s.Key).ToHashSet(); +} diff --git a/mws.application/Projects/Contracts.cs b/mws.application/Projects/Contracts.cs new file mode 100644 index 0000000..c47c770 --- /dev/null +++ b/mws.application/Projects/Contracts.cs @@ -0,0 +1,77 @@ +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public class CreateProjectRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} + +public class UpdateProjectRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ProjectStatus Status { get; set; } = ProjectStatus.Active; +} + +public class AddMemberRequest +{ + public Guid UserId { get; set; } + public MemberRole Role { get; set; } = MemberRole.Member; +} + +public class ProjectMemberDto +{ + public Guid UserId { get; set; } + public string Username { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public MemberRole Role { get; set; } + public bool CanViewDocuments { get; set; } + public bool CanCreateDocuments { get; set; } + public bool CanEditDocuments { get; set; } + public bool CanDeleteDocuments { get; set; } +} + +public class UpdateMemberDocumentPermissionsRequest +{ + public bool CanViewDocuments { get; set; } + public bool CanCreateDocuments { get; set; } + public bool CanEditDocuments { get; set; } + public bool CanDeleteDocuments { get; set; } +} + +public class ProjectDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ProjectStatus Status { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class ProjectOverviewDto +{ + public ProjectDto Project { get; set; } = null!; + public int MemberCount { get; set; } + public int DocumentCount { get; set; } + public Dictionary TaskCountsByStatus { get; set; } = []; + public List RecentTasks { get; set; } = []; + public List RecentDocuments { get; set; } = []; +} + +public class RecentTaskDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} + +public class RecentDocumentDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/mws.application/Projects/IProjectService.cs b/mws.application/Projects/IProjectService.cs new file mode 100644 index 0000000..ed82c92 --- /dev/null +++ b/mws.application/Projects/IProjectService.cs @@ -0,0 +1,22 @@ +using Mws.Application.Common; + +namespace Mws.Application.Projects; + +public interface IProjectService +{ + Task> GetProjectsAsync(Guid userId, CancellationToken ct = default); + Task GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default); + Task CreateProjectAsync(Guid userId, CreateProjectRequest request, CancellationToken ct = default); + Task UpdateProjectAsync(Guid userId, Guid projectId, UpdateProjectRequest request, CancellationToken ct = default); + Task ArchiveProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default); + Task GetOverviewAsync(Guid userId, Guid projectId, CancellationToken ct = default); + Task> SearchAsync(Guid userId, string term, CancellationToken ct = default); +} + +public interface IProjectMemberService +{ + Task> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default); + Task AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default); + Task UpdateMemberDocumentPermissionsAsync(Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default); + Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/mws.application/Projects/ProjectMemberService.cs b/mws.application/Projects/ProjectMemberService.cs new file mode 100644 index 0000000..566afd6 --- /dev/null +++ b/mws.application/Projects/ProjectMemberService.cs @@ -0,0 +1,151 @@ +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public class ProjectMemberService(IUnitOfWork uow) : IProjectMemberService +{ + public async Task> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + var isMember = await uow.Projects.IsMemberAsync(projectId, userId, ct); + if (!isMember) + { + throw new NotFoundException("Project not found"); + } + + var members = await uow.Projects.GetMembersWithUserAsync(projectId, ct); + var permissions = await uow.Projects.GetMemberPermissionsAsync(projectId, ProjectPermissionScreens.Documents, ct); + + return members.Select(m => + { + permissions.TryGetValue(m.UserId, out var p); + return new ProjectMemberDto + { + UserId = m.UserId, + Username = m.User.Username, + DisplayName = m.User.DisplayName, + Role = m.Role, + CanViewDocuments = m.Role == MemberRole.Owner || (p?.CanView ?? false), + CanCreateDocuments = m.Role == MemberRole.Owner || (p?.CanCreate ?? false), + CanEditDocuments = m.Role == MemberRole.Owner || (p?.CanEdit ?? false), + CanDeleteDocuments = m.Role == MemberRole.Owner || (p?.CanDelete ?? false), + }; + }).ToList(); + } + + public async Task AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default) + { + if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can add members"); + } + + var user = await uow.Users.GetByIdAsync(request.UserId, ct) + ?? throw new NotFoundException("User not found"); + + var already = await uow.Projects.IsMemberAsync(projectId, request.UserId, ct); + if (already) + { + throw new BadRequestException("User is already a member of this project"); + } + + var role = request.Role is MemberRole.Owner or MemberRole.Member ? request.Role : MemberRole.Member; + var member = new ProjectMember + { + ProjectId = projectId, + UserId = request.UserId, + Role = role, + }; + + uow.Projects.AddMember(member); + uow.Projects.AddMemberPermission(new ProjectMemberPermission + { + ProjectId = projectId, + UserId = request.UserId, + Screen = ProjectPermissionScreens.Documents, + CanView = true, + CanCreate = role == MemberRole.Owner, + CanEdit = role == MemberRole.Owner, + CanDelete = role == MemberRole.Owner, + }); + await uow.SaveChangesAsync(ct); + + return new ProjectMemberDto + { + UserId = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Role = member.Role, + CanViewDocuments = true, + CanCreateDocuments = role == MemberRole.Owner, + CanEditDocuments = role == MemberRole.Owner, + CanDeleteDocuments = role == MemberRole.Owner, + }; + } + + public async Task UpdateMemberDocumentPermissionsAsync( + Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default) + { + if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can change member permissions"); + } + + var member = await uow.Projects.GetMemberWithUserAsync(projectId, memberUserId, ct) + ?? throw new NotFoundException("Member not found in project"); + + if (member.Role == MemberRole.Owner) + { + throw new BadRequestException("Owner permissions cannot be changed"); + } + + var permission = await uow.Projects.GetMemberPermissionAsync(projectId, memberUserId, ProjectPermissionScreens.Documents, ct); + if (permission is null) + { + permission = new ProjectMemberPermission { ProjectId = projectId, UserId = memberUserId, Screen = ProjectPermissionScreens.Documents }; + uow.Projects.AddMemberPermission(permission); + } + + permission.CanView = request.CanViewDocuments; + permission.CanCreate = request.CanCreateDocuments; + permission.CanEdit = request.CanEditDocuments; + permission.CanDelete = request.CanDeleteDocuments; + await uow.SaveChangesAsync(ct); + + return new ProjectMemberDto + { + UserId = member.UserId, + Username = member.User.Username, + DisplayName = member.User.DisplayName, + Role = member.Role, + CanViewDocuments = permission.CanView, + CanCreateDocuments = permission.CanCreate, + CanEditDocuments = permission.CanEdit, + CanDeleteDocuments = permission.CanDelete, + }; + } + + public async Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default) + { + if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can remove members"); + } + + var member = await uow.Projects.GetMemberAsync(projectId, memberUserId, ct) + ?? throw new NotFoundException("Member not found in project"); + + var owners = await uow.Projects.CountOwnersAsync(projectId, ct); + + if (member.Role == MemberRole.Owner && owners <= 1) + { + throw new BadRequestException("Cannot remove the last owner of the project"); + } + + uow.Projects.RemoveMember(member); + await uow.SaveChangesAsync(ct); + } + + private Task GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) => + uow.Projects.GetMemberRoleAsync(projectId, userId, ct); +} diff --git a/mws.application/Projects/ProjectService.cs b/mws.application/Projects/ProjectService.cs new file mode 100644 index 0000000..03abc50 --- /dev/null +++ b/mws.application/Projects/ProjectService.cs @@ -0,0 +1,127 @@ +using AutoMapper; +using Mws.Application.Common; +using Mws.Domain.Documents; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public class ProjectService(IUnitOfWork uow, IMapper mapper) : IProjectService +{ + public async Task> GetProjectsAsync(Guid userId, CancellationToken ct = default) + { + var projects = await uow.Projects.GetForUserAsync(userId, ct); + return mapper.Map>(projects); + } + + public async Task GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + var project = await GetProjectForUserAsync(userId, projectId, ct); + return mapper.Map(project); + } + + public async Task CreateProjectAsync(Guid userId, CreateProjectRequest request, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BadRequestException("Project name is required"); + } + + var now = DateTime.UtcNow; + var project = new Project + { + Id = Guid.NewGuid(), + Name = request.Name.Trim(), + Description = request.Description, + Status = ProjectStatus.Active, + CreatedAt = now, + UpdatedAt = now, + }; + + project.Members.Add(new ProjectMember + { + ProjectId = project.Id, + UserId = userId, + Role = MemberRole.Owner, + }); + + uow.Projects.Add(project); + await uow.SaveChangesAsync(ct); + return mapper.Map(project); + } + + public async Task UpdateProjectAsync(Guid userId, Guid projectId, UpdateProjectRequest request, CancellationToken ct = default) + { + var project = await GetProjectForUserAsync(userId, projectId, ct); + + if (MemberRole.Owner != await GetMemberRoleAsync(userId, projectId, ct)) + { + throw new ForbiddenException("Only the project owner can update the project"); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BadRequestException("Project name is required"); + } + + project.Name = request.Name.Trim(); + project.Description = request.Description; + project.Status = request.Status; + project.UpdatedAt = DateTime.UtcNow; + + await uow.SaveChangesAsync(ct); + return mapper.Map(project); + } + + public async Task ArchiveProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + var project = await GetProjectForUserAsync(userId, projectId, ct); + + if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner) + { + throw new ForbiddenException("Only the project owner can archive the project"); + } + + project.Status = ProjectStatus.Archived; + project.UpdatedAt = DateTime.UtcNow; + await uow.SaveChangesAsync(ct); + } + + public async Task GetOverviewAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + var project = await GetProjectForUserAsync(userId, projectId, ct); + + var memberCount = await uow.Projects.CountMembersAsync(projectId, ct); + var documentCount = await uow.Documents.CountByTypeForProjectAsync(projectId, DocumentType.Document, ct); + + var taskCounts = await uow.Tasks.CountByStatusForProjectAsync(projectId, ct); + var taskCountsByStatus = taskCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value); + + var recentTasks = await uow.Tasks.GetRecentForProjectAsync(projectId, 5, ct); + var recentDocuments = await uow.Documents.GetRecentForProjectAsync(projectId, DocumentType.Document, 5, ct); + + return new ProjectOverviewDto + { + Project = mapper.Map(project), + MemberCount = memberCount, + DocumentCount = documentCount, + TaskCountsByStatus = taskCountsByStatus, + RecentTasks = mapper.Map>(recentTasks), + RecentDocuments = mapper.Map>(recentDocuments), + }; + } + + public async Task> SearchAsync(Guid userId, string term, CancellationToken ct = default) + { + var projects = await uow.Projects.SearchForUserAsync(userId, term, ct); + return mapper.Map>(projects); + } + + protected async Task GetProjectForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + return await uow.Projects.GetForUserAsync(userId, projectId, ct) + ?? throw new NotFoundException("Project not found"); + } + + protected Task GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) => + uow.Projects.GetMemberRoleAsync(projectId, userId, ct); +} diff --git a/mws.application/Roles/Contracts.cs b/mws.application/Roles/Contracts.cs new file mode 100644 index 0000000..61982c8 --- /dev/null +++ b/mws.application/Roles/Contracts.cs @@ -0,0 +1,24 @@ +namespace Mws.Application.Roles; + +public class PermissionEntryDto +{ + public string Screen { 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 RoleDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsSystem { get; set; } + public List Permissions { get; set; } = []; +} + +public class SaveRoleRequest +{ + public string Name { get; set; } = string.Empty; + public List Permissions { get; set; } = []; +} diff --git a/mws.application/Roles/IRoleService.cs b/mws.application/Roles/IRoleService.cs new file mode 100644 index 0000000..8e21df9 --- /dev/null +++ b/mws.application/Roles/IRoleService.cs @@ -0,0 +1,10 @@ +namespace Mws.Application.Roles; + +public interface IRoleService +{ + Task> GetRolesAsync(CancellationToken ct = default); + Task GetRoleAsync(Guid id, CancellationToken ct = default); + Task CreateRoleAsync(SaveRoleRequest request, CancellationToken ct = default); + Task UpdateRoleAsync(Guid id, SaveRoleRequest request, CancellationToken ct = default); + Task DeleteRoleAsync(Guid id, CancellationToken ct = default); +} diff --git a/mws.application/Roles/RoleService.cs b/mws.application/Roles/RoleService.cs new file mode 100644 index 0000000..855af44 --- /dev/null +++ b/mws.application/Roles/RoleService.cs @@ -0,0 +1,118 @@ +using AutoMapper; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Roles; + +namespace Mws.Application.Roles; + +public class RoleService(IUnitOfWork uow, IMapper mapper) : IRoleService +{ + public async Task> GetRolesAsync(CancellationToken ct = default) + { + var roles = await uow.Roles.GetAllWithPermissionsAsync(ct); + return mapper.Map>(roles); + } + + public async Task GetRoleAsync(Guid id, CancellationToken ct = default) + { + var role = await uow.Roles.GetByIdWithPermissionsAsync(id, ct) + ?? throw new NotFoundException("Role not found"); + return mapper.Map(role); + } + + public async Task CreateRoleAsync(SaveRoleRequest request, CancellationToken ct = default) + { + var name = request.Name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Role name is required"); + } + + if (await uow.Roles.ExistsByNameAsync(name, null, ct)) + { + throw new BadRequestException("Role name already exists"); + } + + var now = DateTime.UtcNow; + var role = new Role + { + Id = Guid.NewGuid(), + Name = name, + IsSystem = false, + CreatedAt = now, + UpdatedAt = now, + Permissions = BuildPermissions(request.Permissions), + }; + + uow.Roles.Add(role); + await uow.SaveChangesAsync(ct); + return mapper.Map(role); + } + + public async Task UpdateRoleAsync(Guid id, SaveRoleRequest request, CancellationToken ct = default) + { + var role = await uow.Roles.GetByIdWithPermissionsAsync(id, ct) + ?? throw new NotFoundException("Role not found"); + + var name = request.Name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new BadRequestException("Role name is required"); + } + + if (await uow.Roles.ExistsByNameAsync(name, id, ct)) + { + throw new BadRequestException("Role name already exists"); + } + + role.Name = name; + role.UpdatedAt = DateTime.UtcNow; + + uow.Roles.RemovePermissions(role.Permissions.ToList()); + role.Permissions = BuildPermissions(request.Permissions); + foreach (var p in role.Permissions) + { + p.RoleId = role.Id; + } + + await uow.SaveChangesAsync(ct); + return mapper.Map(role); + } + + public async Task DeleteRoleAsync(Guid id, CancellationToken ct = default) + { + var role = await uow.Roles.GetByIdAsync(id, ct) + ?? throw new NotFoundException("Role not found"); + + if (role.IsSystem) + { + throw new BadRequestException("Cannot delete a system role"); + } + + if (await uow.Users.ExistsByRoleIdAsync(id, ct)) + { + throw new BadRequestException("Cannot delete a role that is assigned to accounts"); + } + + uow.Roles.Remove(role); + await uow.SaveChangesAsync(ct); + } + + private static List BuildPermissions(List entries) + { + var byScreen = entries.Where(e => ScreenCatalog.Keys.Contains(e.Screen)).ToDictionary(e => e.Screen); + + return ScreenCatalog.Keys.Select(key => + { + byScreen.TryGetValue(key, out var entry); + return new RolePermission + { + Screen = key, + CanView = entry?.CanView ?? false, + CanCreate = entry?.CanCreate ?? false, + CanEdit = entry?.CanEdit ?? false, + CanDelete = entry?.CanDelete ?? false, + }; + }).ToList(); + } +} diff --git a/mws.application/Tasks/Contracts.cs b/mws.application/Tasks/Contracts.cs new file mode 100644 index 0000000..59a81ea --- /dev/null +++ b/mws.application/Tasks/Contracts.cs @@ -0,0 +1,40 @@ +using Mws.Domain.Tasks; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.Application.Tasks; + +public class CreateTaskRequest +{ + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public TaskStatus? Status { get; set; } + public TaskPriority? Priority { get; set; } + public Guid? AssigneeId { get; set; } + public DateTime? DueDate { get; set; } +} + +public class UpdateTaskRequest +{ + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public TaskStatus Status { get; set; } = TaskStatus.Todo; + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + public Guid? AssigneeId { get; set; } + public DateTime? DueDate { get; set; } +} + +public class TaskDto +{ + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public TaskStatus Status { get; set; } + public TaskPriority Priority { get; set; } + public Guid? AssigneeId { get; set; } + public string? AssigneeName { get; set; } + public DateTime? DueDate { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/mws.application/Tasks/ITaskService.cs b/mws.application/Tasks/ITaskService.cs new file mode 100644 index 0000000..7e65757 --- /dev/null +++ b/mws.application/Tasks/ITaskService.cs @@ -0,0 +1,15 @@ +using Mws.Application.Common; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.Application.Tasks; + +public interface ITaskService +{ + Task> GetTasksAsync(Guid userId, Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default); + Task GetAsync(Guid userId, Guid taskId, CancellationToken ct = default); + Task CreateAsync(Guid userId, Guid projectId, CreateTaskRequest request, CancellationToken ct = default); + Task UpdateAsync(Guid userId, Guid taskId, UpdateTaskRequest request, CancellationToken ct = default); + Task DeleteAsync(Guid userId, Guid taskId, CancellationToken ct = default); + Task> SearchAsync(Guid userId, string term, CancellationToken ct = default); +} \ No newline at end of file diff --git a/mws.application/Tasks/TaskService.cs b/mws.application/Tasks/TaskService.cs new file mode 100644 index 0000000..8f67346 --- /dev/null +++ b/mws.application/Tasks/TaskService.cs @@ -0,0 +1,129 @@ +using AutoMapper; +using Mws.Application.Common; +using Mws.Domain.Tasks; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.Application.Tasks; + +public class TaskService(IUnitOfWork uow, IMapper mapper) : ITaskService +{ + public async Task> GetTasksAsync(Guid userId, Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default) + { + await EnsureMemberAccessAsync(userId, projectId, ct); + + var tasks = await uow.Tasks.GetForProjectAsync(projectId, status, priority, assigneeId, ct); + return mapper.Map>(tasks); + } + + public async Task GetAsync(Guid userId, Guid taskId, CancellationToken ct = default) + { + var task = await GetTaskForUserAsync(userId, taskId, ct); + return await GetDtoAsync(task.Id, ct); + } + + public async Task CreateAsync(Guid userId, Guid projectId, CreateTaskRequest request, CancellationToken ct = default) + { + await EnsureMemberAccessAsync(userId, projectId, ct); + + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Task title is required"); + } + + if (request.AssigneeId.HasValue && !await IsMemberAsync(request.AssigneeId.Value, projectId, ct)) + { + throw new BadRequestException("Assignee must be a member of the project"); + } + + var now = DateTime.UtcNow; + var task = new TaskItem + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = request.Title.Trim(), + Description = request.Description, + Status = request.Status ?? TaskStatus.Todo, + Priority = request.Priority ?? TaskPriority.Medium, + AssigneeId = request.AssigneeId, + DueDate = request.DueDate, + CreatedBy = userId, + CreatedAt = now, + UpdatedAt = now, + }; + + uow.Tasks.Add(task); + await uow.SaveChangesAsync(ct); + return await GetDtoAsync(task.Id, ct); + } + + public async Task UpdateAsync(Guid userId, Guid taskId, UpdateTaskRequest request, CancellationToken ct = default) + { + var task = await GetTaskForUserAsync(userId, taskId, ct); + + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Task title is required"); + } + + if (request.AssigneeId.HasValue && !await IsMemberAsync(request.AssigneeId.Value, task.ProjectId, ct)) + { + throw new BadRequestException("Assignee must be a member of the project"); + } + + task.Title = request.Title.Trim(); + task.Description = request.Description; + task.Status = request.Status; + task.Priority = request.Priority; + task.AssigneeId = request.AssigneeId; + task.DueDate = request.DueDate; + task.UpdatedAt = DateTime.UtcNow; + + await uow.SaveChangesAsync(ct); + return await GetDtoAsync(task.Id, ct); + } + + public async Task DeleteAsync(Guid userId, Guid taskId, CancellationToken ct = default) + { + var task = await GetTaskForUserAsync(userId, taskId, ct); + uow.Tasks.Remove(task); + await uow.SaveChangesAsync(ct); + } + + public async Task> SearchAsync(Guid userId, string term, CancellationToken ct = default) + { + var projectIds = await uow.Projects.GetProjectIdsForUserAsync(userId, ct); + var tasks = await uow.Tasks.SearchWithAssigneeAsync(projectIds, term, 20, ct); + return mapper.Map>(tasks); + } + + private async Task GetDtoAsync(Guid id, CancellationToken ct) + { + var task = await uow.Tasks.GetWithAssigneeAsync(id, ct) + ?? throw new NotFoundException("Task not found"); + return mapper.Map(task); + } + + private async Task GetTaskForUserAsync(Guid userId, Guid taskId, CancellationToken ct = default) + { + var task = await uow.Tasks.GetByIdAsync(taskId, ct) + ?? throw new NotFoundException("Task not found"); + + if (!await IsMemberAsync(userId, task.ProjectId, ct)) + { + throw new NotFoundException("Task not found"); + } + return task; + } + + private async Task EnsureMemberAccessAsync(Guid userId, Guid projectId, CancellationToken ct = default) + { + if (!await IsMemberAsync(userId, projectId, ct)) + { + throw new NotFoundException("Project not found"); + } + } + + private Task IsMemberAsync(Guid userId, Guid projectId, CancellationToken ct = default) => + uow.Projects.IsMemberAsync(projectId, userId, ct); +} diff --git a/mws.application/mws.application.csproj b/mws.application/mws.application.csproj new file mode 100644 index 0000000..c339a26 --- /dev/null +++ b/mws.application/mws.application.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/mws.backend.dotnet.sln b/mws.backend.dotnet.sln new file mode 100644 index 0000000..298d142 --- /dev/null +++ b/mws.backend.dotnet.sln @@ -0,0 +1,34 @@ + +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}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mws.application", "mws.application\mws.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}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mws.infrastructure", "mws.infrastructure\mws.infrastructure.csproj", "{FEFFBC10-B99E-4A56-9F32-6D925D192606}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9099030B-4019-410C-AEBE-83099B58ABA6}.Release|Any CPU.Build.0 = Release|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D876692E-C85A-49CB-8897-18D6C884CB0F}.Release|Any CPU.Build.0 = Release|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {338B6456-F229-4738-85EE-3AD2D84052C6}.Release|Any CPU.Build.0 = Release|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEFFBC10-B99E-4A56-9F32-6D925D192606}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/mws.domain/Documents/Document.cs b/mws.domain/Documents/Document.cs new file mode 100644 index 0000000..e79363d --- /dev/null +++ b/mws.domain/Documents/Document.cs @@ -0,0 +1,24 @@ +namespace Mws.Domain.Documents; + +public enum DocumentType +{ + Folder = 1, + Document = 2, +} + +public class Document +{ + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public Guid? ParentId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Content { get; set; } + public DocumentType Type { get; set; } = DocumentType.Document; + public Guid CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public Guid? UpdatedBy { get; set; } + public DateTime UpdatedAt { get; set; } + + public Document? Parent { get; set; } + public List Children { get; set; } = []; +} \ No newline at end of file diff --git a/mws.domain/Projects/Project.cs b/mws.domain/Projects/Project.cs new file mode 100644 index 0000000..8c9881d --- /dev/null +++ b/mws.domain/Projects/Project.cs @@ -0,0 +1,19 @@ +namespace Mws.Domain.Projects; + +public enum ProjectStatus +{ + Active = 1, + Archived = 2, +} + +public class Project +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ProjectStatus Status { get; set; } = ProjectStatus.Active; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + public List Members { get; set; } = []; +} \ No newline at end of file diff --git a/mws.domain/Projects/ProjectMember.cs b/mws.domain/Projects/ProjectMember.cs new file mode 100644 index 0000000..d46d8f7 --- /dev/null +++ b/mws.domain/Projects/ProjectMember.cs @@ -0,0 +1,17 @@ +namespace Mws.Domain.Projects; + +public enum MemberRole +{ + Owner = 1, + Member = 2, +} + +public class ProjectMember +{ + public Guid ProjectId { get; set; } + public Guid UserId { get; set; } + public MemberRole Role { get; set; } = MemberRole.Member; + + public Project Project { get; set; } = null!; + public Mws.Domain.Users.User User { get; set; } = null!; +} \ No newline at end of file diff --git a/mws.domain/Projects/ProjectMemberPermission.cs b/mws.domain/Projects/ProjectMemberPermission.cs new file mode 100644 index 0000000..c003099 --- /dev/null +++ b/mws.domain/Projects/ProjectMemberPermission.cs @@ -0,0 +1,17 @@ +namespace Mws.Domain.Projects; + +public static class ProjectPermissionScreens +{ + public const string Documents = "documents"; +} + +public class ProjectMemberPermission +{ + public Guid ProjectId { get; set; } + public Guid UserId { get; set; } + public string Screen { 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.domain/Roles/Role.cs b/mws.domain/Roles/Role.cs new file mode 100644 index 0000000..d0320fc --- /dev/null +++ b/mws.domain/Roles/Role.cs @@ -0,0 +1,12 @@ +namespace Mws.Domain.Roles; + +public class Role +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsSystem { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + public List Permissions { get; set; } = []; +} diff --git a/mws.domain/Roles/RolePermission.cs b/mws.domain/Roles/RolePermission.cs new file mode 100644 index 0000000..2044258 --- /dev/null +++ b/mws.domain/Roles/RolePermission.cs @@ -0,0 +1,13 @@ +namespace Mws.Domain.Roles; + +public class RolePermission +{ + public Guid RoleId { get; set; } + public string Screen { 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 Role Role { get; set; } = null!; +} diff --git a/mws.domain/Tasks/TaskItem.cs b/mws.domain/Tasks/TaskItem.cs new file mode 100644 index 0000000..752f1de --- /dev/null +++ b/mws.domain/Tasks/TaskItem.cs @@ -0,0 +1,33 @@ +namespace Mws.Domain.Tasks; + +public enum TaskStatus +{ + Todo = 1, + InProgress = 2, + Done = 3, + Cancelled = 4, +} + +public enum TaskPriority +{ + Low = 1, + Medium = 2, + High = 3, +} + +public class TaskItem +{ + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public TaskStatus Status { get; set; } = TaskStatus.Todo; + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + public Guid? AssigneeId { get; set; } + public DateTime? DueDate { get; set; } + public Guid CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + public Mws.Domain.Users.User? Assignee { get; set; } +} \ No newline at end of file diff --git a/mws.domain/Users/User.cs b/mws.domain/Users/User.cs new file mode 100644 index 0000000..ad1a9e4 --- /dev/null +++ b/mws.domain/Users/User.cs @@ -0,0 +1,17 @@ +using Mws.Domain.Roles; + +namespace Mws.Domain.Users; + +public class User +{ + public Guid Id { get; set; } + 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!; +} diff --git a/mws.domain/mws.domain.csproj b/mws.domain/mws.domain.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/mws.domain/mws.domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/mws.infrastructure/Authentication/BcryptPasswordHasher.cs b/mws.infrastructure/Authentication/BcryptPasswordHasher.cs new file mode 100644 index 0000000..30bd9aa --- /dev/null +++ b/mws.infrastructure/Authentication/BcryptPasswordHasher.cs @@ -0,0 +1,10 @@ +using Mws.Application.Auth; + +namespace Mws.Infrastructure.Authentication; + +public class BcryptPasswordHasher : IPasswordHasher +{ + public string Hash(string password) => BCrypt.Net.BCrypt.HashPassword(password, BCrypt.Net.BCrypt.GenerateSalt()); + + public bool Verify(string password, string hash) => BCrypt.Net.BCrypt.Verify(password, hash); +} \ No newline at end of file diff --git a/mws.infrastructure/Authentication/JwtOptions.cs b/mws.infrastructure/Authentication/JwtOptions.cs new file mode 100644 index 0000000..8373efa --- /dev/null +++ b/mws.infrastructure/Authentication/JwtOptions.cs @@ -0,0 +1,9 @@ +namespace Mws.Infrastructure.Authentication; + +public class JwtOptions +{ + public string Secret { get; set; } = string.Empty; + public string Issuer { get; set; } = "Mws"; + public string Audience { get; set; } = "Mws.Clients"; + public TimeSpan Expiration { get; set; } = TimeSpan.FromHours(12); +} \ No newline at end of file diff --git a/mws.infrastructure/Authentication/JwtTokenService.cs b/mws.infrastructure/Authentication/JwtTokenService.cs new file mode 100644 index 0000000..d75ad79 --- /dev/null +++ b/mws.infrastructure/Authentication/JwtTokenService.cs @@ -0,0 +1,32 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using Mws.Application.Auth; + +namespace Mws.Infrastructure.Authentication; + +public class JwtTokenService(JwtOptions options) : ITokenService +{ + public string CreateToken(Guid userId, string username) + { + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.Name, username), + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Secret)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: claims, + expires: DateTime.UtcNow.Add(options.Expiration), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/mws.infrastructure/DependencyInjection.cs b/mws.infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..ef4e143 --- /dev/null +++ b/mws.infrastructure/DependencyInjection.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Mws.Application.Accounts; +using Mws.Application.Auth; +using Mws.Application.Common; +using Mws.Application.Documents; +using Mws.Application.Permissions; +using Mws.Application.Projects; +using Mws.Application.Roles; +using Mws.Application.Tasks; +using Mws.Infrastructure.Authentication; +using Mws.Infrastructure.Persistence; + +namespace Mws.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Default") + ?? "Host=localhost;Port=5432;Database=mws;Username=mws;Password=mws"; + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(); + + services.AddAutoMapper(cfg => { }, typeof(MappingProfile).Assembly); + + services.AddScoped(); + + var jwtSection = configuration.GetSection("Jwt"); + var jwtOptions = jwtSection.Get() ?? new JwtOptions(); + if (string.IsNullOrWhiteSpace(jwtOptions.Secret) || jwtOptions.Secret.Length < 32) + { + jwtOptions.Secret = Environment.GetEnvironmentVariable("MWS_JWT_SECRET") + ?? "mws-development-secret-key-change-me-in-production-123456"; + } + services.Configure(jwtSection.Bind); + services.AddSingleton(jwtOptions); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs b/mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs new file mode 100644 index 0000000..7609625 --- /dev/null +++ b/mws.infrastructure/Migrations/20260809054503_InitialCreate.Designer.cs @@ -0,0 +1,264 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mws.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260809054503_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mws.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.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.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.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.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("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.Domain.Documents.Document", b => + { + b.HasOne("Mws.Domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + { + b.HasOne("Mws.Domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Mws.Domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + { + b.HasOne("Mws.Domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.Project", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/mws.infrastructure/Migrations/20260809054503_InitialCreate.cs b/mws.infrastructure/Migrations/20260809054503_InitialCreate.cs new file mode 100644 index 0000000..da5c5f7 --- /dev/null +++ b/mws.infrastructure/Migrations/20260809054503_InitialCreate.cs @@ -0,0 +1,180 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "documents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: false), + ParentId = table.Column(type: "uuid", nullable: true), + Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + Content = table.Column(type: "text", nullable: true), + Type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_documents", x => x.Id); + table.ForeignKey( + name: "FK_documents_documents_ParentId", + column: x => x.ParentId, + principalTable: "documents", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "projects", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, 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_projects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + DisplayName = table.Column(type: "character varying(150)", maxLength: 150, 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_users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "project_members", + columns: table => new + { + ProjectId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_project_members", x => new { x.ProjectId, x.UserId }); + table.ForeignKey( + name: "FK_project_members_projects_ProjectId", + column: x => x.ProjectId, + principalTable: "projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_project_members_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tasks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + Description = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Priority = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + AssigneeId = table.Column(type: "uuid", nullable: true), + DueDate = table.Column(type: "timestamp with time zone", nullable: true), + CreatedBy = table.Column(type: "uuid", 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_tasks", x => x.Id); + table.ForeignKey( + name: "FK_tasks_users_AssigneeId", + column: x => x.AssigneeId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_documents_ParentId", + table: "documents", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_documents_ProjectId_ParentId", + table: "documents", + columns: new[] { "ProjectId", "ParentId" }); + + migrationBuilder.CreateIndex( + name: "IX_project_members_UserId", + table: "project_members", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_tasks_AssigneeId", + table: "tasks", + column: "AssigneeId"); + + migrationBuilder.CreateIndex( + name: "IX_tasks_ProjectId", + table: "tasks", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_tasks_Status", + table: "tasks", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_users_Username", + table: "users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "documents"); + + migrationBuilder.DropTable( + name: "project_members"); + + migrationBuilder.DropTable( + name: "tasks"); + + migrationBuilder.DropTable( + name: "projects"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs b/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs new file mode 100644 index 0000000..b3123a3 --- /dev/null +++ b/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.Designer.cs @@ -0,0 +1,353 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mws.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260811133605_AddRolesAndPermissions")] + partial class AddRolesAndPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mws.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.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.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.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.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.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.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("RoleId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.HasOne("Mws.Domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + { + b.HasOne("Mws.Domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Mws.Domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + { + b.HasOne("Mws.Domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Mws.Domain.Users.User", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Mws.Domain.Roles.Role", b => + { + b.Navigation("Permissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs b/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs new file mode 100644 index 0000000..02534cd --- /dev/null +++ b/mws.infrastructure/Migrations/20260811133605_AddRolesAndPermissions.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + /// + public partial class AddRolesAndPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsActive", + table: "users", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "RoleId", + table: "users", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "roles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + IsSystem = 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_roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new + { + RoleId = table.Column(type: "uuid", nullable: false), + Screen = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CanView = table.Column(type: "boolean", nullable: false), + CanCreate = table.Column(type: "boolean", nullable: false), + CanEdit = table.Column(type: "boolean", nullable: false), + CanDelete = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.Screen }); + table.ForeignKey( + name: "FK_role_permissions_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_users_RoleId", + table: "users", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_roles_Name", + table: "roles", + column: "Name", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_users_roles_RoleId", + table: "users", + column: "RoleId", + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_users_roles_RoleId", + table: "users"); + + migrationBuilder.DropTable( + name: "role_permissions"); + + migrationBuilder.DropTable( + name: "roles"); + + migrationBuilder.DropIndex( + name: "IX_users_RoleId", + table: "users"); + + migrationBuilder.DropColumn( + name: "IsActive", + table: "users"); + + migrationBuilder.DropColumn( + name: "RoleId", + table: "users"); + } + } +} diff --git a/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs b/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs new file mode 100644 index 0000000..432a2e5 --- /dev/null +++ b/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.Designer.cs @@ -0,0 +1,365 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mws.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260812124332_AddMemberDocumentPermissions")] + partial class AddMemberDocumentPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mws.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.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.Domain.Projects.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CanCreateDocuments") + .HasColumnType("boolean"); + + b.Property("CanDeleteDocuments") + .HasColumnType("boolean"); + + b.Property("CanEditDocuments") + .HasColumnType("boolean"); + + b.Property("CanViewDocuments") + .HasColumnType("boolean"); + + 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.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.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.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.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("RoleId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.HasOne("Mws.Domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + { + b.HasOne("Mws.Domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Mws.Domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + { + b.HasOne("Mws.Domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Mws.Domain.Users.User", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Mws.Domain.Roles.Role", b => + { + b.Navigation("Permissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs b/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs new file mode 100644 index 0000000..40de99b --- /dev/null +++ b/mws.infrastructure/Migrations/20260812124332_AddMemberDocumentPermissions.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + /// + public partial class AddMemberDocumentPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CanCreateDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CanDeleteDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CanEditDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CanViewDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql("UPDATE \"project_members\" SET \"CanViewDocuments\" = true, \"CanCreateDocuments\" = true, \"CanEditDocuments\" = true, \"CanDeleteDocuments\" = true WHERE \"Role\" = 'Owner';"); + migrationBuilder.Sql("UPDATE \"project_members\" SET \"CanViewDocuments\" = true WHERE \"Role\" = 'Member';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CanCreateDocuments", + table: "project_members"); + + migrationBuilder.DropColumn( + name: "CanDeleteDocuments", + table: "project_members"); + + migrationBuilder.DropColumn( + name: "CanEditDocuments", + table: "project_members"); + + migrationBuilder.DropColumn( + name: "CanViewDocuments", + table: "project_members"); + } + } +} diff --git a/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs b/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs new file mode 100644 index 0000000..eca9b28 --- /dev/null +++ b/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.Designer.cs @@ -0,0 +1,391 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mws.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260812130925_SplitProjectMemberPermissions")] + partial class SplitProjectMemberPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mws.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.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.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.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.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.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.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.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("RoleId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.HasOne("Mws.Domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + { + b.HasOne("Mws.Domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Mws.Domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("Mws.Domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + { + b.HasOne("Mws.Domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Mws.Domain.Users.User", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Mws.Domain.Roles.Role", b => + { + b.Navigation("Permissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs b/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs new file mode 100644 index 0000000..ae6ee38 --- /dev/null +++ b/mws.infrastructure/Migrations/20260812130925_SplitProjectMemberPermissions.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mws.Infrastructure.Migrations +{ + /// + public partial class SplitProjectMemberPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "project_member_permissions", + columns: table => new + { + ProjectId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Screen = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CanView = table.Column(type: "boolean", nullable: false), + CanCreate = table.Column(type: "boolean", nullable: false), + CanEdit = table.Column(type: "boolean", nullable: false), + CanDelete = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_project_member_permissions", x => new { x.ProjectId, x.UserId, x.Screen }); + table.ForeignKey( + name: "FK_project_member_permissions_project_members_ProjectId_UserId", + columns: x => new { x.ProjectId, x.UserId }, + principalTable: "project_members", + principalColumns: new[] { "ProjectId", "UserId" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql(""" + INSERT INTO "project_member_permissions" ("ProjectId", "UserId", "Screen", "CanView", "CanCreate", "CanEdit", "CanDelete") + SELECT "ProjectId", "UserId", 'documents', "CanViewDocuments", "CanCreateDocuments", "CanEditDocuments", "CanDeleteDocuments" + FROM "project_members"; + """); + + migrationBuilder.DropColumn( + name: "CanCreateDocuments", + table: "project_members"); + + migrationBuilder.DropColumn( + name: "CanDeleteDocuments", + table: "project_members"); + + migrationBuilder.DropColumn( + name: "CanEditDocuments", + table: "project_members"); + + migrationBuilder.DropColumn( + name: "CanViewDocuments", + table: "project_members"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CanCreateDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CanDeleteDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CanEditDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CanViewDocuments", + table: "project_members", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql(""" + UPDATE "project_members" m + SET "CanViewDocuments" = p."CanView", + "CanCreateDocuments" = p."CanCreate", + "CanEditDocuments" = p."CanEdit", + "CanDeleteDocuments" = p."CanDelete" + FROM "project_member_permissions" p + WHERE p."ProjectId" = m."ProjectId" AND p."UserId" = m."UserId" AND p."Screen" = 'documents'; + """); + + migrationBuilder.DropTable( + name: "project_member_permissions"); + } + } +} diff --git a/mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs b/mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..ec9e4cd --- /dev/null +++ b/mws.infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,388 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mws.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mws.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.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mws.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.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.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.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.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.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.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.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("RoleId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.HasOne("Mws.Domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b => + { + b.HasOne("Mws.Domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Mws.Domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("Mws.Domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Mws.Domain.Roles.RolePermission", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b => + { + b.HasOne("Mws.Domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Mws.Domain.Users.User", b => + { + b.HasOne("Mws.Domain.Roles.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Mws.Domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("Mws.Domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Mws.Domain.Roles.Role", b => + { + b.Navigation("Permissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/mws.infrastructure/Persistence/AppDbContext.cs b/mws.infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..212cbd4 --- /dev/null +++ b/mws.infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Mws.Domain.Documents; +using Mws.Domain.Projects; +using Mws.Domain.Roles; +using Mws.Domain.Tasks; +using Mws.Domain.Users; + +namespace Mws.Infrastructure.Persistence; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet Projects => Set(); + public DbSet ProjectMembers => Set(); + public DbSet ProjectMemberPermissions => Set(); + public DbSet Documents => Set(); + public DbSet Tasks => Set(); + public DbSet Roles => Set(); + public DbSet RolePermissions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} \ No newline at end of file diff --git a/mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs b/mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs new file mode 100644 index 0000000..ee30608 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/DocumentConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Documents; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class DocumentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("documents"); + e.HasKey(d => d.Id); + e.Property(d => d.Title).HasMaxLength(300).IsRequired(); + e.Property(d => d.Type).HasConversion().HasMaxLength(20); + e.Property(d => d.Content).HasColumnType("text"); + + e.HasIndex(d => new { d.ProjectId, d.ParentId }); + e.HasIndex(d => d.ParentId); + + e.HasOne(d => d.Parent) + .WithMany(d => d.Children) + .HasForeignKey(d => d.ParentId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs new file mode 100644 index 0000000..84a12d7 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -0,0 +1,17 @@ +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/Configuration/ProjectMemberConfiguration.cs b/mws.infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs new file mode 100644 index 0000000..2238a85 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Projects; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class ProjectMemberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("project_members"); + e.HasKey(m => new { m.ProjectId, m.UserId }); + e.Property(m => m.Role).HasConversion().HasMaxLength(20); + + e.HasOne(m => m.Project) + .WithMany(p => p.Members) + .HasForeignKey(m => m.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(m => m.User) + .WithMany() + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs b/mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs new file mode 100644 index 0000000..cb4c5b4 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/ProjectMemberPermissionConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Projects; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class ProjectMemberPermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("project_member_permissions"); + e.HasKey(p => new { p.ProjectId, p.UserId, p.Screen }); + e.Property(p => p.Screen).HasMaxLength(50).IsRequired(); + + e.HasOne() + .WithMany() + .HasForeignKey(p => new { p.ProjectId, p.UserId }) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs b/mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs new file mode 100644 index 0000000..b597ca7 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/RoleConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Roles; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class RoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("roles"); + e.HasKey(r => r.Id); + e.Property(r => r.Name).HasMaxLength(100).IsRequired(); + e.HasIndex(r => r.Name).IsUnique(); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs b/mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs new file mode 100644 index 0000000..c3040c0 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/RolePermissionConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Roles; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class RolePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("role_permissions"); + e.HasKey(p => new { p.RoleId, p.Screen }); + e.Property(p => p.Screen).HasMaxLength(50).IsRequired(); + + e.HasOne(p => p.Role) + .WithMany(r => r.Permissions) + .HasForeignKey(p => p.RoleId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs b/mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs new file mode 100644 index 0000000..fdde103 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/TaskConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Tasks; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class TaskConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("tasks"); + e.HasKey(t => t.Id); + e.Property(t => t.Title).HasMaxLength(300).IsRequired(); + e.Property(t => t.Description).HasColumnType("text"); + e.Property(t => t.Status).HasConversion().HasMaxLength(20); + e.Property(t => t.Priority).HasConversion().HasMaxLength(20); + + e.HasIndex(t => t.ProjectId); + e.HasIndex(t => t.AssigneeId); + e.HasIndex(t => t.Status); + + e.HasOne(t => t.Assignee) + .WithMany() + .HasForeignKey(t => t.AssigneeId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/mws.infrastructure/Persistence/Configuration/UserConfiguration.cs b/mws.infrastructure/Persistence/Configuration/UserConfiguration.cs new file mode 100644 index 0000000..47de922 --- /dev/null +++ b/mws.infrastructure/Persistence/Configuration/UserConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mws.Domain.Users; + +namespace Mws.Infrastructure.Persistence.Configuration; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("users"); + e.HasKey(u => u.Id); + e.Property(u => u.Username).HasMaxLength(100).IsRequired(); + 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/mws.infrastructure/Persistence/DbSeeder.cs b/mws.infrastructure/Persistence/DbSeeder.cs new file mode 100644 index 0000000..96cd442 --- /dev/null +++ b/mws.infrastructure/Persistence/DbSeeder.cs @@ -0,0 +1,178 @@ +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; + +namespace Mws.Infrastructure.Persistence; + +public static class DbSeeder +{ + public static async Task SeedAsync(AppDbContext db, IPasswordHasher passwordHasher) + { + if (await db.Users.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, + CreatedAt = now, + UpdatedAt = now, + }; + + db.Projects.Add(project); + + db.ProjectMembers.AddRange( + new ProjectMember { ProjectId = project.Id, UserId = admin.Id, Role = MemberRole.Owner }, + new ProjectMember { ProjectId = project.Id, UserId = alice.Id, Role = MemberRole.Member }, + new ProjectMember { ProjectId = project.Id, UserId = bob.Id, Role = MemberRole.Member }); + + db.ProjectMemberPermissions.AddRange( + new ProjectMemberPermission { ProjectId = project.Id, UserId = alice.Id, Screen = ProjectPermissionScreens.Documents, CanView = true }, + new ProjectMemberPermission { ProjectId = project.Id, UserId = bob.Id, Screen = ProjectPermissionScreens.Documents, CanView = true }); + + await db.SaveChangesAsync(); + + var requirements = new Document + { + Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = null, + Title = "Requirements", Type = DocumentType.Folder, + CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, + }; + + var design = new Document + { + Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = null, + Title = "Technical Design", Type = DocumentType.Folder, + CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, + }; + + var notes = new Document + { + Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = null, + Title = "Notes", Type = DocumentType.Folder, + CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, + }; + + db.Documents.AddRange(requirements, design, notes); + db.Documents.Add(new Document + { + Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id, + Title = "Authentication", Type = DocumentType.Document, + Content = "

Authentication

Phase 1 uses simple username/password with JWT.

", + CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, + }); + db.Documents.Add(new Document + { + Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id, + Title = "User Management", Type = DocumentType.Document, + Content = "

User Management

Users are created via seeding in Phase 1.

", + CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, + }); + db.Documents.Add(new Document + { + Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = design.Id, + Title = "API Design", Type = DocumentType.Document, + Content = "

API Design

REST API with ASP.NET Core Web API.

", + CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now, + }); + await db.SaveChangesAsync(); + + db.Tasks.AddRange( + new TaskItem + { + Id = Guid.NewGuid(), ProjectId = project.Id, Title = "Set up solution", + Status = TaskStatus.Done, Priority = TaskPriority.High, AssigneeId = admin.Id, + CreatedBy = admin.Id, CreatedAt = now, UpdatedAt = now, + }, + new TaskItem + { + Id = Guid.NewGuid(), ProjectId = project.Id, Title = "Implement authentication", + Description = "Login endpoint with BCrypt and JWT.", + Status = TaskStatus.InProgress, Priority = TaskPriority.High, AssigneeId = alice.Id, + DueDate = now.AddDays(3), + CreatedBy = admin.Id, CreatedAt = now, UpdatedAt = now, + }, + new TaskItem + { + Id = Guid.NewGuid(), ProjectId = project.Id, Title = "Task board drag & drop", + Status = TaskStatus.Todo, Priority = TaskPriority.Medium, AssigneeId = bob.Id, + DueDate = now.AddDays(7), + CreatedBy = admin.Id, CreatedAt = now, UpdatedAt = now, + }, + new TaskItem + { + Id = Guid.NewGuid(), ProjectId = project.Id, Title = "Polish documents UI", + Status = TaskStatus.Todo, Priority = TaskPriority.Low, + CreatedBy = admin.Id, CreatedAt = now, UpdatedAt = now, + }); + + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/mws.infrastructure/Persistence/Repositories/DocumentRepository.cs b/mws.infrastructure/Persistence/Repositories/DocumentRepository.cs new file mode 100644 index 0000000..ff56bb0 --- /dev/null +++ b/mws.infrastructure/Persistence/Repositories/DocumentRepository.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Mws.Application.Common.Repositories; +using Mws.Domain.Documents; + +namespace Mws.Infrastructure.Persistence.Repositories; + +public class DocumentRepository(AppDbContext db) : RepositoryBase(db), IDocumentRepository +{ + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Set.FirstOrDefaultAsync(d => d.Id == id, ct); + + public Task> GetTreeForProjectAsync(Guid projectId, CancellationToken ct = default) => + Set.Where(d => d.ProjectId == projectId).OrderBy(d => d.Title).ToListAsync(ct); + + public Task GetInProjectAsync(Guid parentId, Guid projectId, CancellationToken ct = default) => + Set.FirstOrDefaultAsync(d => d.Id == parentId && d.ProjectId == projectId, ct); + + public Task GetParentIdAsync(Guid documentId, CancellationToken ct = default) => + Set.Where(d => d.Id == documentId).Select(d => d.ParentId).SingleAsync(ct); + + public Task> GetChildrenAsync(Guid parentId, CancellationToken ct = default) => + Set.Where(d => d.ParentId == parentId).ToListAsync(ct); + + public Task> SearchAsync(List projectIds, string? term, int take, CancellationToken ct = default) + { + var query = Set.Where(d => projectIds.Contains(d.ProjectId)); + if (!string.IsNullOrWhiteSpace(term)) + { + var lower = term.Trim().ToLower(); + query = query.Where(d => d.Title.ToLower().Contains(lower)); + } + + return query.OrderByDescending(d => d.UpdatedAt).Take(take).ToListAsync(ct); + } + + public Task CountByTypeForProjectAsync(Guid projectId, DocumentType type, CancellationToken ct = default) => + Set.CountAsync(d => d.ProjectId == projectId && d.Type == type, ct); + + public Task> GetRecentForProjectAsync(Guid projectId, DocumentType type, int take, CancellationToken ct = default) => + Set.Where(d => d.ProjectId == projectId && d.Type == type).OrderByDescending(d => d.UpdatedAt).Take(take).ToListAsync(ct); +} diff --git a/mws.infrastructure/Persistence/Repositories/ProjectRepository.cs b/mws.infrastructure/Persistence/Repositories/ProjectRepository.cs new file mode 100644 index 0000000..59349fa --- /dev/null +++ b/mws.infrastructure/Persistence/Repositories/ProjectRepository.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using Mws.Application.Common.Repositories; +using Mws.Domain.Projects; + +namespace Mws.Infrastructure.Persistence.Repositories; + +public class ProjectRepository(AppDbContext db) : RepositoryBase(db), IProjectRepository +{ + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + 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); + + public Task> GetForUserAsync(Guid userId, CancellationToken ct = default) => + Set.Where(p => p.Members.Any(m => m.UserId == userId)) + .OrderByDescending(p => p.UpdatedAt) + .ToListAsync(ct); + + public Task> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default) + { + var query = Set.Where(p => p.Members.Any(m => m.UserId == userId)); + if (!string.IsNullOrWhiteSpace(term)) + { + var lower = term.Trim().ToLower(); + query = query.Where(p => p.Name.ToLower().Contains(lower)); + } + + return query.OrderByDescending(p => p.UpdatedAt).ToListAsync(ct); + } + + public Task CountMembersAsync(Guid projectId, CancellationToken ct = default) => + Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId, ct); + + public Task IsMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default) => + Db.ProjectMembers.AnyAsync(m => m.ProjectId == projectId && m.UserId == userId, ct); + + public Task GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default) => + Db.ProjectMembers + .Where(m => m.ProjectId == projectId && m.UserId == userId) + .Select(m => (MemberRole?)m.Role) + .SingleOrDefaultAsync(ct); + + public Task GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default) => + Db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct); + + 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) => + Db.ProjectMembers + .Where(m => m.ProjectId == projectId) + .Include(m => m.User) + .OrderByDescending(m => m.Role) + .ThenBy(m => m.User.DisplayName) + .ToListAsync(ct); + + public Task CountOwnersAsync(Guid projectId, CancellationToken ct = default) => + Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct); + + public Task> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default) => + Set.Where(p => p.Members.Any(m => m.UserId == userId)).Select(p => p.Id).ToListAsync(ct); + + public Task> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default) => + Db.ProjectMembers.Where(m => m.UserId == userId && m.Role == MemberRole.Owner).Select(m => m.ProjectId).ToListAsync(ct); + + public Task> GetDocumentViewableProjectIdsAsync(Guid userId, CancellationToken ct = default) => + Db.ProjectMembers + .Where(m => m.UserId == userId && + (m.Role == MemberRole.Owner || + Db.ProjectMemberPermissions.Any(p => p.ProjectId == m.ProjectId && p.UserId == userId && p.Screen == ProjectPermissionScreens.Documents && p.CanView))) + .Select(m => m.ProjectId) + .ToListAsync(ct); + + public void AddMember(ProjectMember member) => Db.ProjectMembers.Add(member); + + public void RemoveMember(ProjectMember member) => Db.ProjectMembers.Remove(member); + + public Task GetMemberPermissionAsync(Guid projectId, Guid userId, string screen, CancellationToken ct = default) => + Db.ProjectMemberPermissions.FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId && p.Screen == screen, ct); + + public Task> GetMemberPermissionsAsync(Guid projectId, string screen, CancellationToken ct = default) => + Db.ProjectMemberPermissions + .Where(p => p.ProjectId == projectId && p.Screen == screen) + .ToDictionaryAsync(p => p.UserId, ct); + + public void AddMemberPermission(ProjectMemberPermission permission) => Db.ProjectMemberPermissions.Add(permission); +} diff --git a/mws.infrastructure/Persistence/Repositories/RepositoryBase.cs b/mws.infrastructure/Persistence/Repositories/RepositoryBase.cs new file mode 100644 index 0000000..c830a29 --- /dev/null +++ b/mws.infrastructure/Persistence/Repositories/RepositoryBase.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Mws.Application.Common.Repositories; + +namespace Mws.Infrastructure.Persistence.Repositories; + +public abstract class RepositoryBase(AppDbContext db) : IRepository where T : class +{ + protected readonly AppDbContext Db = db; + protected DbSet Set => Db.Set(); + + public void Add(T entity) => Set.Add(entity); + public void Remove(T entity) => Set.Remove(entity); +} diff --git a/mws.infrastructure/Persistence/Repositories/RoleRepository.cs b/mws.infrastructure/Persistence/Repositories/RoleRepository.cs new file mode 100644 index 0000000..d2ad747 --- /dev/null +++ b/mws.infrastructure/Persistence/Repositories/RoleRepository.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Mws.Application.Common.Repositories; +using Mws.Domain.Roles; + +namespace Mws.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 GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default) => + Set.Include(r => r.Permissions).FirstOrDefaultAsync(r => r.Id == id, ct); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Set.FirstOrDefaultAsync(r => r.Id == id, ct); + + public Task ExistsByNameAsync(string name, Guid? excludeId, CancellationToken ct = default) => + Set.AnyAsync(r => r.Name == name && (excludeId == null || r.Id != excludeId), ct); + + public Task GetPermissionAsync(Guid roleId, string screen, CancellationToken ct = default) => + Db.RolePermissions.FirstOrDefaultAsync(p => p.RoleId == roleId && p.Screen == screen, ct); + + public Task> GetPermissionsAsync(Guid roleId, CancellationToken ct = default) => + Db.RolePermissions.Where(p => p.RoleId == roleId).ToDictionaryAsync(p => p.Screen, ct); + + public void RemovePermissions(IEnumerable permissions) => Db.RolePermissions.RemoveRange(permissions); +} diff --git a/mws.infrastructure/Persistence/Repositories/TaskRepository.cs b/mws.infrastructure/Persistence/Repositories/TaskRepository.cs new file mode 100644 index 0000000..643d24a --- /dev/null +++ b/mws.infrastructure/Persistence/Repositories/TaskRepository.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Mws.Application.Common.Repositories; +using Mws.Domain.Tasks; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.Infrastructure.Persistence.Repositories; + +public class TaskRepository(AppDbContext db) : RepositoryBase(db), ITaskRepository +{ + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Set.FirstOrDefaultAsync(t => t.Id == id, ct); + + 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) + { + var query = Set.Where(t => t.ProjectId == projectId); + + if (status.HasValue) + { + query = query.Where(t => t.Status == status); + } + if (priority.HasValue) + { + query = query.Where(t => t.Priority == priority); + } + if (assigneeId.HasValue) + { + query = query.Where(t => t.AssigneeId == assigneeId); + } + + return query.Include(t => t.Assignee).OrderByDescending(t => t.UpdatedAt).ToListAsync(ct); + } + + public Task> SearchWithAssigneeAsync(List projectIds, string? term, int take, CancellationToken ct = default) + { + var query = Set.Where(t => projectIds.Contains(t.ProjectId)); + if (!string.IsNullOrWhiteSpace(term)) + { + var lower = term.Trim().ToLower(); + query = query.Where(t => t.Title.ToLower().Contains(lower)); + } + + return query.Include(t => t.Assignee).OrderByDescending(t => t.UpdatedAt).Take(take).ToListAsync(ct); + } + + public async Task> CountByStatusForProjectAsync(Guid projectId, CancellationToken ct = default) + { + return await Set.Where(t => t.ProjectId == projectId) + .GroupBy(t => t.Status) + .Select(g => new { Status = g.Key, Count = g.Count() }) + .ToDictionaryAsync(a => a.Status, a => a.Count, ct); + } + + public Task> GetRecentForProjectAsync(Guid projectId, int take, CancellationToken ct = default) => + Set.Where(t => t.ProjectId == projectId).OrderByDescending(t => t.UpdatedAt).Take(take).ToListAsync(ct); +} diff --git a/mws.infrastructure/Persistence/Repositories/UserRepository.cs b/mws.infrastructure/Persistence/Repositories/UserRepository.cs new file mode 100644 index 0000000..4b6e244 --- /dev/null +++ b/mws.infrastructure/Persistence/Repositories/UserRepository.cs @@ -0,0 +1,44 @@ +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); + } +} diff --git a/mws.infrastructure/Persistence/UnitOfWork.cs b/mws.infrastructure/Persistence/UnitOfWork.cs new file mode 100644 index 0000000..13a6aaf --- /dev/null +++ b/mws.infrastructure/Persistence/UnitOfWork.cs @@ -0,0 +1,28 @@ +using Mws.Application.Common; +using Mws.Application.Common.Repositories; +using Mws.Infrastructure.Persistence.Repositories; + +namespace Mws.Infrastructure.Persistence; + +public class UnitOfWork : IUnitOfWork +{ + private readonly AppDbContext _db; + + public UnitOfWork(AppDbContext db) + { + _db = db; + Users = new UserRepository(db); + Projects = new ProjectRepository(db); + Tasks = new TaskRepository(db); + Documents = new DocumentRepository(db); + Roles = new RoleRepository(db); + } + + public IUserRepository Users { get; } + public IProjectRepository Projects { get; } + public ITaskRepository Tasks { get; } + public IDocumentRepository Documents { get; } + public IRoleRepository Roles { get; } + + public Task SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); +} diff --git a/mws.infrastructure/mws.infrastructure.csproj b/mws.infrastructure/mws.infrastructure.csproj new file mode 100644 index 0000000..f883ec5 --- /dev/null +++ b/mws.infrastructure/mws.infrastructure.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + +